-
+ {/* 백드롭(어두운 영역) — 클릭 시 닫힘. 좌상단에 뒤로가기(직전 화면 히스토리 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() {
양식 다운로드
+
+
+ 데이터 다운로드
+