o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx
Mina Choi 05fe0de5ce [feat] negodata/front: 협상 채팅·견적 리스트 개선 및 404 페이지 추가
- 협상 채팅 말풍선에서 협력사 제시가 마스킹 + 챗버블 컴포넌트 분리
- 견적 리스트에 재생성된 다음 차수 표시
- 매칭 없는 경로용 404 폴백 페이지 추가
- 견적 재생성 모달 너비 확대

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:50:36 +09:00

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>
);
}