From 2a004734d860fa9cbe326f7bb3926269320a44bd Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 30 Jul 2026 11:03:51 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata=C2=B7negosium:=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=C2=B7=EA=B2=AC=EC=A0=81=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=98=91=EC=83=81=EC=B9=B4=EB=93=9C=20=EC=97=B0=EB=8F=99=C2=B7?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=EC=96=B4=20=EB=92=A4=EB=A1=9C=EA=B0=80?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엑셀 데이터 내보내기(상품·협력사·협상카드): 현재 검색/페이지 필터를 무시하고 전체를 페이지 순회로 받아(fetchAllForExport, 무언 절삭 방지) 양식과 동일 헤더로 CSV 내보내기(downloadXxxData) + '데이터 다운로드' 메뉴·todayStamp 파일명 → 내려받아 수정 후 재업로드(라운드트립) - 견적상세 ChatTab: 사용 협상카드 매칭을 chat_id→card_id(카드 PK)로 교정, 카드 사용 메시지 본문 script 중복 제거(카드 박스에만 노출), 카드칩→/cards?detail= 상세 링크, 카드 멘트 실가격 마스킹(target_price 변수 미주입+maskPrices) - 견적상세 협상카드 탭: 번호·스크립트 미리보기 컬럼 추가(cardScriptPreview 평문 추출)·카드명 상세 링크 - 상세 드로어: 백드롭 좌상단 '뒤로'(navigate(-1)) 버튼 추가(데스크톱), 공용 Sheet·견적상세 동일 적용 - 협상요약(negosium): 배송 리드타임 라벨을 회사설정(labels.lead_time) 연동 → '표준납기' 표기 --- .../chat/components/templates/Summary.tsx | 5 +- negodata/front/src/components/ui/sheet.tsx | 19 +++++-- .../cards/components/CardExcelUploadModal.tsx | 28 +++++++++- .../src/features/cards/hooks/useCards.ts | 17 ++++++ .../partners/components/ExcelUploadModal.tsx | 19 ++++++- .../features/partners/hooks/usePartners.ts | 16 ++++++ .../products/components/ExcelUploadModal.tsx | 30 ++++++++++- .../features/products/hooks/useProducts.ts | 16 ++++++ .../QuotationDetailSheet/ChatTab.tsx | 52 +++++++++++++------ .../QuotationCardsTab.tsx | 31 +++++++++-- .../components/QuotationDetailSheet/index.tsx | 17 +++++- .../front/src/features/quotations/types.ts | 22 ++++++++ negodata/front/src/lib/excel.ts | 7 +++ negodata/front/src/pages/cards.tsx | 21 ++++++-- negodata/front/src/pages/partners.tsx | 21 ++++++-- negodata/front/src/pages/products.tsx | 20 ++++++- 16 files changed, 305 insertions(+), 36 deletions(-) diff --git a/frontend/src/features/chat/components/templates/Summary.tsx b/frontend/src/features/chat/components/templates/Summary.tsx index 2e8b1a7..bc861e5 100644 --- a/frontend/src/features/chat/components/templates/Summary.tsx +++ b/frontend/src/features/chat/components/templates/Summary.tsx @@ -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 (
@@ -43,7 +46,7 @@ export function Summary({ data }: { data: ChatSummary }) { - +
최종 협의 가격 diff --git a/negodata/front/src/components/ui/sheet.tsx b/negodata/front/src/components/ui/sheet.tsx index cf3632a..8769113 100644 --- a/negodata/front/src/components/ui/sheet.tsx +++ b/negodata/front/src/components/ui/sheet.tsx @@ -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(
- {/* 백드롭 */} -
+ {/* 백드롭(어두운 영역) — 클릭 시 닫힘. 좌상단에 뒤로가기(직전 화면 히스토리 back). 모바일(md 미만)에서는 숨김 */} +
+ +
{/* 우측 드로어 패널 (패널 자체가 세로 스크롤 — justify-* 는 스크롤 시작점을 가려 안 씀) overscroll-contain: 스크롤 끝에서 배경으로 체이닝 차단 / pb 는 모바일 하단 바 safe-area 확보 */} diff --git a/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx b/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx index 20a1b36..adf924d 100644 --- a/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx +++ b/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx @@ -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 = { + [CardUsageType.COMMON]: '공통', + [CardUsageType.NEW]: '신규견적전용', + [CardUsageType.REUSE]: '재견적전용', +}; + +// 서버의 실제 카드 데이터를 양식과 동일한 헤더로 내보낸다(가져와 수정 → 재업로드 라운드트립). +// 스크립트는 저장된 마커 문자열 그대로 나간다(업로드는 평문으로 재등록). +export function downloadCardData(cards: NegotiationCard[]) { + downloadExcel( + `협상카드_목록_${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, + ); +} diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index 666d87b..9ac242d 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -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 => { + 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, }; diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 1103f60..6c09cb7 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -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( + `협력사_목록_${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) { diff --git a/negodata/front/src/features/partners/hooks/usePartners.ts b/negodata/front/src/features/partners/hooks/usePartners.ts index ac89c1f..7e9ef8e 100644 --- a/negodata/front/src/features/partners/hooks/usePartners.ts +++ b/negodata/front/src/features/partners/hooks/usePartners.ts @@ -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 => { + 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, }; diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index aaa1c45..f81be20 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -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 | 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( + `상품_목록_${todayStamp()}`, + columns.map((c) => ({ header: c.header, value: (p) => productCell(p, c, label) })), + products, + ); +} + type ExcelUploadModalProps = { open: boolean; products: Product[]; // 코드 중복 검사용 diff --git a/negodata/front/src/features/products/hooks/useProducts.ts b/negodata/front/src/features/products/hooks/useProducts.ts index a3fd88f..cd6b52b 100644 --- a/negodata/front/src/features/products/hooks/useProducts.ts +++ b/negodata/front/src/features/products/hooks/useProducts.ts @@ -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 => { + 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 = {}; @@ -136,6 +151,7 @@ export function useProducts(params: ListItemsParams) { updateProduct, deleteProduct, bulkCreate, + fetchAllForExport, refresh, isLoading: itemsQuery.isLoading, }; diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index 32e9f69..4817836 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -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 (
@@ -237,7 +240,7 @@ function BotBubble({ {m.step} )} - {m.script && ( + {m.script && !usedCard && ( {renderEmphasis(maskPrices(m.script))} @@ -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 (
협상카드 - {usedCard.number && ( - #{usedCard.number} - )} - {usedCard.name && ( - · {usedCard.name} + {cardDetailId ? ( + + {usedCard.number && ( + #{usedCard.number} + )} + {usedCard.name && ( + · {usedCard.name} + )} + + ) : ( + <> + {usedCard.number && ( + #{usedCard.number} + )} + {usedCard.name && ( + · {usedCard.name} + )} + )}
- {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */} + {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script. 가격 변수는 매핑하지 않아(값 미주입) 실가격이 노출되지 않는다. */} {cardNodes ? (
) : usedCard.script ? ( - {usedCard.script} + {maskPrices(usedCard.script)} ) : null} @@ -391,3 +408,8 @@ function UsedCardBox({
); } + +// 메시지가 사용한 협상카드를 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; +} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx index 44e1a3b..8ac713d 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx @@ -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: + 번호 카드 이름 + 스크립트 타입 @@ -21,7 +23,12 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: {quotationCardViews.length > 0 ? ( quotationCardViews.map((qc) => ( - + + + {qc.number ? `#${qc.number}` : '—'} + + + {qc.card_id ? ( - + + {qc.script_preview ? ( + + {qc.script_preview} + + ) : ( + + — + + )} + + {qc.type} @@ -43,7 +66,7 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: )) ) : ( - + 사용된 협상 카드가 없습니다. (리스트가 비어 있습니다) diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx index 1d34300..dc060dd 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx @@ -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(null); + const navigate = useNavigate(); // 시트 열린 동안 뒤 견적 리스트(
) 스크롤 잠금 — 옆에 배경 스크롤바가 같이 뜨는 것 방지. useScrollLock(); @@ -146,7 +148,18 @@ export function QuotationDetailSheet({ // 부모로 체이닝돼 모바일에서 뒤 화면이 스크롤된다. return createPortal(
-
+ {/* 백드롭(어두운 영역) — 클릭 시 닫힘. 좌상단에 뒤로가기(직전 화면 히스토리 back). 모바일(md 미만)에서는 숨김 */} +
+ +
diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index 0ccdc01..7b19130 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -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 ?? '', }; }); } diff --git a/negodata/front/src/lib/excel.ts b/negodata/front/src/lib/excel.ts index 1aaf0d6..d4c7516 100644 --- a/negodata/front/src/lib/excel.ts +++ b/negodata/front/src/lib/excel.ts @@ -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(filename: string, columns: ExcelColumn[], rows: T[]): void { const header = columns.map((c) => escapeCell(c.header)).join(','); const body = rows diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index 699a76c..e551119 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -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() { 양식 다운로드 + + + 데이터 다운로드 + diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index c747376..bbddbaa 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -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() { 양식 다운로드 + + + 데이터 다운로드 + diff --git a/negodata/front/src/pages/products.tsx b/negodata/front/src/pages/products.tsx index fa75a1c..cf381b2 100644 --- a/negodata/front/src/pages/products.tsx +++ b/negodata/front/src/pages/products.tsx @@ -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() { 양식 다운로드 + + + 데이터 다운로드 +