[feat] negodata/front: 견적 재생성·차수 체인 UI + 담당자/메모 + 스크롤·목업 정비

- 견적 재생성(다음 견적) 엔드포인트 연동 모달: 연결 공급사+협상단계 표시, 마지막 차수에서만
- 차수 체인 타임라인/견적번호 클릭 필터, 생성 응답 슬림(qt_id/session_count) 반영
- 상세 시트 스크롤 수정(min-h-0)·배경 스크롤 락(useScrollLock)·헤더2:탭1 비율
- 담당자=로그인 유저(store) 자동 전송 + 생성 모달 메모 입력 추가
- 목업/가짜 폴백 제거(담당자·견적명·번호·메모 → '-'), 시드 회사 placeholder UUID→실 UUID
- 견적상태 CREATED 칩 pulse 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-23 16:57:29 +09:00
parent 7f948075e5
commit 9f947e4823
23 changed files with 565 additions and 86 deletions

View File

@ -5,7 +5,6 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export * from './asyncJob';
export * from './bodyUploadItemImageV1ItemImagePost'; export * from './bodyUploadItemImageV1ItemImagePost';
export * from './cardData'; export * from './cardData';
export * from './cardDataCondition'; export * from './cardDataCondition';
@ -126,6 +125,7 @@ export * from './reqCreateSupplierManagerEmail';
export * from './reqCreateSupplierManagerName'; export * from './reqCreateSupplierManagerName';
export * from './reqCreateSupplierPriority'; export * from './reqCreateSupplierPriority';
export * from './reqLogin'; export * from './reqLogin';
export * from './reqRegenerateQuotation';
export * from './reqUpdateCard'; export * from './reqUpdateCard';
export * from './reqUpdateCardCondition'; export * from './reqUpdateCardCondition';
export * from './reqUpdateCardEditScript'; export * from './reqUpdateCardEditScript';
@ -173,9 +173,8 @@ export * from './resCheckCodesMsg';
export * from './resCreateAccount'; export * from './resCreateAccount';
export * from './resCreateAccountMsg'; export * from './resCreateAccountMsg';
export * from './resCreateQuotation'; export * from './resCreateQuotation';
export * from './resCreateQuotationAsyncJob';
export * from './resCreateQuotationMsg'; export * from './resCreateQuotationMsg';
export * from './resCreateQuotationQuotation'; export * from './resCreateQuotationQtId';
export * from './resDeleteCard'; export * from './resDeleteCard';
export * from './resDeleteCardMsg'; export * from './resDeleteCardMsg';
export * from './resDeleteItem'; export * from './resDeleteItem';

View File

@ -15,7 +15,6 @@ export interface ReqCreateQuotation {
qt_setting_id: string; qt_setting_id: string;
version_id?: ReqCreateQuotationVersionId; version_id?: ReqCreateQuotationVersionId;
name?: string; name?: string;
number?: string;
type?: number; type?: number;
status?: number; status?: number;
start_time?: ReqCreateQuotationStartTime; start_time?: ReqCreateQuotationStartTime;

View File

@ -5,7 +5,6 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export interface AsyncJob { export interface ReqRegenerateQuotation {
status?: string; supplier_ids?: string[];
message?: string;
} }

View File

@ -6,14 +6,11 @@
*/ */
import type { ErrorInfo } from './errorInfo'; import type { ErrorInfo } from './errorInfo';
import type { ResCreateQuotationMsg } from './resCreateQuotationMsg'; import type { ResCreateQuotationMsg } from './resCreateQuotationMsg';
import type { ResCreateQuotationQuotation } from './resCreateQuotationQuotation'; import type { ResCreateQuotationQtId } from './resCreateQuotationQtId';
import type { SessionData } from './sessionData';
import type { ResCreateQuotationAsyncJob } from './resCreateQuotationAsyncJob';
export interface ResCreateQuotation { export interface ResCreateQuotation {
result?: ErrorInfo; result?: ErrorInfo;
msg?: ResCreateQuotationMsg; msg?: ResCreateQuotationMsg;
quotation?: ResCreateQuotationQuotation; qt_id?: ResCreateQuotationQtId;
sessions?: SessionData[]; session_count?: number;
async_job?: ResCreateQuotationAsyncJob;
} }

View File

@ -4,6 +4,5 @@
* Negodata Api Server * Negodata Api Server
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { AsyncJob } from './asyncJob';
export type ResCreateQuotationAsyncJob = AsyncJob | null; export type ResCreateQuotationQtId = string | null;

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { QuotationData } from './quotationData';
export type ResCreateQuotationQuotation = QuotationData | null;

View File

@ -27,6 +27,7 @@ import type {
HTTPValidationError, HTTPValidationError,
ListQuotationsParams, ListQuotationsParams,
ReqCreateQuotation, ReqCreateQuotation,
ReqRegenerateQuotation,
ResCreateQuotation, ResCreateQuotation,
ResDeleteQuotation, ResDeleteQuotation,
ResQuotation, ResQuotation,
@ -265,6 +266,71 @@ export const useStopQuotation = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/** /**
* @summary 견적 재생성(다음 라운드)
*/
export const regenerateQuotation = (
qtId: string,
reqRegenerateQuotation: ReqRegenerateQuotation,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCreateQuotation>(
{url: `/v1/quotation/regenerate/${qtId}`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqRegenerateQuotation, signal
},
options);
}
export const getRegenerateQuotationMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof regenerateQuotation>>, TError,{qtId: string;data: ReqRegenerateQuotation}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof regenerateQuotation>>, TError,{qtId: string;data: ReqRegenerateQuotation}, TContext> => {
const mutationKey = ['regenerateQuotation'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof regenerateQuotation>>, {qtId: string;data: ReqRegenerateQuotation}> = (props) => {
const {qtId,data} = props ?? {};
return regenerateQuotation(qtId,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type RegenerateQuotationMutationResult = NonNullable<Awaited<ReturnType<typeof regenerateQuotation>>>
export type RegenerateQuotationMutationBody = ReqRegenerateQuotation
export type RegenerateQuotationMutationError = void | HTTPValidationError
/**
* @summary 견적 재생성(다음 라운드)
*/
export const useRegenerateQuotation = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof regenerateQuotation>>, TError,{qtId: string;data: ReqRegenerateQuotation}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof regenerateQuotation>>,
TError,
{qtId: string;data: ReqRegenerateQuotation},
TContext
> => {
const mutationOptions = getRegenerateQuotationMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 견적 상태 조회 * @summary 견적 상태 조회
*/ */
export const getQuotationStatus = ( export const getQuotationStatus = (

View File

@ -20,8 +20,8 @@ export function Sheet({ open, title, onClose, children }: SheetProps) {
{/* 백드롭 */} {/* 백드롭 */}
<div className="flex-1 cursor-pointer" onClick={onClose} /> <div className="flex-1 cursor-pointer" onClick={onClose} />
{/* 우측 드로어 패널 */} {/* 우측 드로어 패널 (패널 자체가 세로 스크롤 — justify-* 는 스크롤 시작점을 가려 안 씀) */}
<div className="w-full max-w-lg bg-card border-l border-border h-full flex flex-col justify-between shadow-2xl p-6 overflow-y-auto animate-slide-left"> <div className="w-full max-w-lg bg-card border-l border-border h-full shadow-2xl p-6 overflow-y-auto animate-slide-left">
<div> <div>
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between pb-4 border-b border-border"> <div className="flex items-center justify-between pb-4 border-b border-border">

View File

@ -9,6 +9,13 @@ import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model'; import { QuotationType } from '@/api/generated/model';
import { QUOTATION_TYPE_OPTIONS } from '../types'; import { QUOTATION_TYPE_OPTIONS } from '../types';
// datetime-local 디폴트값: 현재 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
function nowKstLocalInput(): string {
const s = new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
return s.slice(0, 16).replace(' ', 'T');
}
type QuotationCreateModalProps = { type QuotationCreateModalProps = {
open: boolean; open: boolean;
products: Product[]; products: Product[];
@ -33,9 +40,10 @@ export function QuotationCreateModal({
const [type, setType] = useState<number>(QuotationType.REQUOTE); const [type, setType] = useState<number>(QuotationType.REQUOTE);
const [productId, setProductId] = useState(''); const [productId, setProductId] = useState('');
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]); const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
const [dueDate, setDueDate] = useState('2026-06-15T18:00'); const [dueDate, setDueDate] = useState(nowKstLocalInput);
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? ''); const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]); const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
const [memo, setMemo] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const typeOptions = QUOTATION_TYPE_OPTIONS; const typeOptions = QUOTATION_TYPE_OPTIONS;
@ -65,6 +73,7 @@ export function QuotationCreateModal({
dueDate, dueDate,
settingId, settingId,
cardIds: selectedCardIds, cardIds: selectedCardIds,
memo,
}); });
if (ok) onClose(); if (ok) onClose();
} finally { } finally {
@ -277,6 +286,19 @@ export function QuotationCreateModal({
})} })}
</div> </div>
</div> </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> </div>
)} )}

