[feat] negodata·negosium: 엑셀 데이터 내보내기·견적상세 협상카드 연동·드로어 뒤로가기

- 엑셀 데이터 내보내기(상품·협력사·협상카드): 현재 검색/페이지 필터를 무시하고 전체를 페이지 순회로 받아(fetchAllForExport, 무언 절삭 방지) 양식과 동일 헤더로 CSV 내보내기(downloadXxxData) + '데이터 다운로드' 메뉴·todayStamp 파일명 → 내려받아 수정 후 재업로드(라운드트립)
- 견적상세 ChatTab: 사용 협상카드 매칭을 chat_id→card_id(카드 PK)로 교정, 카드 사용 메시지 본문 script 중복 제거(카드 박스에만 노출), 카드칩→/cards?detail= 상세 링크, 카드 멘트 실가격 마스킹(target_price 변수 미주입+maskPrices)
- 견적상세 협상카드 탭: 번호·스크립트 미리보기 컬럼 추가(cardScriptPreview 평문 추출)·카드명 상세 링크
- 상세 드로어: 백드롭 좌상단 '뒤로'(navigate(-1)) 버튼 추가(데스크톱), 공용 Sheet·견적상세 동일 적용
- 협상요약(negosium): 배송 리드타임 라벨을 회사설정(labels.lead_time) 연동 → '표준납기' 표기
This commit is contained in:
Mina Choi 2026-07-30 11:03:51 +09:00
parent 35d38e0818
commit 2a004734d8
16 changed files with 305 additions and 36 deletions

View File

