[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:
parent
35d38e0818
commit
2a004734d8
@ -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">
|
||||
|
||||
@ -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 확보 */}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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[]; // 코드 중복 검사용
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,12 +337,29 @@ 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>
|
||||
{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"
|
||||
variant="small"
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
|
||||
@ -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 ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user