969 lines
50 KiB
TypeScript
969 lines
50 KiB
TypeScript
import { useState, useMemo, useEffect, useRef } 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 { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
||
import { useLabels, useHiddenFields } 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 { 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,
|
||
awardStrategySummary,
|
||
PriceGateAction,
|
||
DEFAULT_MID_ACTION,
|
||
DEFAULT_OVER_ACTION,
|
||
type QuotationMode,
|
||
} from '../types';
|
||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||
import { showToast } from '@/lib/notify';
|
||
|
||
const INTERNET_AVERAGE_FEE = 0.078;
|
||
const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2;
|
||
|
||
// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
||
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
|
||
function toKstLocalInput(date: Date): string {
|
||
const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
|
||
return s.slice(0, 16).replace(' ', 'T');
|
||
}
|
||
|
||
function nowKstLocalInput(): string {
|
||
return toKstLocalInput(new Date());
|
||
}
|
||
|
||
function defaultDueDateLocalInput(): string {
|
||
return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000));
|
||
}
|
||
|
||
function isFutureLocalInput(value: string): boolean {
|
||
const time = new Date(value).getTime();
|
||
return Number.isFinite(time) && time > Date.now();
|
||
}
|
||
|
||
function parsePercent(value: string | undefined): number {
|
||
const n = Number(String(value ?? '').replace('%', '').trim());
|
||
return Number.isFinite(n) ? n / 100 : 0;
|
||
}
|
||
|
||
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 [submitting, setSubmitting] = useState(false);
|
||
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
|
||
|
||
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();
|
||
|
||
// ── 픽리스트 서버검색(상품/협력사/카드) — 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 isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
|
||
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;
|
||
// ── 카드 선택 게이팅: 협상 멘트에 변수명이 노출될 카드를 선택 단계에서 막는다 ──
|
||
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
|
||
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
|
||
const conditionUnfilled = (c: NegotiationCard) =>
|
||
(c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`);
|
||
// (2) 인터넷 최저가({internet_lowest_price}) 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 —
|
||
// 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다.
|
||
// 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅).
|
||
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
|
||
const lowestPriceLeak = (c: NegotiationCard) =>
|
||
lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}');
|
||
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
|
||
const blockReason = (c: NegotiationCard): string | null =>
|
||
c.isWildcard && c.status !== 'ACTIVE'
|
||
? '미승인 와일드카드'
|
||
: conditionUnfilled(c)
|
||
? `${CONDITION_LABEL} 미작성`
|
||
: lowestPriceLeak(c)
|
||
? '인터넷 최저가 미수집'
|
||
: null;
|
||
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
|
||
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
|
||
const rankedCards = cardRows
|
||
.slice()
|
||
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
|
||
const cardOptions: ComboOption[] = rankedCards
|
||
.map((card, i) => {
|
||
const reason = blockReason(card);
|
||
return {
|
||
id: card.id,
|
||
label: card.title,
|
||
disabled: !!reason,
|
||
node: (
|
||
<div className={reason ? 'opacity-60' : undefined}>
|
||
<div className="flex items-center gap-1.5">
|
||
{card.usedCount > 0 && (
|
||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
|
||
{i + 1}위 · 성공률 {Math.round(card.successRate * 100)}%
|
||
</span>
|
||
)}
|
||
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
|
||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||
{card.isWildcard ? '와일드' : '협상'}
|
||
</span>
|
||
{reason && (
|
||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
|
||
{reason}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
||
</div>
|
||
),
|
||
};
|
||
});
|
||
|
||
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
|
||
const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
|
||
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
|
||
const autoSelectedRef = useRef(false);
|
||
|
||
// 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다.
|
||
useEffect(() => {
|
||
if (!open) {
|
||
autoSelectedRef.current = false;
|
||
return;
|
||
}
|
||
if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도
|
||
autoSelectedRef.current = true;
|
||
setCardDetails((m) => {
|
||
const next = new Map(m);
|
||
topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }));
|
||
return next;
|
||
});
|
||
setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id)));
|
||
// topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open, topRankedKey]);
|
||
|
||
// 이미 고른 카드 중, 상품을 최저가 미수집 상품으로 바꾸면 {internet_lowest_price} 인용 카드는 자동 해제.
|
||
// (picklist disabled 는 신규 선택만 막으므로, 상품 변경 후 잔존 선택분을 여기서 정리해 노출을 막는다.)
|
||
useEffect(() => {
|
||
if (!lowestUnavailable) return;
|
||
const leakIds = new Set(
|
||
cardRows.filter((c) => (c.scriptPreview ?? '').includes('{internet_lowest_price}')).map((c) => c.id),
|
||
);
|
||
if (leakIds.size === 0) return;
|
||
setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev));
|
||
// productId 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [lowestUnavailable, productId]);
|
||
|
||
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
|
||
const selectedCardRows = selectedCardIds.map((id) => {
|
||
const d = cardDetails.get(id);
|
||
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
||
});
|
||
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
|
||
const margin = parsePercent(selectedSetting?.target_margin);
|
||
// 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
|
||
const targetBreakdown = [
|
||
{ key: 'internet_lowest_price', label: `${label('item.internet_lowest_price')} × (1−수수료 ${+(INTERNET_AVERAGE_FEE * 100).toFixed(1)}%)`, raw: internetLowest, rate: INTERNET_AVERAGE_FEE, show: internetLowest != null },
|
||
{ key: 'purchase_price', label: `${label('item.purchase_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: purchase, rate: margin, show: isReType && purchase != null },
|
||
{ key: 'selling_price', label: `${label('item.selling_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: selling, rate: margin, show: isReType && selling != null },
|
||
]
|
||
.filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
|
||
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
|
||
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
|
||
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
|
||
// 구매담당자 제시가 필드엔 자동 산출값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다.
|
||
// (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.)
|
||
const effectiveMdPrice = mdTouched ? mdPrice : (autoTarget != null ? String(autoTarget) : mdPrice);
|
||
const mdNum = Number(effectiveMdPrice) || 0;
|
||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||
const mdRequired = !!productId && !hasItemCandidate;
|
||
const targetReady = mdNum > 0 || hasItemCandidate;
|
||
// 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 자동 산출값.
|
||
const estimatedTargetPrice = mdNum > 0 ? mdNum : autoTarget;
|
||
const targetPriceLimit = unitPrice != null && unitPrice > 0
|
||
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
|
||
: null;
|
||
const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit;
|
||
|
||
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([]);
|
||
|
||
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: mdTouched && mdPrice ? Number(mdPrice) : null,
|
||
midAction: oneToOne ? midAction : undefined,
|
||
overAction: oneToOne ? overAction : 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}`
|
||
: '';
|
||
}}
|
||
</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}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{oneToOne ? (
|
||
<>
|
||
{/* 낙찰 기준(1:1 전용) — 스펙트럼 = 선택. 낙찰선을 앵커/목표가 중 택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="rounded border border-border bg-muted/20 p-3 flex items-start gap-2.5">
|
||
<Gavel size={18} 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>
|
||
)}
|
||
|
||
{/* 목표가 산정 후보(계산식+결과값) — 참고용, 위. 채택된 최저는 녹색 강조. */}
|
||
{productId && targetBreakdown.length > 0 && (
|
||
<div className="rounded border border-border bg-muted/20 p-3 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="space-y-1">
|
||
{targetBreakdown.map((c) => {
|
||
const isMin = autoTarget != null && c.value === autoTarget;
|
||
return (
|
||
<div key={c.key} className={cn('flex items-center gap-2 rounded px-2 py-1', isMin && 'bg-emerald-50 dark:bg-emerald-950/30')}>
|
||
<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', isMin && 'text-emerald-700 dark:text-emerald-400')}>₩{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순위)로 쓰입니다. 수정 가능.'
|
||
: '자동 산출값이 없어 직접 입력이 필요합니다.'}
|
||
</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">
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
|
||
|
||
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
|
||
function SupplyTypeBadge({ type }: { type?: number }) {
|
||
if (type === undefined) {
|
||
return (
|
||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||
미정
|
||
</Typography>
|
||
);
|
||
}
|
||
return (
|
||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold shrink-0">
|
||
{supplierTypeLabel(type)}
|
||
</Typography>
|
||
);
|
||
}
|
||
|
||
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
|
||
function SelectedPartnerTable({
|
||
rows,
|
||
supplyTypeBySupplier,
|
||
showSupplyType,
|
||
onRemove,
|
||
}: {
|
||
rows: { id: string; name: string; email: string }[];
|
||
supplyTypeBySupplier: Map<string, number>;
|
||
showSupplyType: boolean;
|
||
onRemove: (id: string) => void;
|
||
}) {
|
||
if (rows.length === 0) {
|
||
return (
|
||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||
담긴 협력사가 없습니다 — 위에서 검색해 담아주세요.
|
||
</Typography>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="rounded border border-border overflow-hidden">
|
||
<table className="w-full table-fixed text-xs">
|
||
<thead>
|
||
<tr className="bg-muted/40">
|
||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">협력사명</Typography></th>
|
||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">담당자 이메일</Typography></th>
|
||
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">상품조달유형</Typography></th>}
|
||
<th className="w-9 px-2 py-1.5" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id} className="border-t border-border">
|
||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
|
||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
|
||
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
|
||
<td className="px-2 py-1.5 text-right">
|
||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||
<X size={13} />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
|
||
function SelectedCardTable({
|
||
rows,
|
||
onRemove,
|
||
}: {
|
||
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
|
||
onRemove: (id: string) => void;
|
||
}) {
|
||
if (rows.length === 0) {
|
||
return (
|
||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||
담긴 카드가 없습니다 — 위에서 검색해 담아주세요.
|
||
</Typography>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="rounded border border-border overflow-hidden">
|
||
<table className="w-full table-fixed text-xs">
|
||
<thead>
|
||
<tr className="bg-muted/40">
|
||
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드번호</Typography></th>
|
||
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">유형</Typography></th>
|
||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드명</Typography></th>
|
||
<th className="w-9 px-2 py-1.5" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id} className="border-t border-border">
|
||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
|
||
<td className="px-2 py-1.5">
|
||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||
{r.isWildcard ? '와일드' : '협상'}
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
|
||
<td className="px-2 py-1.5 text-right">
|
||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||
<X size={13} />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
||
function Segmented({
|
||
options,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
options: { value: string; label: string; sub?: string }[];
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
|
||
{options.map((o) => {
|
||
const active = o.value === value;
|
||
return (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
onClick={() => onChange(o.value)}
|
||
className={cn(
|
||
'rounded border p-2.5 text-left transition-all cursor-pointer',
|
||
active ? 'bg-primary/5 border-primary' : 'bg-background border-border hover:bg-muted/20',
|
||
)}
|
||
>
|
||
<Typography as="span" variant="small" className={cn('block font-semibold', active && 'text-primary')}>{o.label}</Typography>
|
||
{o.sub && <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground mt-0.5 leading-tight">{o.sub}</Typography>}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) —
|
||
// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다.
|
||
function AwardLinePicker({
|
||
mid, over, onMid, onOver,
|
||
}: {
|
||
mid: number;
|
||
over: number;
|
||
onMid: (v: number) => void;
|
||
onOver: (v: number) => void;
|
||
}) {
|
||
const A = PriceGateAction.AWARD;
|
||
const O = PriceGateAction.OPEN;
|
||
const zones = [
|
||
{ label: '앵커링가 이하', win: true, locked: true, toggle: undefined },
|
||
{ label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) },
|
||
{ label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) },
|
||
];
|
||
return (
|
||
<div className="space-y-1.5 pt-1">
|
||
<div className="flex items-center justify-between">
|
||
<Typography as="span" variant="label">낙찰 기준</Typography>
|
||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다</Typography>
|
||
</div>
|
||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||
협력사 <span className="font-semibold text-foreground">최저 투찰가</span>가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다.
|
||
</Typography>
|
||
{/* 스펙트럼 선: 구간이 곧 선택 버튼 */}
|
||
<div className="flex rounded-md overflow-hidden border border-border text-center">
|
||
{zones.map((z, i) => {
|
||
const body = (
|
||
<>
|
||
<Typography as="span" variant="small" className="block text-[9px] leading-tight text-muted-foreground">{z.label}</Typography>
|
||
<Typography as="span" variant="small" className={cn('block text-[12px] font-bold leading-tight', z.win ? 'text-emerald-700 dark:text-emerald-400' : 'text-zinc-500')}>
|
||
{z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''}
|
||
</Typography>
|
||
</>
|
||
);
|
||
const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted');
|
||
return z.locked ? (
|
||
<div key={z.label} className={cls} title="앵커링가 이하는 항상 낙찰(고정)">{body}</div>
|
||
) : (
|
||
<button key={z.label} type="button" onClick={z.toggle} className={cn(cls, 'cursor-pointer transition-[filter] hover:brightness-95')}>{body}</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */}
|
||
<div className="relative h-6">
|
||
{[
|
||
{ left: '33.3333%', label: '앵커링가' },
|
||
{ left: '66.6667%', label: '목표가' },
|
||
].map((mk) => (
|
||
<span
|
||
key={mk.label}
|
||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center text-[9px] text-muted-foreground"
|
||
style={{ left: mk.left }}
|
||
>
|
||
<span className="leading-none">▲</span>
|
||
<span className="leading-tight whitespace-nowrap">{mk.label}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
{/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */}
|
||
{(() => {
|
||
const t = awardStrategySummary(mid, over);
|
||
const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN;
|
||
return (
|
||
<Typography as="p" variant="small" className="text-[11px]">
|
||
<span className="font-bold text-primary">{t.strategy}</span>
|
||
<span className="text-muted-foreground"> · {t.desc}</span>
|
||
{isBasic && (
|
||
<span className="text-emerald-700 dark:text-emerald-400 font-semibold"> · 목표가 포함</span>
|
||
)}
|
||
</Typography>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
}
|