o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx

763 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useEffect } from 'react';
import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
import { useListItems, useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types';
import { useLabels, useVatMode } from '@/features/settings/useCompanySettings';
import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { useScrollLock } from '@/lib/useScrollLock';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox, type ComboOption } from '@/components/ui/combobox';
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
import {
is1v1,
toQuotationType,
PriceGateAction,
DEFAULT_MID_ACTION,
DEFAULT_OVER_ACTION,
type QuotationMode,
} from '../types';
import { showToast } from '@/lib/notify';
import { defaultDueDateLocalInput, nowKstLocalInput, isFutureLocalInput } from './quotationForm.utils';
import { SupplyTypeBadge, SelectedPartnerTable, SelectedCardTable, Segmented, AwardLinePicker } from './QuotationFormParts';
import { useTargetPrice } from '../hooks/useTargetPrice';
import { useCardGating } from '../hooks/useCardGating';
type QuotationCreateModalProps = {
open: boolean;
products: Product[];
partners: Partner[];
cards: NegotiationCard[];
quotationSettings: QuotationSetting[];
onCreate: (input: CreateQuotationInput) => boolean | Promise<boolean>;
onClose: () => void;
};
export function QuotationCreateModal({
open,
products,
partners,
cards,
quotationSettings,
onCreate,
onClose,
}: QuotationCreateModalProps) {
// 모달 열린 동안 배경(부모) 스크롤 잠금 — 뒤 페이지가 같이 스크롤되는 것 방지.
useScrollLock();
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
// 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다.
const [mode, setMode] = useState<QuotationMode>('nego'); // 1:1 협상 / 1:N 경매
const [isNew, setIsNew] = useState(true); // 신규 / 후속(재)
const [productId, setProductId] = useState('');
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
const [dueDate, setDueDate] = useState(defaultDueDateLocalInput);
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
// 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다.
const [partnerDetails, setPartnerDetails] = useState<Map<string, { name: string; email: string }>>(() => new Map());
const [cardDetails, setCardDetails] = useState<Map<string, { code: string; title: string; isWildcard: boolean }>>(() => new Map());
const [memo, setMemo] = useState('');
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
const [ceilingPct, setCeilingPct] = useState(''); // 협상 완료 상한율(%) 이 견적 override. 비우면 세팅 기본값
const [ceilingTouched, setCeilingTouched] = useState(false); // 상한율을 직접 건드렸는지 — 안 건드렸으면 세팅값 표시
const [submitting, setSubmitting] = useState(false);
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
// 네고율 차감(매입가·판매가 공통) — 이 견적에서만 조정. 안 건드리면 세팅 기본값을 따른다(negoTouched=false). 네고율 값 자체는 세팅값 고정.
const [negoTouched, setNegoTouched] = useState(false);
const [applyNego, setApplyNego] = useState(false); // 네고율 차감 여부
// 목표가로 채택한 후보 키 — null이면 최저 후보를 기본 채택.
const [selectedCandidateKey, setSelectedCandidateKey] = useState<string | null>(null);
const type = toQuotationType(mode, isNew); // 4코드 합성값
const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다
const isReType = !isNew;
// 스텝 구성 — 1:1 협상만 협상카드 스텝 추가(작은 화면 과밀 방지). 경매는 3스텝.
const steps = oneToOne
? ['기본 정보', '협력사 초청', '낙찰 기준', '협상카드']
: ['기본 정보', '협력사 초청', '확인·완료'];
const totalSteps = steps.length;
const navigate = useNavigate();
// 세팅 목록은 비동기 도착 — useState 초기값은 최초 마운트 때 목록이 비어 있으면 ''로 굳는다.
// 목록이 채워지면 기본(첫) 세팅을 선택하고, 이미 유효한 선택은 유지한다.
useEffect(() => {
if (quotationSettings.length === 0) return;
setSettingId((prev) => (prev && quotationSettings.some((s) => s.qt_setting_id === prev) ? prev : quotationSettings[0].qt_setting_id));
}, [quotationSettings]);
// ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출.
const [productQ, setProductQ] = useState('');
const [productLabel, setProductLabel] = useState('');
const [supplierQ, setSupplierQ] = useState('');
const [cardQ, setCardQ] = useState('');
const productSearch = useListItems({ search: productQ || undefined, size: 30 });
const supplierSearch = useListSuppliers({ search: supplierQ || undefined, size: 30 });
const cardSearch = useListCards({ search: cardQ || undefined, size: 30 });
// 선택 상품은 검색으로 목록이 좁혀져도 파생값(목표가 후보)이 안 깨지게 id로 단건 조회한다.
const selItem = useGetItem(productId, { query: { enabled: !!productId } }).data?.item ?? null;
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
const unitPrice = selItem?.price ?? null;
const internetLowest = selItem?.internet_lowest_price ?? null;
const purchase = selItem?.purchase_price ?? null;
const selling = selItem?.selling_price ?? null;
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const vatUnified = useVatMode() === 'unified_excluded'; // 부가세 전체 통일 — 목표가 입력 기준 안내
const supplyTypeBySupplier = useMemo(() => {
const m = new Map<string, number>();
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
return m;
}, [supplyTypeQuery.data]);
// 콤보박스 옵션 — 미검색이면 부모 목록, 검색 중이면 서버결과.
const productOptions: ComboOption[] = productQ
? (productSearch.data?.items ?? []).map((it) => ({ id: it.item_id, label: `${it.name}${it.code ? ` [${it.code}]` : ''}` }))
: products.map((p) => ({ id: p.id ?? '', label: `${p.name}${p.code ? ` [${p.code}]` : ''}` }));
const supplierRows = supplierQ
? (supplierSearch.data?.suppliers ?? []).map((sp) => ({ id: sp.supplier_id, name: sp.name, email: sp.manager_email ?? '' }))
: partners.map((p) => ({ id: p.id ?? '', name: p.name, email: p.managerEmail ?? '' }));
const supplierOptions: ComboOption[] = supplierRows.map((s) => ({
id: s.id,
label: s.name,
node: (
<div className="flex items-center justify-between gap-2 w-full">
<div className="min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">{s.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">: {s.email}</Typography>
</div>
{productId && <SupplyTypeBadge type={supplyTypeBySupplier.get(s.id)} />}
</div>
),
}));
// 선택된 협력사 표시행 — 이름/이메일은 캐시에서, 취급유형은 상품 매핑(supplyTypeBySupplier)에서 라이브로 읽는다.
const selectedPartnerRows = selectedPartnerIds.map((id) => {
const d = partnerDetails.get(id);
return { id, name: d?.name ?? id, email: d?.email ?? '' };
});
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
// ── 목표가 산정 (카드 게이팅이 목표가를 참조하므로 게이팅보다 먼저 계산한다) ──
const {
settingMarginPct,
negoToggleAvailable,
targetBreakdown,
autoTarget,
activeCandidateKey,
effectiveApplyNego,
effectiveMdPrice,
mdRequired,
targetReady,
targetLimitExceeded,
estimatedTargetPrice,
submitMdPrice,
settingCeilingRate,
doneCeilingPrice,
} = useTargetPrice({
quotationSettings,
settingId,
productId,
isReType,
internetLowest,
purchase,
selling,
unitPrice,
mdPrice,
mdTouched,
negoTouched,
applyNego,
selectedCandidateKey,
doneCeilingRateOverride: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : null,
});
// ── 카드 선택 게이팅 — 부적합 카드 disabled·자동 선택/해제(useCardGating 이 소유) ──
const { blockReason, cardOptions, selectedCardRows } = useCardGating({
cardRows,
productId,
internetLowest,
estimatedTargetPrice,
open,
selectedCardIds,
cardDetails,
setSelectedCardIds,
setCardDetails,
});
if (!open) return null;
const selectMode = (next: QuotationMode) => {
setMode(next);
// 경매→협상 전환 시 다중선택으로 담긴 협력사를 1곳으로 줄인다(1:1 단일선택).
if (next === 'nego') setSelectedPartnerIds((prev) => prev.slice(0, 1));
// 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다.
if (next === 'auction') setStep((s) => Math.min(s, 3));
};
const togglePartner = (id: string) => {
// 담는 순간 이름/이메일을 캐시에 적재 — 이후 검색어가 바뀌어 목록에서 빠져도 선택 테이블이 유지된다.
const row = supplierRows.find((r) => r.id === id);
if (row) setPartnerDetails((m) => new Map(m).set(id, { name: row.name, email: row.email }));
setSelectedPartnerIds((prev) =>
oneToOne
? prev.includes(id)
? []
: [id]
: prev.includes(id)
? prev.filter((p) => p !== id)
: [...prev, id],
);
};
const toggleCard = (id: string) => {
const row = cardRows.find((c) => c.id === id);
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
};
// 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지.
const selectAllCards = () => {
const rows = cardRows.filter((c) => !blockReason(c));
setCardDetails((m) => {
const next = new Map(m);
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
return next;
});
setSelectedCardIds((prev) => [...new Set([...prev, ...rows.map((r) => r.id)])]);
};
const clearAllCards = () => setSelectedCardIds([]);
// 후보 택1 — 그 후보값이 목표가로 들어가게 md 직접입력 오버라이드를 해제한다.
const selectCandidate = (key: string) => {
setSelectedCandidateKey(key);
setMdTouched(false);
setMdPrice('');
};
const handleSubmit = async () => {
if (submitting) return;
setSubmitting(true);
try {
// 목표가 산정에 쓸 값이 없으면(MD가·상품 후보 전무) 생성 차단 — 서버 산정불가 에러 선제 방어.
if (productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
return; // finally 에서 submitting 해제
}
if (!isFutureLocalInput(dueDate)) {
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
return;
}
if (targetLimitExceeded) {
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
return;
}
// 낙찰 기준은 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 카드도 1:1 전용.
const ok = await onCreate({
title,
type,
productId,
partnerIds: selectedPartnerIds,
dueDate,
settingId,
cardIds: oneToOne ? selectedCardIds : [],
memo,
mdPrice: submitMdPrice,
midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined,
// 완료 상한율 override — 직접 건드렸을 때만 전송(‰). 비우면 서버가 세팅 기본값 사용.
doneCeilingRate: ceilingTouched && ceilingPct !== '' ? Math.round(Number(ceilingPct) * 10) : undefined,
});
if (ok) onClose();
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
{submitting && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
<Loader2 className="text-primary animate-spin" size={44} strokeWidth={2.5} />
<Typography variant="small" className="font-semibold font-mono"> </Typography>
<Typography variant="small" className="text-muted-foreground font-mono"> · </Typography>
</div>
</div>
)}
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 flex flex-col max-h-[90vh] overflow-hidden animate-scale-up font-mono">
{/* Header */}
<div className="shrink-0 flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} />
<Typography variant="small" className="font-bold"> ( {step}/{totalSteps})</Typography>
</div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
</button>
</div>
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */}
<div className="shrink-0 flex items-center justify-between gap-2 py-4 border-b border-border/40 text-muted-foreground">
{steps.map((stepLabel, i) => {
const n = i + 1;
return (
<div key={stepLabel} className="flex items-center gap-2 min-w-0">
{i > 0 && <ArrowRight size={14} className="shrink-0" />}
<button type="button" onClick={() => setStep(n)} className="cursor-pointer hover:opacity-75 transition-opacity truncate">
<Typography as="span" variant="body" className={`font-semibold ${step === n ? 'text-primary' : 'text-muted-foreground'}`}>{n}. {stepLabel}</Typography>
</button>
</div>
);
})}
</div>
{/* Step content — 남은 높이를 채우고 내용이 길면 여기만 스크롤(작은 화면 대응) */}
<div className="flex-1 overflow-y-auto min-h-0 my-6 pr-1 text-xs text-foreground space-y-4">
{step === 1 && (
<div className="space-y-4">
{/* 진행 방식 × 대상 2축 — 협상/경매 갈림이 아래 카드·낙찰기준 노출까지 결정한다 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<Typography as="label" variant="label"> </Typography>
<Segmented
options={[
{ value: 'nego', label: '1:1 협상', sub: '협상카드·낙찰기준 선택' },
{ value: 'auction', label: '1:N 견적', sub: '최저가 자동 낙찰' },
]}
value={mode}
onChange={(v) => selectMode(v as QuotationMode)}
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Segmented
options={[
{ value: 'new', label: '신규', sub: '처음 진행하는 견적' },
{ value: 're', label: '후속·재', sub: '이전 견적에 이어 진행' },
]}
value={isNew ? 'new' : 're'}
onChange={(v) => setIsNew(v === 'new')}
/>
</div>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">{label('quotation.due_date')}</Typography>
<input
id="wizard-date"
type="datetime-local"
className="w-full p-2 bg-background border border-border rounded text-xs"
value={dueDate}
min={nowKstLocalInput()}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="label">{label('quotation.title')}</Typography>
<Input
id="wizard-title"
type="text"
className="text-xs"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 6월 배터리 원부자재 견적 의뢰"
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Combobox
id="wizard-product"
options={productOptions}
loading={productSearch.isLoading}
onQueryChange={setProductQ}
value={productId || undefined}
selectedLabel={productLabel}
onSelect={(opt) => { setProductId(opt.id); setProductLabel(opt.label); }}
placeholder="협상 대상 상품을 고르세요..."
searchPlaceholder="상품명·코드로 검색..."
emptyText="일치하는 상품이 없습니다"
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-3">
<Typography as="span" variant="label" className="block"> ({oneToOne ? '단일선택' : '다중선택'})</Typography>
{/* 서버검색 다중선택 — 각 행에 선택 상품 상품조달유형 배지(미매핑=미정). oneToOne이면 togglePartner가 단일로 강제. */}
<Combobox
variant="inline"
multiple
values={selectedPartnerIds}
options={supplierOptions}
loading={supplierSearch.isLoading}
onQueryChange={setSupplierQ}
onToggle={(opt) => togglePartner(opt.id)}
searchPlaceholder="협력사명·코드·담당자 검색..."
emptyText="협력사가 없습니다"
maxListHeight="max-h-56"
/>
{/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */}
<div className="space-y-1">
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
{selectedPartnerRows.length}
</Typography>
<SelectedPartnerTable
rows={selectedPartnerRows}
supplyTypeBySupplier={supplyTypeBySupplier}
showSupplyType={!!productId}
onRemove={togglePartner}
/>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="space-y-1 font-mono text-xs">
<Typography as="label" variant="label"> </Typography>
<Select value={settingId} onValueChange={(v) => setSettingId(v ?? '')}>
<SelectTrigger id="wizard-setting-select" className="w-full">
<SelectValue>
{(value) => {
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
return qs
? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count} · 타결상한 +${qs.done_ceiling_rate / 10}%`
: '';
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{quotationSettings.map((qs) => (
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
[{label('target_margin')}: {qs.target_margin}] {qs.card_use_count} · +{qs.done_ceiling_rate / 10}%
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* ── 타결 기준 (협상) — 봇이 어느 가격까지 합의하면 타결로 볼지 ── */}
<div className="rounded-lg border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border bg-muted/30">
<span className="grid place-items-center h-5 w-5 rounded-md bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400 shrink-0"><CheckCheck size={12} /></span>
<div className="min-w-0">
<Typography as="span" variant="small" className="font-bold block leading-tight"> <span className="text-muted-foreground font-normal text-[10px]">· </span></Typography>
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] block leading-tight"> </Typography>
</div>
</div>
<div className="p-3 space-y-4">
{/* 목표가 산정 후보 — 후보 택1로 목표가 결정(기본=최저). 네고율 차감 토글은 매입가·판매가 공통이라 리스트 상단에 둔다. 숨김필드는 제외. */}
{productId && targetBreakdown.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Typography as="span" variant="label">
<span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
</Typography>
<button
type="button"
onClick={() => navigate(`/products?detail=${productId}`)}
className={cn(typographyVariants({ variant: 'link' }), 'text-[10px]')}
>
</button>
</div>
<div className="flex items-center justify-between gap-2">
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
(기본: 최저).
</Typography>
{negoToggleAvailable && (
<label className="flex items-center gap-1 shrink-0 cursor-pointer">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary cursor-pointer"
checked={effectiveApplyNego}
onChange={(e) => {
setNegoTouched(true);
setApplyNego(e.target.checked);
}}
/>
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground whitespace-nowrap"> {settingMarginPct}% </Typography>
</label>
)}
</div>
<div className="space-y-1" role="radiogroup">
{targetBreakdown.map((c) => {
const isMin = autoTarget != null && c.value === autoTarget;
const isActive = c.key === activeCandidateKey;
return (
<div
key={c.key}
role="radio"
aria-checked={isActive}
tabIndex={0}
onClick={() => selectCandidate(c.key)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectCandidate(c.key);
}
}}
className={cn(
'flex items-center gap-2 rounded px-2 py-1.5 cursor-pointer transition-colors',
isActive ? 'bg-primary/5 ring-1 ring-inset ring-primary' : 'hover:bg-muted/40',
)}
>
{/* 라디오 표시 */}
<span className={cn('grid place-items-center h-3.5 w-3.5 shrink-0 rounded-full border', isActive ? 'border-primary' : 'border-muted-foreground/40')}>
{isActive && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
</span>
<Typography as="span" variant="small" className="flex-1 min-w-0 truncate text-[11px] text-muted-foreground">{c.label}</Typography>
<Typography as="span" variant="small" className="w-16 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground/70">{c.raw.toLocaleString()}</Typography>
<Typography as="span" variant="small" className={cn('w-20 shrink-0 text-right font-mono font-bold tabular-nums', isActive && 'text-primary')}>{c.value.toLocaleString()}</Typography>
<span className="w-9 shrink-0 text-right">
{isMin && <span className="rounded-full border border-emerald-600 px-1.5 text-[8px] font-bold text-emerald-700 dark:text-emerald-400"></span>}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 목표가(구매담당자 제시가) — 후보 최저를 자동 입력. 값이 높든 낮든 이 값이 1순위(실제 목표가). 수정 가능. */}
<div className="space-y-1">
<Typography as="label" variant="label" className={mdRequired ? 'text-rose-500' : undefined}>
( ) {mdRequired ? '(필수 — 산정값 없음)' : ''}
</Typography>
<Input
id="wizard-md-price"
type="number"
min={0}
className="text-xs"
value={effectiveMdPrice}
onChange={(e) => {
setMdTouched(true);
setMdPrice(e.target.value);
}}
placeholder={mdRequired ? '상품에 산정값이 없어 직접 입력이 필요합니다' : '자동 산출값 · 수정 가능'}
/>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
{autoTarget != null
? '위에서 선택한 후보값이 목표가(1순위)로 입력됩니다. 수정 가능.'
: '자동 산출값이 없어 직접 입력이 필요합니다.'}
{vatUnified && ' 금액은 VAT 별도(제외) 기준입니다.'}
</Typography>
{!targetReady && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
· .
</Typography>
)}
{targetLimitExceeded && (
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
{estimatedTargetPrice?.toLocaleString()} 2 .
</Typography>
)}
</div>
{/* 타결 상한 — 목표가 초과 허용폭. 봇이 이 이하로 합의하면 타결, 초과하면 결렬. 비우면 세팅 기본율. */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Typography as="label" variant="label"> </Typography>
{/* OFF=세팅 기본율 그대로 · ON=이 견적만 직접 지정 */}
<label className="flex items-center gap-1.5 cursor-pointer">
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground"> </Typography>
<Switch
checked={ceilingTouched}
onCheckedChange={(on) => {
setCeilingTouched(on);
if (on && ceilingPct === '') setCeilingPct(String(settingCeilingRate / 10)); // 켤 때 세팅값에서 출발
}}
/>
</label>
</div>
{ceilingTouched ? (
<div className="flex items-center gap-1.5">
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground"> +</Typography>
<Input
type="number"
step="0.5"
min={0}
className="h-8 w-20 text-xs text-right"
value={ceilingPct}
onChange={(e) => setCeilingPct(e.target.value)}
/>
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground">%</Typography>
</div>
) : (
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
<span className="font-semibold text-foreground"> +{settingCeilingRate / 10}%</span>
</Typography>
)}
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
{doneCeilingPrice != null ? (
<> <span className="font-bold text-foreground">{doneCeilingPrice.toLocaleString()}</span> , .</>
) : '목표가가 정해지면 타결 상한가가 자동 계산됩니다.'}
</Typography>
</div>
</div>
</div>
{/* ── 낙찰 기준 (마감) — 타결된 투찰가로 누구를 낙찰시킬지 ── */}
<div className="rounded-lg border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border bg-muted/30">
<span className="grid place-items-center h-5 w-5 rounded-md bg-primary/10 text-primary shrink-0"><Gavel size={12} /></span>
<div className="min-w-0">
<Typography as="span" variant="small" className="font-bold block leading-tight"> <span className="text-muted-foreground font-normal text-[10px]">· </span></Typography>
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] block leading-tight"> </Typography>
</div>
</div>
<div className="p-3">
{oneToOne ? (
/* 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택1, 목표가 초과는 항상 개찰. */
<AwardLinePicker
mid={midAction}
over={overAction}
// 단조성: 초과=낙찰이면 앵커~목표가도 낙찰, 앵커~목표가=개찰이면 초과도 개찰(혼합 조합 방지)
onMid={(v) => {
setMidAction(v);
if (v === PriceGateAction.OPEN) setOverAction(PriceGateAction.OPEN);
}}
onOver={(v) => {
setOverAction(v);
if (v === PriceGateAction.AWARD) setMidAction(PriceGateAction.AWARD);
}}
/>
) : (
/* 경매(1:N) — 최저가 자동 낙찰. */
<div className="flex items-start gap-2.5">
<Gavel size={16} className="text-primary mt-0.5 shrink-0" />
<div>
<Typography as="span" variant="small" className="font-semibold block"> </Typography>
<Typography as="span" variant="small" className="text-muted-foreground text-[10px] leading-snug">
1:N . .
</Typography>
</div>
</div>
)}
</div>
</div>
<div className="space-y-1">
<Typography as="label" variant="label"> ()</Typography>
<textarea
id="wizard-memo"
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={2}
maxLength={100}
className="w-full p-2 bg-background border border-border rounded text-xs resize-none"
placeholder="협상방에서 협력사에게 보여줄 안내 내용 (선택, 최대 100자)"
/>
</div>
</div>
)}
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
{step === 4 && oneToOne && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Typography as="span" variant="label" className="block"> ()</Typography>
<div className="flex items-center gap-1.5">
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1" onClick={selectAllCards}>
<CheckCheck size={13} />
</Button>
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1 text-muted-foreground" onClick={clearAllCards} disabled={selectedCardIds.length === 0}>
<X size={13} />
</Button>
</div>
</div>
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
1:1 AI .
</Typography>
<Combobox
variant="inline"
multiple
values={selectedCardIds}
options={cardOptions}
loading={cardSearch.isLoading}
onQueryChange={setCardQ}
onToggle={(opt) => toggleCard(opt.id)}
searchPlaceholder="카드명·번호·스크립트 검색..."
emptyText="협상카드가 없습니다"
maxListHeight="max-h-72"
/>
{/* 선택 목록 — 검색어가 바뀌어도 담은 카드가 유지되는 고정 테이블. */}
<div className="space-y-1">
<Typography as="span" variant="label" className="block text-[10px] text-muted-foreground">
{selectedCardRows.length}
</Typography>
<SelectedCardTable rows={selectedCardRows} onRemove={toggleCard} />
</div>
</div>
)}
</div>
{/* Footer nav */}
<div className="shrink-0 flex justify-between items-center pt-4 border-t border-border mt-6">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => (step === 1 ? onClose() : setStep((prev) => prev - 1))}
>
{step === 1 ? '취소' : '이전 보기'}
</Button>
<div className="flex gap-2">
{step < totalSteps ? (
<Button
type="button"
size="sm"
onClick={() => {
if (step === 1 && !isFutureLocalInput(dueDate)) {
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
return;
}
if (step === 3 && productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
return;
}
if (step === 3 && targetLimitExceeded) {
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
return;
}
setStep((prev) => prev + 1);
}}
>
</Button>
) : (
<Button type="button" size="sm" onClick={handleSubmit} disabled={submitting}>
{submitting ? '생성 중…' : '견적 등록'}
</Button>
)}
</div>
</div>
</div>
</div>
);
}