- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용 - 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드 - supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|