@ -12,6 +12,9 @@ export function Summary({ data }: { data: ChatSummary }) {
// 공급사가 마무리에서 남긴 의견(있을 때만 표시)
const custom = useChatInitStore((s) => s.custom)
const opinion = custom?.opinion ? String(custom.opinion) : ''
// 납기 라벨은 회사 설정(labels.lead_time) 기준 — IMK 등은 '표준납기'. 미설정 시 기본 '배송 리드타임'.
const labels = useChatInitStore((s) => s.labels)
const leadTimeLabel = labels?.['lead_time'] || '배송 리드타임'
return (
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
@ -43,7 +46,7 @@ export function Summary({ data }: { data: ChatSummary }) {
<Row label="제품 규격" value={data.item_spec || '-'} />
<Row label="최소 주문" value={data.item_moq || '-'} />
<Row label="배송 형태" value={data.item_delivery_type || '-'} />
<Row label="배송 리드타임" value={formatLeadTime(data.item_lead_time) || '-'} />
<Row label={leadTimeLabel} value={formatLeadTime(data.item_lead_time) || '-'} />
<div className="flex items-start justify-between gap-3 px-4 py-3">
<span className="shrink-0 text-sm text-neutral-60"> </span>
<span className="min-w-0 flex-1 break-keep text-right text-sm">

View File

@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { useNavigate } from 'react-router';
import { ChevronLeft, X } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
type SheetProps = {
@ -16,12 +17,24 @@ type SheetProps = {
// body 로 포털 — 페이지 스크롤 컨테이너(main) 안에 렌더되면 시트 스크롤이 끝에서
// 부모(main)로 체이닝돼 모바일에서 뒤 화면이 스크롤되므로, DOM 계보 자체를 분리한다.
export function Sheet({ open, title, onClose, children }: SheetProps) {
const navigate = useNavigate();
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex justify-end bg-black/40 backdrop-blur-xs animate-fade-in">
{/* 백드롭 */}
<div className="flex-1 cursor-pointer" onClick={onClose} />
{/* 백드롭(어두운 영역) — 클릭 시 닫힘. 좌상단에 뒤로가기(직전 화면 히스토리 back). 모바일(md 미만)에서는 숨김 */}
<div className="flex-1 cursor-pointer relative" onClick={onClose}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); navigate(-1); }}
title="이전 화면으로"
className="hidden md:inline-flex items-center gap-1.5 absolute top-6 left-6 px-3 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white cursor-pointer backdrop-blur-sm"
>
<ChevronLeft size={18} />
<Typography as="span" variant="small" className="text-inherit font-semibold"></Typography>
</button>
</div>
{/* ( justify-* )
overscroll-contain: 스크롤 / pb safe-area */}

View File

@ -2,13 +2,14 @@ import { useMemo, useRef, useState } from 'react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { CardUsageType } from '@/api/generated/model';
import { deserialize } from '../editor';
import type { CardInput } from '../hooks/useCards';
import type { NegotiationCard } from '@/types';
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 검증에서 파생한다.
type RawRow = {
@ -359,3 +360,28 @@ export function downloadCardTemplate() {
],
);
}
// 카드용도 코드 → 양식과 동일한 원문(usageCode 파싱과 라운드트립: 신규→NEW·재→REUSE·그 외 COMMON).
const USAGE_LABEL: Record<number, string> = {
[CardUsageType.COMMON]: '공통',
[CardUsageType.NEW]: '신규견적전용',
[CardUsageType.REUSE]: '재견적전용',
};
// 서버의 실제 카드 데이터를 양식과 동일한 헤더로 내보낸다(가져와 수정 → 재업로드 라운드트립).
// 스크립트는 저장된 마커 문자열 그대로 나간다(업로드는 평문으로 재등록).
export function downloadCardData(cards: NegotiationCard[]) {
downloadExcel<NegotiationCard>(
`협상카드_목록_${todayStamp()}`,
[
{ header: '카드종류', value: (c) => (c.isWildcard ? '와일드카드' : '협상카드') },
{ header: '카드번호', value: (c) => c.code },
{ header: '카드이름', value: (c) => c.title },
{ header: '스크립트', value: (c) => c.scriptPreview },
{ header: '카드용도', value: (c) => USAGE_LABEL[c.usageType] ?? '공통' },
{ header: '사용조건', value: (c) => c.triggerCondition ?? '' },
{ header: '메모', value: (c) => c.memo ?? '' },
],
cards,
);
}

View File

@ -1,11 +1,13 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import {
useListCards,
listCards,
createCard,
updateCard,
deleteCard,
} from '@/api/generated/card/card';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
import type { CardData } from '@/api/generated/model/cardData';
import type { Descendant } from 'slate';
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
import type { ResCard } from '@/api/generated/model/resCard';
@ -101,6 +103,20 @@ export function useCards(params: ListCardsParams) {
const totalNego = cardsQuery.data?.total_nego ?? 0; // 협상카드 탭 카운트
const totalWild = cardsQuery.data?.total_wild ?? 0; // 와일드카드 탭 카운트
// 내보내기용 전체 조회 — 현재 탭/검색 필터를 무시하고 페이지 순회로 협상·와일드 전량을 받는다(무언 절삭 방지).
const fetchAllForExport = async (): Promise<NegotiationCard[]> => {
const size = 100;
const acc: CardData[] = [];
for (let page = 1; ; page++) {
const res = await listCards({ page, size });
const batch = res.cards ?? [];
acc.push(...batch);
const totalCount = res.total ?? acc.length;
if (batch.length === 0 || acc.length >= totalCount) break;
}
return acc.map(mapCardData);
};
return {
cards,
total,
@ -110,6 +126,7 @@ export function useCards(params: ListCardsParams) {
updateCard: updateCardFn,
deleteCard: deleteCardFn,
bulkCreate,
fetchAllForExport,
refresh,
cardsQuery,
};

View File

@ -3,7 +3,7 @@ import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
import { customFetch } from '@/api/mutator/custom-fetch';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
@ -98,6 +98,23 @@ export function downloadPartnerTemplate() {
);
}
// 서버의 실제 협력사 데이터를 양식과 동일한 헤더로 내보낸다(가져와 수정 → 재업로드 라운드트립).
// 취급상품은 목록 응답에 없어 빈칸으로 둔다 — 재업로드는 매핑을 추가만 하므로 비파괴.
export function downloadPartnerData(partners: Partner[]) {
downloadExcel<Partner>(
`협력사_목록_${todayStamp()}`,
[
{ header: '협력사명', value: (p) => p.name },
{ header: '식별코드', value: (p) => p.code ?? '' },
{ header: '담당자명', value: (p) => p.manager_name ?? '' },
{ header: '담당자이메일', value: (p) => p.manager_email ?? '' },
{ header: '총매출액', value: (p) => p.total_revenue ?? '' },
{ header: '취급상품', value: () => '' },
],
partners,
);
}
// 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) {

View File

@ -1,6 +1,7 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import {
useListSuppliers,
listSuppliers,
createSupplier,
updateSupplier,
deleteSupplier,
@ -92,6 +93,20 @@ export function usePartners(params: ListSuppliersParams) {
// 전체(메타) 협력사 — 엑셀 중복검사 모달용(현재 페이지에 없을 수 있어 전체 기준).
const allPartners: Partner[] = (metaQuery.data?.suppliers ?? []).map(toPartner);
// 내보내기용 전체 조회 — 현재 검색/페이지 필터를 무시하고 페이지 순회로 전량을 받는다(무언 절삭 방지).
const fetchAllForExport = async (): Promise<Partner[]> => {
const size = 100;
const acc: SupplierData[] = [];
for (let page = 1; ; page++) {
const res = await listSuppliers({ page, size });
const batch = res.suppliers ?? [];
acc.push(...batch);
const totalCount = res.total ?? acc.length;
if (batch.length === 0 || acc.length >= totalCount) break;
}
return acc.map(toPartner);
};
return {
partners,
total,
@ -100,6 +115,7 @@ export function usePartners(params: ListSuppliersParams) {
updatePartner,
deletePartner,
bulkCreate,
fetchAllForExport,
refresh,
isLoading: suppliersQuery.isLoading,
};

View File

@ -3,7 +3,7 @@ import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
import { useScrollLock } from '@/lib/useScrollLock';
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
import { customFetch } from '@/api/mutator/custom-fetch';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
@ -162,6 +162,34 @@ export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldD
);
}
// 실제 상품(Product) 한 건에서 업로드 컬럼 값을 뽑는다 — 헤더/코드 변환이 양식·파싱과 일치해야 재업로드된다.
function productCell(p: Product, c: UploadColumn, label: LabelFn): string | number | null | undefined {
if (c.customKey) {
const v = (p.custom as Record<string, unknown> | undefined)?.[c.customKey];
if (v == null || v === '') return '';
if (typeof v === 'boolean') return v ? 'Y' : 'N';
return c.numeric ? Number(v) : String(v);
}
switch (c.key) {
case 'minPrice': return p.internet_lowest_price ?? '';
case 'suppliers': return (p.supplier_names ?? []).join(', ');
case 'delivery_type': return p.delivery_type ? label(`delivery_type.${p.delivery_type}`) : '';
case 'vat_yn': return p.vat_yn == null ? '' : p.vat_yn ? 'Y' : 'N';
case 'delivery_fee_yn': return p.delivery_fee_yn == null ? '' : p.delivery_fee_yn ? 'Y' : 'N';
default: return (p[c.key as keyof Product] as string | number | null | undefined) ?? '';
}
}
// 서버의 실제 상품 데이터를 양식과 동일한 헤더(회사 라벨/커스텀필드 반영)로 내보낸다(라운드트립).
export function downloadProductData(products: Product[], label: LabelFn, itemFields: CustomFieldDef[], isHidden: (key: string) => boolean = () => false) {
const columns = buildColumns(label, itemFields, isHidden);
downloadExcel<Product>(
`상품_목록_${todayStamp()}`,
columns.map((c) => ({ header: c.header, value: (p) => productCell(p, c, label) })),
products,
);
}
type ExcelUploadModalProps = {
open: boolean;
products: Product[]; // 코드 중복 검사용

View File

@ -2,6 +2,7 @@ import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import {
useListItems,
useListItemCategories,
listItems,
createItem,
updateItem,
deleteItem,
@ -112,6 +113,20 @@ export function useProducts(params: ListItemsParams) {
// 전체(메타) 상품 — 엑셀 중복검사/최저가 모달이 코드·id 로 조회한다(현재 페이지에 없을 수 있어 전체 기준).
const allProducts: Product[] = (metaQuery.data?.items ?? []).map(toProduct);
// 내보내기용 전체 조회 — 현재 검색/카테고리 필터를 무시하고 페이지 순회로 전량을 받는다(무언 절삭 방지).
const fetchAllForExport = async (): Promise<Product[]> => {
const size = 100;
const acc: ItemData[] = [];
for (let page = 1; ; page++) {
const res = await listItems({ page, size });
const batch = res.items ?? [];
acc.push(...batch);
const totalCount = res.total ?? acc.length;
if (batch.length === 0 || acc.length >= totalCount) break;
}
return acc.map(toProduct);
};
// 카테고리: 백엔드 distinct 결과를 그대로 쓴다(프론트가 상품을 긁어 만들지 않는다).
// category_type(카테고리 id)은 이름→코드로 보존하고, 신규 카테고리는 max+1 을 부여한다(폼에서 사용).
const categoryTypeByName: Record<string, number> = {};
@ -136,6 +151,7 @@ export function useProducts(params: ListItemsParams) {
updateProduct,
deleteProduct,
bulkCreate,
fetchAllForExport,
refresh,
isLoading: itemsQuery.isLoading,
};

View File

@ -1,3 +1,4 @@
import { Link } from 'react-router';
import { Download, Sparkles } from 'lucide-react';
import type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
@ -29,8 +30,8 @@ function exportChatJson(
status: session ? sessionStatusLabel(session.status) : null,
message_count: messages.length,
messages: messages.map((m) => {
// 사용 카드는 화면 버블과 동일하게 chat_id 로 매칭(card_type 만으론 어느 카드인지 알 수 없음).
const card = m.card_used_yn ? serverCards.find((c) => c.session_card_id === m.chat_id) : undefined;
// 사용 카드는 화면 버블과 동일하게 card_id(=카드 PK) 로 매칭. session_card_id 는 nego/wild 카드 PK.
const card = m.card_used_yn ? serverCards.find((c) => c.session_card_id === m.card_id) : undefined;
return {
seq: m.index,
sender: m.sender === ChatSender.BOT ? 'BOT' : 'PARTNER',
@ -223,6 +224,8 @@ function BotBubble({
serverCards: QuotationCardData[];
}) {
const m = message;
// 카드 사용 메시지는 같은 멘트가 아래 카드 박스에 그대로 나오므로 본문 script 는 중복 → 카드 없을 때만 노출.
const usedCard = findUsedCard(m, serverCards);
return (
<div className="flex justify-start">
<div className="flex flex-col gap-1.5 max-w-[85%]">
@ -237,7 +240,7 @@ function BotBubble({
{m.step}
</Typography>
)}
{m.script && (
{m.script && !usedCard && (
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
{renderEmphasis(maskPrices(m.script))}
</Typography>
@ -316,13 +319,11 @@ function UsedCardBox({
serverCards: QuotationCardData[];
}) {
const m = message;
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
const usedCard = m.card_used_yn
? serverCards.find((c) => c.session_card_id === m.chat_id)
: undefined;
const usedCard = findUsedCard(m, serverCards);
if (!usedCard) return null;
const cardNodes = Array.isArray(usedCard.edit_script) ? (usedCard.edit_script as unknown[]) : null;
const isWildCard = usedCard.type === CardType.WILD;
const cardDetailId = usedCard.nego_card_id ?? usedCard.wild_card_id ?? null; // 협상카드 상세(/cards?detail=) 링크용
return (
<div
@ -336,11 +337,28 @@ function UsedCardBox({
<div className={`flex items-center gap-1 ${isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'}`}>
<Sparkles size={10} />
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit"></Typography>
{usedCard.number && (
<Typography as="span" variant="small" className="text-[10px] font-mono font-semibold opacity-70 text-inherit">#{usedCard.number}</Typography>
)}
{usedCard.name && (
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">· {usedCard.name}</Typography>
{cardDetailId ? (
<Link
to={`/cards?detail=${cardDetailId}`}
className="inline-flex items-center gap-1 text-inherit underline underline-offset-2 decoration-1"
title={`${usedCard.name ?? ''} — 협상카드 상세로 이동`}
>
{usedCard.number && (
<Typography as="span" variant="small" className="text-[10px] font-mono font-semibold opacity-70 text-inherit">#{usedCard.number}</Typography>
)}
{usedCard.name && (
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">· {usedCard.name}</Typography>
)}
</Link>
) : (
<>
{usedCard.number && (
<Typography as="span" variant="small" className="text-[10px] font-mono font-semibold opacity-70 text-inherit">#{usedCard.number}</Typography>
)}
{usedCard.name && (
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">· {usedCard.name}</Typography>
)}
</>
)}
<Typography
as="span"
@ -355,13 +373,12 @@ function UsedCardBox({
</Typography>
</div>
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script. 가격 변수는 매핑하지 않아(값 미주입) 실가격이 노출되지 않는다. */}
{cardNodes ? (
<div className="mt-1.5">
<SlateRenderer
nodes={cardNodes}
variables={{
target_price: m.target_price,
partner_name: currentSupplierName,
product_name: currentProduct?.name ?? '',
}}
@ -369,7 +386,7 @@ function UsedCardBox({
</div>
) : usedCard.script ? (
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
{usedCard.script}
{maskPrices(usedCard.script)}
</Typography>
) : null}
@ -391,3 +408,8 @@ function UsedCardBox({
</div>
);
}
// 메시지가 사용한 협상카드를 card_id(=카드 PK)로 매칭. 카드 미사용이면 undefined.
function findUsedCard(m: ChatMessageData, serverCards: QuotationCardData[]): QuotationCardData | undefined {
return m.card_used_yn ? serverCards.find((c) => c.session_card_id === m.card_id) : undefined;
}

View File

@ -1,6 +1,6 @@
import { Link } from 'react-router';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { typographyVariants } from '@/components/ui/typography';
import { Typography, typographyVariants } from '@/components/ui/typography';
import { StatusPill } from './StatusPill';
import { mapServerCardView } from '../../types';
@ -13,7 +13,9 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews:
<Table className="w-full text-left text-xs border-collapse font-mono">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"> </TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
<TableHead className="p-3 font-semibold font-sans"></TableHead>
</TableRow>
</TableHeader>
@ -21,7 +23,12 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews:
{quotationCardViews.length > 0 ? (
quotationCardViews.map((qc) => (
<TableRow key={qc.session_card_id} className="hover:bg-muted/30 transition-colors text-[11px]">
<TableCell className="p-3 text-foreground font-sans font-semibold">
<TableCell className="p-3 align-top whitespace-nowrap">
<Typography as="span" variant="small" className="text-[11px] font-mono font-semibold text-muted-foreground">
{qc.number ? `#${qc.number}` : '—'}
</Typography>
</TableCell>
<TableCell className="p-3 text-foreground font-sans font-semibold align-top">
{qc.card_id ? (
<Link
to={`/cards?detail=${qc.card_id}`}
@ -34,7 +41,23 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews:
qc.card_name
)}
</TableCell>
<TableCell className="p-3">
<TableCell className="p-3 max-w-[320px] align-top">
{qc.script_preview ? (
<Typography
as="p"
variant="small"
className="text-[11px] leading-snug text-muted-foreground font-sans line-clamp-2 whitespace-pre-line"
title={qc.script_preview}
>
{qc.script_preview}
</Typography>
) : (
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground/50 font-sans">
</Typography>
)}
</TableCell>
<TableCell className="p-3 align-top">
<StatusPill tone={qc.type === '와일드 카드' ? 'amber' : 'zinc'} className="rounded">
{qc.type}
</StatusPill>
@ -43,7 +66,7 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews:
))
) : (
<TableRow>
<TableCell colSpan={2} className="p-12 text-center text-muted-foreground">
<TableCell colSpan={4} className="p-12 text-center text-muted-foreground">
. ( )
</TableCell>
</TableRow>

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { createPortal } from 'react-dom';
import { CheckCircle2, X, UserCheck, MessageSquare, Layers, RefreshCw } from 'lucide-react';
import { CheckCircle2, ChevronLeft, X, UserCheck, MessageSquare, Layers, RefreshCw } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import {
useGetQuotationSessions,
@ -63,6 +64,7 @@ export function QuotationDetailSheet({
const [showHeaderCards, setShowHeaderCards] = useState(true);
const [regenOpen, setRegenOpen] = useState(false);
const [targetSessionId, setTargetSessionId] = useState<string | null>(null);
const navigate = useNavigate();
// 시트 열린 동안 뒤 견적 리스트(<main>) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지.
useScrollLock();
@ -146,7 +148,18 @@ export function QuotationDetailSheet({
// 부모로 체이닝돼 모바일에서 뒤 화면이 스크롤된다.
return createPortal(
<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} />
{/* 백드롭(어두운 영역) — 클릭 시 닫힘. 좌상단에 뒤로가기(직전 화면 히스토리 back). 모바일(md 미만)에서는 숨김 */}
<div className="flex-1 cursor-pointer relative" onClick={onClose}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); navigate(-1); }}
title="이전 화면으로"
className="hidden md:inline-flex items-center gap-1.5 absolute top-6 left-6 px-3 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white cursor-pointer backdrop-blur-sm"
>
<ChevronLeft size={18} />
<Typography as="span" variant="small" className="text-inherit font-semibold"></Typography>
</button>
</div>
<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">

View File

@ -256,8 +256,10 @@ export type SessionView = {
export type QuotationCardView = {
session_card_id: string;
card_id: string | null; // 실제 카드 id(협상=nego_card_id, 와일드=wild_card_id) — /cards?detail= 링크용
number: string; // 카드 번호(#1, #2 …) — 협상/와일드 각자 시퀀스, 없으면 ''
card_name: string;
type: string;
script_preview: string; // 카드 멘트 평문 미리보기(목록용) — 서식·변수치환 없이 텍스트만
};
// ── 견적 결과 요약(목표가 대비 낙찰/절감) ─────────────────────────────────
@ -394,13 +396,31 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
};
}
// 카드 멘트 평문 미리보기. 평문 script 우선, 없으면 서식본(edit_script Slate)에서 텍스트 노드만 이어붙인다.
function cardScriptPreview(c: QuotationCardData): string {
if (typeof c.script === 'string' && c.script.trim()) return c.script.trim();
const nodes = Array.isArray(c.edit_script) ? (c.edit_script as unknown[]) : null;
if (!nodes) return '';
const collect = (node: unknown): string => {
if (node && typeof node === 'object') {
const n = node as { text?: unknown; children?: unknown };
if (typeof n.text === 'string') return n.text;
if (Array.isArray(n.children)) return n.children.map(collect).join('');
}
return '';
};
return nodes.map(collect).join(' ').replace(/\s+/g, ' ').trim();
}
// 서버 QuotationCardData → 사용 카드 뷰. type 2=와일드, 그 외 협상카드.
export function mapServerCardView(c: QuotationCardData): QuotationCardView {
return {
session_card_id: c.session_card_id,
card_id: c.nego_card_id ?? c.wild_card_id ?? null,
number: c.number ?? '',
card_name: c.name || '-',
type: c.type === CardType.WILD ? '와일드 카드' : '협상 카드',
script_preview: cardScriptPreview(c),
};
}
@ -411,8 +431,10 @@ export function buildQuotationCards(est: Estimate, cards: NegotiationCard[]): Qu
return {
session_card_id: `scard-${est.id}-${cId}`,
card_id: cId,
number: matchedCard?.code ?? '',
card_name: matchedCard?.title || '-',
type: matchedCard?.isWildcard ? '와일드 카드' : '협상 카드',
script_preview: matchedCard?.scriptPreview ?? '',
};
});
}

View File

@ -16,6 +16,13 @@ function escapeCell(v: string | number | null | undefined): string {
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
// 파일명용 오늘 날짜(YYYYMMDD, 로컬 기준). 내보내기 파일명에 붙인다.
export function todayStamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`;
}
export function downloadExcel<T>(filename: string, columns: ExcelColumn<T>[], rows: T[]): void {
const header = columns.map((c) => escapeCell(c.header)).join(',');
const body = rows

View File

@ -1,4 +1,4 @@
import { Plus, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { Plus, Upload, Download, FileDown, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
@ -13,7 +13,7 @@ import { useCards } from '@/features/cards/hooks/useCards';
import { useGetCard } from '@/api/generated/card/card';
import { CardTable } from '@/features/cards/components/CardTable';
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
import { CardExcelUploadModal, downloadCardTemplate } from '@/features/cards/components/CardExcelUploadModal';
import { CardExcelUploadModal, downloadCardTemplate, downloadCardData } from '@/features/cards/components/CardExcelUploadModal';
import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types';
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
@ -27,7 +27,7 @@ export default function CardsPage() {
page: list.page,
size: list.pageSize,
};
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard, bulkCreate } = useCards(params);
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard, bulkCreate, fetchAllForExport } = useCards(params);
const totalPages = list.totalPages(total);
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
@ -43,6 +43,17 @@ export default function CardsPage() {
const openCreate = () => overlay.open('new');
const openEdit = (card: NegotiationCard) => overlay.open('detail', card.id);
// 서버의 전체 카드(협상+와일드)를 CSV로 내려받는다(양식과 동일 헤더 → 수정 후 재업로드).
const handleExport = async () => {
try {
const rows = await fetchAllForExport();
if (rows.length === 0) { showToast('내보낼 카드가 없습니다.', 'info'); return; }
downloadCardData(rows);
} catch (err) {
showToast(err instanceof Error ? err.message : '데이터 다운로드 실패', 'error');
}
};
const handleDeleteCard = async (id: string, cardName: string) => {
if (await confirm({ title: '카드 삭제', description: `[${cardName}]을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
try {
@ -83,6 +94,10 @@ export default function CardsPage() {
<Download />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}>
<FileDown />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Plus, Upload, Download, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react';
import { Plus, Upload, Download, FileDown, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
@ -14,7 +14,7 @@ import { usePartners } from '@/features/partners/hooks/usePartners';
import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams';
import { PartnerTable } from '@/features/partners/components/PartnerTable';
import { PartnerFormSheet } from '@/features/partners/components/PartnerFormSheet';
import { ExcelUploadModal, downloadPartnerTemplate } from '@/features/partners/components/ExcelUploadModal';
import { ExcelUploadModal, downloadPartnerTemplate, downloadPartnerData } from '@/features/partners/components/ExcelUploadModal';
import { type Partner } from '@/features/partners/types';
export default function PartnersPage() {
@ -26,7 +26,7 @@ export default function PartnersPage() {
size: list.pageSize,
};
const { partners, total, allPartners, createPartner, updatePartner, deletePartner, bulkCreate } =
const { partners, total, allPartners, createPartner, updatePartner, deletePartner, bulkCreate, fetchAllForExport } =
usePartners(params);
const totalPages = list.totalPages(total);
@ -63,6 +63,17 @@ export default function PartnersPage() {
const openCreate = () => overlay.open('new');
const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id);
// 서버의 전체 협력사를 CSV로 내려받는다(양식과 동일 헤더 → 수정 후 재업로드).
const handleExport = async () => {
try {
const rows = await fetchAllForExport();
if (rows.length === 0) { showToast('내보낼 협력사가 없습니다.', 'info'); return; }
downloadPartnerData(rows);
} catch (err) {
showToast(err instanceof Error ? err.message : '데이터 다운로드 실패', 'error');
}
};
const handleDeletePartner = async (id: string, partnerName: string) => {
if (await confirm({ title: '협력사 삭제', description: `[${partnerName}] 파트너사를 협력사 목록에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
try {
@ -110,6 +121,10 @@ export default function PartnersPage() {
<Download />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}>
<FileDown />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Plus, Upload, TrendingDown, Download, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react';
import { Plus, Upload, TrendingDown, Download, FileDown, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
@ -17,7 +17,7 @@ import { ProductTable } from '@/features/products/components/ProductTable';
import { ProductFormSheet } from '@/features/products/components/ProductFormSheet';
import { PriceUpdateModal } from '@/features/products/components/PriceUpdateModal';
import { LowestPriceHistorySheet } from '@/features/products/components/LowestPriceHistorySheet';
import { ExcelUploadModal, downloadProductTemplate } from '@/features/products/components/ExcelUploadModal';
import { ExcelUploadModal, downloadProductTemplate, downloadProductData } from '@/features/products/components/ExcelUploadModal';
import { type Product } from '@/features/products/types';
export default function ProductsPage() {
@ -45,6 +45,7 @@ export default function ProductsPage() {
updateProduct,
deleteProduct,
bulkCreate,
fetchAllForExport,
} = useProducts(params);
const totalPages = list.totalPages(total);
@ -65,6 +66,17 @@ export default function ProductsPage() {
const openCreate = () => overlay.open('new');
const openEdit = (prod: Product) => overlay.open('detail', prod.item_id);
// 서버의 전체 상품을 CSV로 내려받는다(양식과 동일 헤더/회사 라벨 → 수정 후 재업로드).
const handleExport = async () => {
try {
const rows = await fetchAllForExport();
if (rows.length === 0) { showToast('내보낼 상품이 없습니다.', 'info'); return; }
downloadProductData(rows, label, settings.item_fields ?? [], isHidden);
} catch (err) {
showToast(err instanceof Error ? err.message : '데이터 다운로드 실패', 'error');
}
};
// 선택 일괄삭제(IMK #8) — 단건 삭제 API 루프(엑셀 일괄등록과 동일 패턴). 소유자 아닌 행은 서버가 거부 → 실패 집계.
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return;
@ -138,6 +150,10 @@ export default function ProductsPage() {
<Download />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}>
<FileDown />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>