o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationDetailSheet/RegenerateModal.tsx
Mina Choi c62b66e35b [feat] negodata: 소유권 게이팅 + 견적 낙찰 + 작성자명 표시 + 전화입력 + 카드 엑셀
- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용
- 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드
- supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:18:33 +09:00

120 lines
5.5 KiB
TypeScript

import { useState } from 'react';
import { X, RefreshCw, Loader2 } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
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) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
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 p-4 bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto 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}
</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>
);
}