- 협상 채팅 말풍선에서 협력사 제시가 마스킹 + 챗버블 컴포넌트 분리 - 견적 리스트에 재생성된 다음 차수 표시 - 매칭 없는 경로용 404 폴백 페이지 추가 - 견적 재생성 모달 너비 확대 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
5.4 KiB
TypeScript
118 lines
5.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { X, RefreshCw, Loader2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Typography } from '@/components/ui/typography';
|
|
import { type Partner, sessionStatusLabel } from '../../types';
|
|
|
|
type RegenerateModalProps = {
|
|
open: boolean;
|
|
/** 현재 견적에 연결된 공급사만. */
|
|
partners: Partner[];
|
|
/** 공급사별 협상 단계(세션 상태 코드) — 행에 함께 표시. */
|
|
sessionStatusBySupplier?: Record<string, number>;
|
|
/** 기본 선택 = 원 라운드의 공급사들. */
|
|
defaultSupplierIds: string[];
|
|
/** 확정 → 재생성 호출. 성공(true) 시 모달 닫힘. */
|
|
onConfirm: (supplierIds: string[]) => Promise<boolean> | boolean;
|
|
onClose: () => void;
|
|
};
|
|
|
|
// 마감된 견적의 '다음 라운드'를 만들 때 부를 공급사를 고르는 모달.
|
|
// 상품·견적번호·협상기간·카드는 원 견적에서 이어받으므로 여기선 공급사만 선택한다.
|
|
export function RegenerateModal({ open, partners, sessionStatusBySupplier, defaultSupplierIds, onConfirm, onClose }: RegenerateModalProps) {
|
|
const [selected, setSelected] = useState<string[]>(defaultSupplierIds);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
if (!open) return null;
|
|
|
|
const toggle = (id: string) =>
|
|
setSelected((prev) => (prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]));
|
|
|
|
// 공급사 1곳=재협상(1:1), 여러 곳=재견적(1:N) — 백엔드 타입 자동결정과 동일하게 미리 안내.
|
|
const nextTypeLabel = selected.length <= 1 ? '재협상 (1:1)' : '재견적 (1:N)';
|
|
|
|
const handle = async () => {
|
|
if (submitting || selected.length === 0) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const ok = await onConfirm(selected);
|
|
if (ok) onClose();
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[55] flex items-center justify-center bg-black/40 backdrop-blur-xs">
|
|
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 animate-scale-up font-mono">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between pb-4 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<RefreshCw className="text-foreground" size={16} />
|
|
<Typography variant="small" className="font-bold">다음 견적 재생성</Typography>
|
|
</div>
|
|
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="my-4 space-y-3 text-xs">
|
|
<Typography as="span" variant="small" className="text-muted-foreground block leading-relaxed">
|
|
상품 · 견적번호 · 협상기간 · 카드는 이 견적에서 이어받습니다. 다음 견적에 부를 공급사만 고르세요.
|
|
</Typography>
|
|
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
|
{partners.map((part) => {
|
|
const isChecked = selected.includes(part.id ?? '');
|
|
return (
|
|
<label
|
|
key={part.id}
|
|
className="flex items-center justify-between gap-2.5 p-3 hover:bg-muted/30 cursor-pointer transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={() => toggle(part.id ?? '')}
|
|
className="accent-primary h-4 w-4 shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<Typography as="span" variant="small" className="font-semibold block truncate">{part.name}</Typography>
|
|
<Typography as="span" variant="small" className="text-muted-foreground">
|
|
이메일: {part.managerEmail} · 등급: {part.rank}
|
|
</Typography>
|
|
</div>
|
|
</div>
|
|
{sessionStatusBySupplier?.[part.id ?? ''] != null && (
|
|
<span className="shrink-0 text-[9px] font-mono px-1.5 py-0.5 rounded-full border border-border bg-muted text-muted-foreground">
|
|
{sessionStatusLabel(sessionStatusBySupplier[part.id ?? ''])}
|
|
</span>
|
|
)}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
|
<span>선택: <b className="text-foreground">{selected.length}</b>곳</span>
|
|
<span>유형: <b className="text-foreground">{nextTypeLabel}</b></span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
|
<Button type="button" variant="outline" size="sm" onClick={onClose}>취소</Button>
|
|
<Button type="button" size="sm" onClick={handle} disabled={submitting || selected.length === 0}>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="animate-spin mr-1" size={14} />
|
|
생성 중…
|
|
</>
|
|
) : (
|
|
'다음 견적 생성'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|