View File

@ -37,7 +37,7 @@ export function ChatTab({
<div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase"> <div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase">
참여자 협력사 리스트 참여자 협력사 리스트
</div> </div>
<div className="flex-1 overflow-y-auto divide-y divide-border font-sans"> <div className="flex-1 min-h-0 overflow-y-auto divide-y divide-border font-sans">
{serverSessions.length === 0 && ( {serverSessions.length === 0 && (
<div className="p-4 text-center text-muted-foreground text-xs font-mono"> <div className="p-4 text-center text-muted-foreground text-xs font-mono">
참여 협상 세션이 없습니다. (리스트가 비어 있습니다) 참여 협상 세션이 없습니다. (리스트가 비어 있습니다)
@ -91,7 +91,7 @@ export function ChatTab({
</div> </div>
</div> </div>
<div className="flex-1 p-4 overflow-y-auto space-y-4"> <div className="flex-1 min-h-0 p-4 overflow-y-auto space-y-4">
{!effectiveSessionId ? ( {!effectiveSessionId ? (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs"> <div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
선택된 협력사가 없습니다. 선택된 협력사가 없습니다.

View File

@ -11,6 +11,7 @@ import {
type QuotationSetting, type QuotationSetting,
buildBidSummary, buildBidSummary,
quotationTypeLabel, quotationTypeLabel,
fmtDateTime,
} from '../../types'; } from '../../types';
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) => const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
@ -43,13 +44,16 @@ export function DrawerHeaderCards({
currentProduct, currentProduct,
}: DrawerHeaderCardsProps) { }: DrawerHeaderCardsProps) {
// Quotations DDL 표시값 // Quotations DDL 표시값
const q_name = quotation.name || '미지정'; const q_name = quotation.name || '-';
const q_number = quotation.number || 'EST-000000-0000'; const q_number = quotation.number || '-';
const q_round = quotation.round || 1; const q_round = quotation.round || 1;
const q_end_time = quotation.end_time || '미지정'; const q_end_time = quotation.end_time ? fmtDateTime(quotation.end_time) : '-';
const q_manager_name = quotation.manager_name || '홍길동 파트너'; const q_created_at = fmtDateTime(quotation.created_at);
const q_manager_email = quotation.manager_email || 'gildong@negodata.com'; const q_manager =
const q_memo = quotation.memo || '안내사항 없음'; quotation.manager_name || quotation.manager_email
? `${quotation.manager_name || '-'} (${quotation.manager_email || '-'})`
: '-';
const q_memo = quotation.memo || '-';
const bidSummaryObj = buildBidSummary(quotation, partners); const bidSummaryObj = buildBidSummary(quotation, partners);
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id); const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
@ -85,7 +89,8 @@ export function DrawerHeaderCards({
<QuotationStatusBadge status={quotation.status} /> <QuotationStatusBadge status={quotation.status} />
</InfoField> </InfoField>
<InfoField label="마감시각" value={q_end_time} /> <InfoField label="마감시각" value={q_end_time} />
<InfoField label="담당자" value={`${q_manager_name} (${q_manager_email})`} valueClassName="font-sans" /> <InfoField label="생성일" value={q_created_at} />
<InfoField label="담당자" value={q_manager} valueClassName="font-sans" />
<InfoField <InfoField
label="메모" label="메모"
value={q_memo} value={q_memo}

View File

@ -0,0 +1,117 @@
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-md 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>
);
}

View File

@ -0,0 +1,47 @@
import { Fragment } from 'react';
import { ChevronRight } from 'lucide-react';
import { useQuotationChain } from '../../hooks/useQuotationChain';
type RoundTimelineProps = {
/** 현재 견적의 견적번호(체인 키). */
number: string;
/** 현재 보고 있는 견적 id — 타임라인에서 하이라이트. */
currentQtId: string;
/** 다른 라운드 칩 클릭 → 그 라운드 상세로 전환. */
onSwitchRound: (qtId: string) => void;
};
// 같은 견적번호의 라운드 흐름(1차 → 2차 → ...). 라운드 1개뿐이면(재생성 없음) 렌더 안 함.
export function RoundTimeline({ number, currentQtId, onSwitchRound }: RoundTimelineProps) {
const { rounds } = useQuotationChain(number);
if (rounds.length <= 1) return null;
return (
<div className="flex items-center gap-1 mt-2 flex-wrap">
<span className="text-[10px] text-muted-foreground font-mono mr-1 uppercase tracking-wider">
견적 차수
</span>
{rounds.map((r, i) => {
const isCurrent = r.qt_id === currentQtId;
return (
<Fragment key={r.qt_id}>
{i > 0 && <ChevronRight size={11} className="text-muted-foreground/40" />}
<button
type="button"
onClick={() => !isCurrent && onSwitchRound(r.qt_id)}
disabled={isCurrent}
title={`${r.round}차 견적`}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold border transition-colors ${
isCurrent
? 'bg-primary text-primary-foreground border-primary cursor-default'
: 'bg-muted text-foreground border-border hover:bg-muted-foreground/15 cursor-pointer'
}`}
>
{r.round}차 견적
</button>
</Fragment>
);
})}
</div>
);
}

View File

@ -44,11 +44,11 @@ export function sessionStatusTone(status?: number | null): PillTone {
return 'emerald'; return 'emerald';
} }
/* ── 견적 상태 배지 (헤더, dot + border + 견적생성 시 pulse) ── /* ── 견적 상태 배지 (헤더, dot + border) ──
작은 pill 들과 모양이 달라(테두리·점·pulse) 별도 컴포넌트로 둔다. */ 작은 pill 들과 모양이 달라(테두리·점) 별도 컴포넌트로 둔다. */
const QSTATUS_TONE: Record<QuotationStatus, { box: string; dot: string }> = { const QSTATUS_TONE: Record<QuotationStatus, { box: string; dot: string }> = {
[QuotationStatus.CREATED]: { [QuotationStatus.CREATED]: {
box: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50 animate-pulse', box: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50',
dot: 'bg-amber-500', dot: 'bg-amber-500',
}, },
[QuotationStatus.ACTIVE]: { [QuotationStatus.ACTIVE]: {

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { CheckCircle2, X, UserCheck, MessageSquare, Layers } from 'lucide-react'; import { CheckCircle2, X, UserCheck, MessageSquare, Layers, RefreshCw } from 'lucide-react';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { import {
useGetQuotationSessions, useGetQuotationSessions,
@ -9,6 +9,8 @@ import {
import { useGetItem } from '@/api/generated/item/item'; import { useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting'; import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
import { useQuotationChain } from '../../hooks/useQuotationChain';
import { useScrollLock } from '@/lib/useScrollLock';
import type { QuotationData } from '@/api/generated/model/quotationData'; import type { QuotationData } from '@/api/generated/model/quotationData';
import { import {
mapItem, mapItem,
@ -19,6 +21,8 @@ import {
} from '../../types'; } from '../../types';
import { QuotationStatus } from '@/api/generated/model'; import { QuotationStatus } from '@/api/generated/model';
import { DrawerHeaderCards } from './DrawerHeaderCards'; import { DrawerHeaderCards } from './DrawerHeaderCards';
import { RoundTimeline } from './RoundTimeline';
import { RegenerateModal } from './RegenerateModal';
import { SessionsStatusTab } from './SessionsStatusTab'; import { SessionsStatusTab } from './SessionsStatusTab';
import { QuotationCardsTab } from './QuotationCardsTab'; import { QuotationCardsTab } from './QuotationCardsTab';
import { ChatTab } from './ChatTab'; import { ChatTab } from './ChatTab';
@ -28,18 +32,26 @@ type DrawerTab = 'status' | 'cards' | 'chat';
type QuotationDetailSheetProps = { type QuotationDetailSheetProps = {
quotation: QuotationData; quotation: QuotationData;
onCloseQuotation: (id: string, name: string) => void; onCloseQuotation: (id: string, name: string) => void;
/** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */
onSwitchRound: (qtId: string) => void;
/** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */
onRegenerate: (qtId: string, supplierIds: string[]) => Promise<string | null>;
onClose: () => void; onClose: () => void;
}; };
export function QuotationDetailSheet({ export function QuotationDetailSheet({
quotation, quotation,
onCloseQuotation, onCloseQuotation,
onSwitchRound,
onRegenerate,
onClose, onClose,
}: QuotationDetailSheetProps) { }: QuotationDetailSheetProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status'); const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [showHeaderCards, setShowHeaderCards] = useState(true); const [showHeaderCards, setShowHeaderCards] = useState(true);
const [regenOpen, setRegenOpen] = useState(false);
// 시트 열린 동안 뒤 견적 리스트(<main>) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지.
useScrollLock();
// 협력사·견적세팅 목록은 sheet 안에서 직접 서버(orval)로 읽는다(부모 props 의존 제거).
const suppliersQuery = useListSuppliers({ size: 100 }); const suppliersQuery = useListSuppliers({ size: 100 });
const settingsQuery = useListSettings(); const settingsQuery = useListSettings();
const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier); const partners = (suppliersQuery.data?.suppliers ?? []).map(mapSupplier);
@ -49,23 +61,38 @@ export function QuotationDetailSheet({
const qtId = quotation.qt_id ?? ''; const qtId = quotation.qt_id ?? '';
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다. // 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
const sessionsQuery = useGetQuotationSessions(qtId, { query: { enabled: !!qtId } }); // 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다.
const sessionsQuery = useGetQuotationSessions(qtId, {
query: { enabled: !!qtId, refetchOnWindowFocus: true },
});
const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } }); const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } });
const serverSessions = sessionsQuery.data?.sessions ?? []; const serverSessions = sessionsQuery.data?.sessions ?? [];
const serverCards = cardsQuery.data?.cards ?? []; const serverCards = cardsQuery.data?.cards ?? [];
// 재생성 모달 기본 선택 = 이 라운드에 부른 공급사들(세션 distinct supplier).
const currentSupplierIds = [...new Set(serverSessions.map((s) => s.supplier_id))];
// 재생성 모달엔 '현재 견적에 연결된 공급사'만 + 각자의 협상 단계(세션 상태)를 함께 보여준다.
const connectedPartners = partners.filter((p) => currentSupplierIds.includes(p.id ?? ''));
const sessionStatusBySupplier: Record<string, number> = Object.fromEntries(
serverSessions.map((s) => [s.supplier_id, s.status]),
);
// 재생성 버튼은 '체인의 마지막 차수(마감됨)'에서만 노출. 체인 로딩 끝난 뒤 판정해 옛 라운드에서 깜빡임 방지.
const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number);
const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1);
const isLatestRound = (quotation.round ?? 1) >= maxRound;
const canRegenerate = !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound;
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null); const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null; const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
const chatQuery = useGetSessionChat(effectiveSessionId ?? '', { const chatQuery = useGetSessionChat(effectiveSessionId ?? '', {
query: { enabled: !!effectiveSessionId }, query: { enabled: !!effectiveSessionId, refetchOnWindowFocus: true },
}); });
const chatMessages = chatQuery.data?.messages ?? []; const chatMessages = chatQuery.data?.messages ?? [];
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId); const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
const currentSupplierName = const currentSupplierName =
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-'; partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
// 견적 1건 = 상품 1개(item_ids:[productId])라 모든 세션이 같은 상품을 공유한다.
// 카탈로그 전체 대신 그 상품 1건만 단건 조회 → 상품 수가 늘어도 무관하고, 상품 id 별로 캐시된다.
const itemId = serverSessions[0]?.item_id ?? ''; const itemId = serverSessions[0]?.item_id ?? '';
const itemQuery = useGetItem(itemId, { query: { enabled: !!itemId } }); const itemQuery = useGetItem(itemId, { query: { enabled: !!itemId } });
const currentItem = itemQuery.data?.item; const currentItem = itemQuery.data?.item;
@ -78,12 +105,13 @@ export function QuotationDetailSheet({
const quotationCardViews = serverCards.map(mapServerCardView); const quotationCardViews = serverCards.map(mapServerCardView);
// 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산). // 헤더 상단바·마감 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
const q_name = quotation.name || '미지정'; const q_name = quotation.name || '-';
const q_number = quotation.number || 'EST-000000-0000'; const q_number = quotation.number || '-';
const goToChat = (sessionId: string) => { const goToChat = (sessionId: string) => {
setSelectedSessionId(sessionId); setSelectedSessionId(sessionId);
setActiveTab('chat'); setActiveTab('chat');
setShowHeaderCards(false); // 채팅 진입 시 대화 영역 넓게 — 견적 상세 정보 접기
}; };
const tabs: { id: DrawerTab; label: string; icon: typeof UserCheck }[] = [ const tabs: { id: DrawerTab; label: string; icon: typeof UserCheck }[] = [
@ -96,16 +124,19 @@ export function QuotationDetailSheet({
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in"> <div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
<div className="flex-1 cursor-pointer" onClick={onClose} /> <div className="flex-1 cursor-pointer" onClick={onClose} />
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col justify-between shadow-2xl overflow-hidden animate-slide-left"> <div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col shadow-2xl overflow-hidden animate-slide-left">
{/* Header */} {/* Header title bar (고정) */}
<div className="p-6 border-b border-border bg-muted/30"> <div className={`shrink-0 px-6 pt-6 bg-muted/30 ${showHeaderCards ? 'pb-3' : 'pb-6 border-b border-border'}`}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase"> <div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
<span>견적 상세 // {q_number}</span> <span>견적 상세 // {q_number}</span>
</div> </div>
<Typography variant="h3" className="mt-1">{q_name}</Typography> <Typography variant="h3" className="mt-1">{q_name}</Typography>
{quotation.number && (
<RoundTimeline number={quotation.number} currentQtId={qtId} onSwitchRound={onSwitchRound} />
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -115,6 +146,16 @@ export function QuotationDetailSheet({
> >
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span> <span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
</button> </button>
{/* 마감된 '마지막 차수'에서만 다음 라운드 수동 재생성(공급사는 모달에서 선택). 서버도 비-마지막은 에러로 방어. */}
{canRegenerate && (
<button
onClick={() => setRegenOpen(true)}
className="flex items-center gap-1 px-3 py-1.5 bg-violet-600 hover:bg-violet-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors"
>
<RefreshCw size={14} />
<span>다음 견적 재생성</span>
</button>
)}
{/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */} {/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */}
{(() => { {(() => {
const canClose = quotation.status !== QuotationStatus.CLOSED; const canClose = quotation.status !== QuotationStatus.CLOSED;
@ -123,7 +164,7 @@ export function QuotationDetailSheet({
onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)} onClick={() => onCloseQuotation(quotation.qt_id ?? '', q_name)}
disabled={!canClose} disabled={!canClose}
title={canClose ? undefined : '이미 마감된 견적입니다.'} title={canClose ? undefined : '이미 마감된 견적입니다.'}
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-600" className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-600"
> >
<CheckCircle2 size={14} /> <CheckCircle2 size={14} />
<span>견적 마감</span> <span>견적 마감</span>
@ -138,20 +179,25 @@ export function QuotationDetailSheet({
</button> </button>
</div> </div>
</div> </div>
</div>
{/* DB mapping info cards */} {/* 견적 상세 정보 — 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */}
{showHeaderCards && ( {showHeaderCards && (
<div
style={{ flex: '2 1 0%' }}
className="min-h-0 overflow-y-auto px-6 pb-6 bg-muted/30 border-b border-border"
>
<DrawerHeaderCards <DrawerHeaderCards
quotation={quotation} quotation={quotation}
partners={partners} partners={partners}
quotationSettings={quotationSettings} quotationSettings={quotationSettings}
currentProduct={currentProduct} currentProduct={currentProduct}
/> />
)}
</div> </div>
)}
{/* Tabs */} {/* Tabs */}
<div className="border-b border-border bg-background px-6"> <div className="shrink-0 border-b border-border bg-background px-6">
<div className="flex gap-4"> <div className="flex gap-4">
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
@ -183,7 +229,7 @@ export function QuotationDetailSheet({
</div> </div>
{/* Tab content */} {/* Tab content */}
<div className="flex-1 p-6 overflow-y-auto bg-background/50"> <div style={{ flex: '1 1 0%' }} className="min-h-0 p-6 overflow-y-auto bg-background/50">
{activeTab === 'status' && ( {activeTab === 'status' && (
<SessionsStatusTab sessionViews={sessionViews} onOpenChat={goToChat} /> <SessionsStatusTab sessionViews={sessionViews} onOpenChat={goToChat} />
)} )}
@ -204,6 +250,24 @@ export function QuotationDetailSheet({
)} )}
</div> </div>
</div> </div>
{regenOpen && (
<RegenerateModal
open
partners={connectedPartners}
sessionStatusBySupplier={sessionStatusBySupplier}
defaultSupplierIds={currentSupplierIds}
onConfirm={async (ids) => {
const newId = await onRegenerate(qtId, ids);
if (newId) {
onSwitchRound(newId); // 새 라운드 상세로 전환
return true;
}
return false;
}}
onClose={() => setRegenOpen(false)}
/>
)}
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Clock, Building2 } from 'lucide-react'; import { Clock, Building2, Link2 } from 'lucide-react';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { type Estimate, type Product, quotationStatusLabel, quotationTypeLabel } from '../types'; import { type Estimate, type Product, quotationStatusLabel, quotationTypeLabel } from '../types';
import { QuotationType, QuotationStatus } from '@/api/generated/model'; import { QuotationType, QuotationStatus } from '@/api/generated/model';
@ -8,13 +8,15 @@ type QuotationTableProps = {
data: Estimate[]; data: Estimate[];
products: Product[]; products: Product[];
onOpenDetail: (id: string) => void; onOpenDetail: (id: string) => void;
/** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */
onFilterChain?: (number: string) => void;
footer?: ReactNode; footer?: ReactNode;
}; };
const statusBadgeClass = (status?: number | null) => { const statusBadgeClass = (status?: number | null) => {
switch (status) { switch (status) {
case QuotationStatus.CREATED: case QuotationStatus.CREATED:
return 'bg-yellow-50 text-yellow-700 border-yellow-300 animate-pulse'; return 'bg-yellow-50 text-yellow-700 border-yellow-300';
case QuotationStatus.ACTIVE: case QuotationStatus.ACTIVE:
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/40'; return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/25 dark:text-emerald-400 border-emerald-300/40';
case QuotationStatus.CLOSED: case QuotationStatus.CLOSED:
@ -26,7 +28,7 @@ const statusBadgeClass = (status?: number | null) => {
} }
}; };
export function QuotationTable({ data, products, onOpenDetail, footer }: QuotationTableProps) { export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer }: QuotationTableProps) {
return ( return (
<DataTable <DataTable
data={data} data={data}
@ -56,7 +58,23 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
{ {
header: '견적번호', header: '견적번호',
cellClassName: 'font-mono text-muted-foreground', cellClassName: 'font-mono text-muted-foreground',
cell: (est) => est.number, cell: (est) =>
onFilterChain && est.number ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation(); // 행 클릭(상세 열기) 대신 체인 필터만
onFilterChain(est.number!);
}}
title="이 견적번호의 모든 차수만 보기"
className="inline-flex items-center gap-1 hover:text-primary hover:underline cursor-pointer"
>
<Link2 size={11} className="opacity-60" />
{est.number}
</button>
) : (
est.number
),
}, },
{ {
header: '유형', header: '유형',
@ -100,6 +118,11 @@ export function QuotationTable({ data, products, onOpenDetail, footer }: Quotati
</div> </div>
), ),
}, },
{
header: '생성일',
cellClassName: 'font-mono text-muted-foreground whitespace-nowrap',
cell: (est) => est.createdDate ?? '-',
},
{ {
header: '협력사수', header: '협력사수',
align: 'center', align: 'center',

View File

@ -0,0 +1,26 @@
import { keepPreviousData } from '@tanstack/react-query';
import { useListQuotations } from '@/api/generated/quotation/quotation';
import { chainRoundState, type ChainRoundState } from '../types';
export type ChainRound = {
qt_id: string;
round: number;
state: ChainRoundState;
};
// 같은 견적번호(체인)의 모든 차수를 라운드 오름차순으로 돌려준다.
// 체인은 parent_id 없이 number 공유로만 묶이므로, 목록 검색(number ILIKE)으로 모은 뒤
// 정확매칭(q.number === number)으로 좁힌다 — search 가 이름까지 매칭하는 오염을 제거.
// 재생성은 사유별 1회 한도라 체인은 최대 ~3라운드 → size 한 번이면 전부 들어온다.
export function useQuotationChain(number?: string | null) {
const enabled = !!number;
const query = useListQuotations(
{ search: number ?? undefined, size: 50 },
{ query: { enabled, placeholderData: keepPreviousData } },
);
const rounds: ChainRound[] = (query.data?.quotations ?? [])
.filter((q) => q.number === number)
.map((q) => ({ qt_id: q.qt_id, round: q.round ?? 1, state: chainRoundState(q) }))
.sort((a, b) => a.round - b.round);
return { rounds, isLoading: enabled && query.isLoading };
}

View File

@ -14,6 +14,7 @@ import {
useListQuotations, useListQuotations,
useCreateQuotation, useCreateQuotation,
useStopQuotation, useStopQuotation,
useRegenerateQuotation,
getGetQuotationQueryKey, getGetQuotationQueryKey,
getGetQuotationSessionsQueryKey, getGetQuotationSessionsQueryKey,
} from '@/api/generated/quotation/quotation'; } from '@/api/generated/quotation/quotation';
@ -21,6 +22,7 @@ import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsP
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation'; import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm'; import { confirm } from '@/lib/confirm';
import { useAuthStore } from '@/stores/auth';
import type { Estimate } from '../types'; import type { Estimate } from '../types';
import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
import { QuotationStatus } from '@/api/generated/model'; import { QuotationStatus } from '@/api/generated/model';
@ -33,6 +35,7 @@ export type CreateQuotationInput = {
dueDate: string; // datetime-local 원본값 dueDate: string; // datetime-local 원본값
settingId: string; settingId: string;
cardIds: string[]; cardIds: string[];
memo: string;
}; };
export type SettingInput = { export type SettingInput = {
@ -56,6 +59,7 @@ export function useQuotations(params: ListQuotationsParams) {
const deleteSettingMutation = useDeleteSetting(); const deleteSettingMutation = useDeleteSetting();
const createQuotationMutation = useCreateQuotation(); const createQuotationMutation = useCreateQuotation();
const stopQuotationMutation = useStopQuotation(); const stopQuotationMutation = useStopQuotation();
const regenerateQuotationMutation = useRegenerateQuotation();
// 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화). // 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화).
const invalidateQuotations = () => const invalidateQuotations = () =>
@ -165,6 +169,8 @@ export function useQuotations(params: ListQuotationsParams) {
return null; return null;
} }
// 담당자는 로그인 유저(개인정보 store)에서 그대로 채워 보낸다 — 견적 생성자 = 담당자.
const me = useAuthStore.getState().user;
const payload: ReqCreateQuotation = { const payload: ReqCreateQuotation = {
qt_setting_id: input.settingId, qt_setting_id: input.settingId,
name: input.title, name: input.title,
@ -173,11 +179,15 @@ export function useQuotations(params: ListQuotationsParams) {
item_ids: [input.productId], item_ids: [input.productId],
supplier_ids: input.partnerIds, supplier_ids: input.partnerIds,
card_ids: input.cardIds, card_ids: input.cardIds,
manager_name: me?.name || undefined,
manager_email: me?.email || undefined,
manager_contact_number: me?.contact || undefined,
memo: input.memo.trim() || undefined,
}; };
try { try {
const res = await createQuotationMutation.mutateAsync({ data: payload }); const res = await createQuotationMutation.mutateAsync({ data: payload });
const qtId = res?.quotation?.qt_id ?? null; const qtId = res?.qt_id ?? null;
// 에러는 응답 패킷 규약(result.success/code/desc + msg)대로 표시한다. HTTP 200 이어도 success=false면 실패. // 에러는 응답 패킷 규약(result.success/code/desc + msg)대로 표시한다. HTTP 200 이어도 success=false면 실패.
if (!res?.result?.success || !qtId) { if (!res?.result?.success || !qtId) {
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
@ -187,7 +197,7 @@ export function useQuotations(params: ListQuotationsParams) {
} }
// 목록 재조회가 끝나야 새 견적이 정본 목록에 들어온다(상세 자동오픈이 그 행을 찾을 수 있게). // 목록 재조회가 끝나야 새 견적이 정본 목록에 들어온다(상세 자동오픈이 그 행을 찾을 수 있게).
await invalidateQuotations(); await invalidateQuotations();
showToast(`협상견적[${input.title}] 생성 완료 — 협상 세션 ${res?.sessions?.length ?? 0}건.`, 'success'); showToast(`협상견적[${input.title}] 생성 완료 — 협상 세션 ${res?.session_count ?? 0}건.`, 'success');
return qtId; return qtId;
} catch { } catch {
showToast('견적 생성에 실패했습니다. 입력값을 확인해 주세요.', 'error'); showToast('견적 생성에 실패했습니다. 입력값을 확인해 주세요.', 'error');
@ -195,6 +205,31 @@ export function useQuotations(params: ListQuotationsParams) {
} }
}; };
// 마감된 견적을 골라 다음 라운드를 수동 생성한다(공급사는 프론트 선택, 상품·번호·기간은 원 견적 승계).
// 성공 시 새 라운드 qt_id 반환, 실패/검증오류 시 null.
const regenerateQuotation = async (qtId: string, supplierIds: string[]): Promise<string | null> => {
if (supplierIds.length === 0) {
showToast('다음 견적에 부를 공급사를 한 곳 이상 선택해 주세요.', 'error');
return null;
}
try {
const res = await regenerateQuotationMutation.mutateAsync({ qtId, data: { supplier_ids: supplierIds } });
const newId = res?.qt_id ?? null;
if (!res?.result?.success || !newId) {
const reason = res?.msg ?? res?.result?.desc ?? '서버 오류';
const code = res?.result?.code;
showToast(`재생성 실패${code ? ` [${code}]` : ''}: ${reason}`, 'error');
return null;
}
await invalidateQuotations();
showToast(`다음 견적이 생성되었습니다 — 협상 세션 ${res?.session_count ?? 0}건.`, 'success');
return newId;
} catch {
showToast('견적 재생성에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
return null;
}
};
return { return {
products, products,
partners, partners,
@ -206,5 +241,6 @@ export function useQuotations(params: ListQuotationsParams) {
addSetting, addSetting,
deleteSetting, deleteSetting,
createQuotation, createQuotation,
regenerateQuotation,
}; };
} }

View File

@ -14,6 +14,7 @@ export type { Product, Partner, NegotiationCard } from '@/types';
export type Estimate = Partial<QuotationData> & { export type Estimate = Partial<QuotationData> & {
id?: string; id?: string;
dueDate?: string; dueDate?: string;
createdDate?: string;
title?: string; title?: string;
productId?: string; productId?: string;
productName?: string; productName?: string;
@ -81,6 +82,7 @@ export function mapQuotation(q: QuotationData): Estimate {
productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용 productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용
productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백 productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백
dueDate: formatDueDate(q.end_time), dueDate: formatDueDate(q.end_time),
createdDate: fmtDateTime(q.created_at),
participationCount: q.participation_count ?? 0, participationCount: q.participation_count ?? 0,
winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null, winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null,
isEqualPrice: !!q.equal_bid_yn, isEqualPrice: !!q.equal_bid_yn,
@ -89,13 +91,30 @@ export function mapQuotation(q: QuotationData): Estimate {
}; };
} }
// 서버 end_time(ISO) → 'YYYY-MM-DD HH:mm' 표기. 파싱 실패 시 원본 유지. // 서버가 주는 시각 문자열은 타임존 표식이 없는 UTC다. UTC로 못박은 뒤 한국시간(Asia/Seoul)으로
// 'YYYY-MM-DD HH:mm' 포맷한다. 브라우저 로컬타임존에 의존하지 않도록 timeZone 을 명시. 파싱 실패면 null.
function toKstDateTime(s: string): string | null {
const iso = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(s) ? s : s + 'Z';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const parts = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
.formatToParts(d)
.reduce((o, p) => ((o[p.type] = p.value), o), {} as Record<string, string>);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
}
// 서버 end_time(UTC ISO) → 한국시간 표기. 파싱 실패 시 원본 유지.
function formatDueDate(end?: string | null): string { function formatDueDate(end?: string | null): string {
if (!end) return '미지정'; if (!end) return '-';
const d = new Date(end); return toKstDateTime(end) ?? end;
if (Number.isNaN(d.getTime())) return end;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류'; export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류';
@ -125,6 +144,24 @@ export const QUOTATION_TYPE_OPTIONS = [QuotationType.REQUOTE, QuotationType.RENE
label: QUOTATION_TYPE_LABEL[value], label: QUOTATION_TYPE_LABEL[value],
})); }));
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.
export type ChainRoundState = 'awarded' | 'equal' | 'closed' | 'active';
export const CHAIN_ROUND_STATE_LABEL: Record<ChainRoundState, string> = {
awarded: '낙찰',
equal: '동가',
closed: '마감',
active: '진행중',
};
export function chainRoundState(
q: Pick<QuotationData, 'status' | 'preferred_sp_id' | 'equal_bid_yn'>,
): ChainRoundState {
if (q.preferred_sp_id) return 'awarded';
if (q.equal_bid_yn) return 'equal';
if (q.status === QuotationStatus.CLOSED) return 'closed';
return 'active';
}
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ──────── // ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
export type BidSummaryView = { export type BidSummaryView = {
@ -190,13 +227,10 @@ export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
export const sessionStatusLabel = (code?: number | null): string => export const sessionStatusLabel = (code?: number | null): string =>
(code != null ? SESSION_STATUS_LABEL[code as SessionStatus] : undefined) ?? String(code ?? ''); (code != null ? SESSION_STATUS_LABEL[code as SessionStatus] : undefined) ?? String(code ?? '');
// ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'. // 서버 UTC ISO 문자열 → 한국시간 'YYYY-MM-DD HH:mm'. 빈 값은 '-', 파싱 실패는 원본.
export function fmtDateTime(s?: string | null): string { export function fmtDateTime(s?: string | null): string {
if (!s) return '-'; if (!s) return '-';
const d = new Date(s); return toKstDateTime(s) ?? s;
if (Number.isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
} }
// 서버 SessionData → 협상현황 테이블 뷰. 협력사명/상품명은 이미 로드된 목록에서 해석. // 서버 SessionData → 협상현황 테이블 뷰. 협력사명/상품명은 이미 로드된 목록에서 해석.
@ -210,7 +244,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
supplier_id: sd.supplier_id, supplier_id: sd.supplier_id,
supplier_name: supplier?.name || sd.supplier_id, supplier_name: supplier?.name || sd.supplier_id,
item_id: sd.item_id, item_id: sd.item_id,
item_name: product?.name || '부품', item_name: product?.name || '-',
status: sd.status, status: sd.status,
target_price: sd.target_price ?? 0, target_price: sd.target_price ?? 0,
bid_price: sd.bid_price ?? null, bid_price: sd.bid_price ?? null,

View File

@ -9,6 +9,7 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router';
// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이 // overlay.has('new') // ?new 존재 여부 — 플래그 오버레이
// overlay.open('detail', id) // ?detail=<id> (다른 오버레이 키는 지움) — 히스토리 push // overlay.open('detail', id) // ?detail=<id> (다른 오버레이 키는 지움) — 히스토리 push
// overlay.open('new') // ?new (값 생략 시 '1') // overlay.open('new') // ?new (값 생략 시 '1')
// overlay.open('detail', id, { replace: true }) // 같은 오버레이 내 값만 교체(히스토리 안 쌓음)
// overlay.close() // 그룹 내 모든 오버레이 키 제거 // overlay.close() // 그룹 내 모든 오버레이 키 제거
const OVERLAY_PUSHED = '__overlayPushed'; const OVERLAY_PUSHED = '__overlayPushed';
@ -29,11 +30,22 @@ export function useOverlayRouter<K extends string>(keys: readonly K[]) {
return s ? `?${s}` : ''; return s ? `?${s}` : '';
}; };
const open = (key: K, value = '1') => const open = (key: K, value = '1', opts?: { replace?: boolean }) => {
const replace = opts?.replace ?? false;
navigate( navigate(
{ pathname: location.pathname, search: buildSearch((p) => p.set(key, value)) }, { pathname: location.pathname, search: buildSearch((p) => p.set(key, value)) },
{ state: { ...(location.state ?? {}), [OVERLAY_PUSHED]: true } }, {
replace,
// push: 새 히스토리 → 닫을 때 navigate(-1)로 빠지게 표식.
// replace(같은 오버레이 안에서 값만 교체, 예: 차수 전환): 히스토리를 안 쌓고,
// 원래 열림이 push였는지 표식을 그대로 물려준다(닫기 동작이 일관되게 리스트로 빠지도록).
state: {
...(location.state ?? {}),
[OVERLAY_PUSHED]: replace ? location.state?.[OVERLAY_PUSHED] ?? false : true,
},
},
); );
};
const close = () => { const close = () => {
if (location.state?.[OVERLAY_PUSHED]) { if (location.state?.[OVERLAY_PUSHED]) {

View File

@ -0,0 +1,30 @@
import { useEffect } from 'react';
// 오버레이(시트/모달)가 열려 있는 동안 뒤 페이지 스크롤을 잠근다.
// 이 앱 레이아웃 루트는 min-h-screen(높이 제약 없음)이라 <main> 이 아니라 문서(html/body)가 스크롤된다.
// 그래서 실제 스크롤 주체인 document.scrollingElement(보통 <html>)를 잠근다.
// 중첩 오버레이 대비 카운터로 관리 — 마지막 하나가 닫힐 때만 원복.
let lockCount = 0;
let prevOverflow = '';
function scroller(): HTMLElement {
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
}
export function useScrollLock(active = true) {
useEffect(() => {
if (!active) return;
if (lockCount === 0) {
const el = scroller();
prevOverflow = el.style.overflow;
el.style.overflow = 'hidden';
}
lockCount += 1;
return () => {
lockCount -= 1;
if (lockCount === 0) {
scroller().style.overflow = prevOverflow;
}
};
}, [active]);
}

View File

@ -40,6 +40,7 @@ export default function QuotationPage() {
addSetting, addSetting,
deleteSetting, deleteSetting,
createQuotation, createQuotation,
regenerateQuotation,
} = useQuotations(params); } = useQuotations(params);
const totalPages = list.totalPages(total); const totalPages = list.totalPages(total);
@ -49,7 +50,10 @@ export default function QuotationPage() {
const isSettingsOpen = overlay.has('settings'); const isSettingsOpen = overlay.has('settings');
// 상세 요약은 리스트에서 find 하지 않고 단건 API 로 받아온다(딥링크 시 리스트 의존 제거). // 상세 요약은 리스트에서 find 하지 않고 단건 API 로 받아온다(딥링크 시 리스트 의존 제거).
const detailQuery = useGetQuotation(detailId ?? '', { query: { enabled: !!detailId } }); // 탭 복귀 시 재조회(자리비운 사이 스케줄러가 마감/낙찰/재생성했을 수 있음). 전역 기본은 false라 상세만 켠다.
const detailQuery = useGetQuotation(detailId ?? '', {
query: { enabled: !!detailId, refetchOnWindowFocus: true },
});
const activeQuotation = detailQuery.data?.quotation ?? null; const activeQuotation = detailQuery.data?.quotation ?? null;
return ( return (
@ -128,6 +132,11 @@ export default function QuotationPage() {
data={quotations} data={quotations}
products={products} products={products}
onOpenDetail={(id) => overlay.open('detail', id)} onOpenDetail={(id) => overlay.open('detail', id)}
onFilterChain={(number) => {
// 견적번호 클릭 → 검색어를 그 번호로 즉시 세팅(같은 체인의 차수만 모아 보기).
list.setSearch(number);
list.submitSearch();
}}
footer={ footer={
<TablePagination <TablePagination
page={list.page} page={list.page}
@ -146,6 +155,8 @@ export default function QuotationPage() {
key={activeQuotation.qt_id} key={activeQuotation.qt_id}
quotation={activeQuotation} quotation={activeQuotation}
onCloseQuotation={closeQuotation} onCloseQuotation={closeQuotation}
onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })}
onRegenerate={regenerateQuotation}
onClose={overlay.close} onClose={overlay.close}
/> />
)} )}

View File

@ -3,23 +3,25 @@
-- 전체/공용 시드가 아니다. 재실행 안전(WHERE NOT EXISTS) — 운영 DB 에는 적용하지 않는다. -- 전체/공용 시드가 아니다. 재실행 안전(WHERE NOT EXISTS) — 운영 DB 에는 적용하지 않는다.
-- admin 계정은 negodata 프론트 로그인 폼 기본값(admin / admin1234)에 대응한다. -- admin 계정은 negodata 프론트 로그인 폼 기본값(admin / admin1234)에 대응한다.
-- 적용(도커 postgres): docker exec -i negosium-db psql -U postgres -d negosium_db < postgres-init/03-seed-negodata.sql -- 적용(도커 postgres): docker exec -i negosium-db psql -U postgres -d negosium_db < postgres-init/03-seed-negodata.sql
-- created_at/updated_at/deleted 은 MainTableMixin server_default 로 자동 채워져 INSERT 에 안 넣는다. -- created_at/updated_at/deleted 은 server_default 로 자동 채워져 INSERT 에 안 넣는다.
\connect negosium_db \connect negosium_db
-- 회사 1개 (고정 UUID). status 1=active. code/industry 는 코드값이라 비워둠(nullable). -- 회사 1개 (고정 UUID — users.company_id FK 가 참조). status 1=active.
INSERT INTO company.companies (name, business_number, representative_name, email, contact_number, website_url, status) INSERT INTO company.companies (company_id, name, business_number, representative_name, email, contact_number, website_url, status)
SELECT '아이마켓코리아', '220-88-21724', '홍길동', SELECT 'a35152d2-db61-4760-9f1e-beb9736d957f', '아이마켓코리아', '220-88-21724', '홍길동',
'admin@imarketkorea.com', '02-3708-5000', 'https://www.imarketkorea.com', 1 'admin@imarketkorea.com', '02-3708-5000', 'https://www.imarketkorea.com', 1
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM company.companies WHERE company_id = '00000000-0000-0000-0000-000000000001' SELECT 1 FROM company.companies WHERE company_id = 'a35152d2-db61-4760-9f1e-beb9736d957f'
); );
-- admin 유저. password 는 'admin123' 의 bcrypt 해시(백엔드 GetHashedPW 와 동일 알고리즘, checkpw 로 검증됨). -- admin 유저. company_id 는 위 회사(고정 UUID)에 묶는다(NOT NULL).
-- password 는 'admin1234' 의 bcrypt 해시(백엔드 GetHashedPW 와 동일 알고리즘, checkpw 로 검증됨).
-- role 2=manager (UserRole.MANAGER; ADMIN 코드는 enum 에 없어 최상위인 MANAGER 사용). status 1=active. -- role 2=manager (UserRole.MANAGER; ADMIN 코드는 enum 에 없어 최상위인 MANAGER 사용). status 1=active.
INSERT INTO company.users (id, password, name, email, contact_number, last_accessed_at, status, role) INSERT INTO company.users (company_id, id, password, name, email, contact_number, last_accessed_at, status, role)
SELECT 'a35152d2-db61-4760-9f1e-beb9736d957f',
'admin', 'admin',
'$2b$12$KY4T0kXQ2npvvt71iWZG0.JZHlMNt9angIkE/7.lBC4vta4dHgrj2', '$2b$12$E.y.XVR.MxmzOMd2satUDuVhvQA4kRsNJu7lNbZA4YZf/UWVcslSy',
'관리자', 'admin@imarketkorea.com', '02-3708-5000', now(), 1, 2 '관리자', 'admin@imarketkorea.com', '02-3708-5000', now(), 1, 2
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM company.users WHERE id = 'admin' AND deleted = FALSE SELECT 1 FROM company.users WHERE id = 'admin' AND deleted = FALSE