[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:
parent
7f948075e5
commit
9f947e4823
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
|
||||||
@ -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 = (
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
선택된 협력사가 없습니다.
|
선택된 협력사가 없습니다.
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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]: {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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]) {
|
||||||
|
|||||||
30
negodata/front/src/lib/useScrollLock.ts
Normal file
30
negodata/front/src/lib/useScrollLock.ts
Normal 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]);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user