diff --git a/negodata/backend/crud/card_crud.py b/negodata/backend/crud/card_crud.py index dfce1da..3e2b122 100644 --- a/negodata/backend/crud/card_crud.py +++ b/negodata/backend/crud/card_crud.py @@ -1,12 +1,12 @@ from abc import ABC, abstractmethod from typing import Optional, Tuple -from sqlalchemy import select, func, and_, or_, update +from sqlalchemy import select, func, and_, or_, update, case from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import users -from common.enums import ErrorType +from common.database.model.models import users, chats, sessions +from common.enums import ErrorType, SessionStatus from common.logger import LOG from common.utils.gtime import GTime @@ -37,6 +37,10 @@ class ICardCRUD(ABC): async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: pass + @abstractmethod + async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]: + pass + @abstractmethod async def owner_company_id(self, cdb: AsyncSession, owner_user_id) -> Tuple[ErrorType, Optional[object]]: pass @@ -79,6 +83,28 @@ class CardCRUD(ICardCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 + async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]: + # 카드별 성공률: 카드 사용(card_used_yn) 채팅이 속한 세션의 타결(DONE) 비율. + # {card_id(UUID): (used_sessions, won_sessions)}. 성공=세션 DONE(협상완료). + try: + stmt = ( + select( + chats.card_id, + func.count(func.distinct(chats.session_id)).label("used"), + func.count(func.distinct(case((sessions.status == SessionStatus.DONE.value, chats.session_id)))).label("won"), + ) + .join(sessions, sessions.session_id == chats.session_id) + .where(chats.card_used_yn == True, chats.card_id.isnot(None), chats.deleted == False) # noqa: E712 + .group_by(chats.card_id) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + if err != ErrorType.SUCCESS: + return err, {} + return ErrorType.SUCCESS, {r[0]: (int(r[1] or 0), int(r[2] or 0)) for r in rows} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} + async def get_by_id(self, cdb: AsyncSession, model, pk_col, card_id) -> Tuple[ErrorType, object]: try: query = select(model).where(pk_col == card_id, model.deleted == False).limit(1) # noqa: E712 diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index 820019c..f6f456e 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -55,6 +55,8 @@ class CardData(WebPacketProtocol): memo: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None + success_rate: float = 0.0 # 카드 성공률(사용 세션 중 타결 비율). #12 순위용 + used_count: int = 0 # 카드 사용 세션 수(표본) class Res_Card(Res_WebPacketProtocol): diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index 9e6c845..a21381b 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -137,6 +137,17 @@ class CardService: merged.sort(key=lambda c: c.created_at or "", reverse=True) page = merged[pg.skip : pg.skip + pg.size] + # 카드 성공률(#12) — 카드 사용→타결 집계를 page 카드에 매핑. + _e, success_map = await DB_SESSION_MNG.execute_lambda( + nego_cards.DBType(), DBWRType.DB_READ.value, + lambda s: self.card_crud.card_success_map(s), + ) + success_map = success_map or {} + for c in page: + used, won = success_map.get(c.nego_card_id, (0, 0)) + c.used_count = used + c.success_rate = (won / used) if used else 0.0 + # 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑. # (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음. author_ids = list({c.user_id for c in page if c.user_id is not None}) diff --git a/negodata/front/src/api/generated/model/cardData.ts b/negodata/front/src/api/generated/model/cardData.ts index 9f1b37c..65cddc8 100644 --- a/negodata/front/src/api/generated/model/cardData.ts +++ b/negodata/front/src/api/generated/model/cardData.ts @@ -33,4 +33,6 @@ export interface CardData { memo?: CardDataMemo; created_at?: CardDataCreatedAt; updated_at?: CardDataUpdatedAt; + success_rate?: number; + used_count?: number; } diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index 8419440..9a73de6 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -84,15 +84,21 @@ export const router = createBrowserRouter([ {path: 'cards', Component: CardsPage}, {path: 'notifications', Component: NotificationsPage}, { - // 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다. + // 최고관리자 전용. 자식 loader 는 부모와 병렬 실행되므로 여기서도 initAuth 를 기다린다(멱등). path: 'members', - loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), + loader: async () => { + await initAuth(); + return hasRole('최고관리자') ? null : redirect('/forbidden'); + }, Component: MembersPage, }, { - // 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. + // 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. (자식 loader 는 부모와 병렬 → initAuth 대기 필수) path: 'settings', - loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), + loader: async () => { + await initAuth(); + return hasRole('최고관리자') ? null : redirect('/forbidden'); + }, Component: SettingsPage, }, ], diff --git a/negodata/front/src/features/cards/types.ts b/negodata/front/src/features/cards/types.ts index 893b27b..fa31dad 100644 --- a/negodata/front/src/features/cards/types.ts +++ b/negodata/front/src/features/cards/types.ts @@ -28,6 +28,8 @@ export function mapCardData(c: CardData): NegotiationCard { triggerCondition: c.condition ?? undefined, memo: c.memo ?? undefined, creatorName: c.creator_name ?? undefined, + successRate: c.success_rate ?? 0, + usedCount: c.used_count ?? 0, }; } diff --git a/negodata/front/src/features/partners/components/PartnerTable.tsx b/negodata/front/src/features/partners/components/PartnerTable.tsx index b03d015..4de95a2 100644 --- a/negodata/front/src/features/partners/components/PartnerTable.tsx +++ b/negodata/front/src/features/partners/components/PartnerTable.tsx @@ -13,6 +13,8 @@ type PartnerTableProps = { totalCount: number; pageSize: number; onPageChange: (page: number) => void; + /** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */ + className?: string; }; export function PartnerTable({ @@ -25,9 +27,11 @@ export function PartnerTable({ totalCount, pageSize, onPageChange, + className, }: PartnerTableProps) { return ( part.supplier_id} onRowClick={onRowClick} diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index ce29697..49b1f79 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -8,6 +8,7 @@ import { customFetch } from '@/api/mutator/custom-fetch'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings'; import type { CustomFieldDef } from '@/features/settings/catalog'; import type { Product } from '../types'; @@ -22,6 +23,7 @@ type RawRow = { category: string; spec: string; manufacturer: string; + suppliers: string; // 공급사명(복수는 쉼표/세미콜론 구분) → 등록 후 supplier_items 매핑 made_in: string; price: number; minPrice: number; @@ -53,11 +55,12 @@ const isYnToken = (s: string): boolean => // base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다. const STANDARD_COLUMNS: { base: string; key: Exclude; labelKey?: string; suffix?: string }[] = [ { base: '상품명', key: 'name' }, - { base: '상품코드', key: 'code' }, - { base: '모델번호', key: 'model_name' }, + { base: '상품코드', key: 'code', labelKey: 'item.code' }, + { base: '모델번호', key: 'model_name', labelKey: 'item.model_name' }, { base: '카테고리', key: 'category', labelKey: 'category' }, { base: '규격', key: 'spec' }, { base: '제조사', key: 'manufacturer' }, + { base: '공급사', key: 'suppliers' }, { base: '원산지', key: 'made_in' }, { base: '상품 단가', key: 'price', labelKey: 'item.price' }, { base: '최저한도', key: 'minPrice' }, @@ -65,7 +68,7 @@ const STANDARD_COLUMNS: { base: string; key: Exclude + s.split(/[,;/]/).map((v) => v.trim()).filter(Boolean); + // 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다. function buildDeliveryMap(label: LabelFn): Record { const map: Record = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 }; @@ -154,7 +161,8 @@ export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldD type ExcelUploadModalProps = { open: boolean; products: Product[]; // 코드 중복 검사용 - onConfirm: (rows: ItemCreate[]) => Promise; + // supplierIdsByCode: 상품코드 → 공급사 supplier_id 목록. 등록된 item_id 로 supplier_items 매핑에 쓴다. + onConfirm: (rows: ItemCreate[], supplierIdsByCode: Record) => Promise; onClose: () => void; }; @@ -168,6 +176,7 @@ function validateRows( deliveryLabels: string[], priceLabel: string, itemFields: CustomFieldDef[], + supplierIdByName: Map, ): ValidatedRow[] { return rows.map((row) => { const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); @@ -195,6 +204,9 @@ function validateRows( if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) { return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.'); } + // 공급사는 등록된 협력사명과 정확히 일치해야 매핑 가능(복수 지정 시 전부). + const unknown = parseSupplierNames(row.suppliers).filter((n) => !supplierIdByName.has(n)); + if (unknown.length > 0) return fail(`공급사 - 등록되지 않은 협력사입니다: ${unknown.join(', ')}`); // 커스텀필드 형식 검증(값이 있을 때만). boolean=Y/N 토큰, number=숫자. for (const f of itemFields) { const v = (row.custom[f.key] ?? '').trim(); @@ -254,6 +266,13 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp const itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]); const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]); const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]); + // 공급사 컬럼 검증·매핑용 협력사 전체 목록(이름 → id). + const supplierList = useListSuppliers({ size: 1000 }); + const supplierIdByName = useMemo(() => { + const m = new Map(); + (supplierList.data?.suppliers ?? []).forEach((sp) => m.set(sp.name.trim(), sp.supplier_id)); + return m; + }, [supplierList.data]); const [excelFile, setExcelFile] = useState(null); const [rows, setRows] = useState([]); @@ -266,9 +285,9 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp () => validateRows( rows, products, serverErrors, deliveryMap, [1, 2, 3].map((c) => label(`delivery_type.${c}`)), - label('item.price'), itemFields, + label('item.price'), itemFields, supplierIdByName, ), - [rows, products, serverErrors, deliveryMap, label, itemFields], + [rows, products, serverErrors, deliveryMap, label, itemFields, supplierIdByName], ); const validRows = validated.filter((r) => r.status === '정상'); const validCount = validRows.length; @@ -295,7 +314,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp const row: RawRow = { id: `row-${i + 1}`, rowNum: i + 2, - name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', made_in: '', + name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', suppliers: '', made_in: '', price: 0, minPrice: 0, purchase_price: 0, selling_price: 0, image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '', custom: {}, @@ -352,7 +371,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp return; } try { - const failures = await onConfirm(validRows.map((r) => toItemCreate(r, deliveryMap, itemFields))); + const supplierIdsByCode: Record = {}; + for (const r of validRows) { + const ids = parseSupplierNames(r.suppliers).map((n) => supplierIdByName.get(n)!).filter(Boolean); + if (ids.length > 0) supplierIdsByCode[r.code] = ids; + } + const failures = await onConfirm(validRows.map((r) => toItemCreate(r, deliveryMap, itemFields)), supplierIdsByCode); const okCount = validRows.length - failures.length; if (failures.length === 0) { showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success'); diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index f20eb81..a995521 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -233,7 +233,7 @@ export function ProductFormSheet({
{/* Code */}
- 상품코드 + {label('item.code')} {/* Model Name */}
- 모델명 + {label('item.model_name')} => { + // supplierIdsByCode 가 있으면 생성된 item_id 로 공급사(supplier_items) 매핑까지 이어 만든다. + const bulkCreate = async ( + rows: ReqCreateItem[], + supplierIdsByCode: Record = {}, + ): Promise => { const failures: BulkFailure[] = []; for (const row of rows) { try { - const msg = itemError(await createItem(row)); - if (msg) failures.push({ code: row.code ?? '', message: msg }); + const res = await createItem(row); + const msg = itemError(res); + if (msg) { + failures.push({ code: row.code ?? '', message: msg }); + continue; + } + const itemId = res.item?.item_id; + const supplierIds = supplierIdsByCode[row.code ?? ''] ?? []; + if (itemId) { + for (const supplierId of supplierIds) { + // 매핑 실패는 상품 등록 자체를 실패로 보지 않고 사유만 남긴다. + try { + await createSupplierItem({ supplier_id: supplierId, item_id: itemId }); + } catch { + failures.push({ code: row.code ?? '', message: '공급사 매핑에 실패했습니다.' }); + } + } + } } catch (err) { failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' }); } diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index 9f5dd16..a5ce7f4 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect, useRef } from 'react'; import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item'; @@ -162,14 +162,23 @@ export function QuotationCreateModal({ }); const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards; - const cardOptions: ComboOption[] = cardRows + // 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다. + const rankedCards = cardRows .filter((c) => !c.isWildcard || c.status === 'ACTIVE') - .map((card) => ({ + .slice() + .sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount); + const cardOptions: ComboOption[] = rankedCards + .map((card, i) => ({ id: card.id, label: card.title, node: (
+ {card.usedCount > 0 && ( + + {i + 1}위 · 성공률 {Math.round(card.successRate * 100)}% + + )} {card.code} {card.isWildcard ? '와일드' : '협상'} @@ -180,6 +189,29 @@ export function QuotationCreateModal({ ), })); + // 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. + const topRankedCards = rankedCards.slice(0, 3).filter((c) => c.usedCount > 0); + const topRankedKey = topRankedCards.map((c) => c.id).join(','); + const autoSelectedRef = useRef(false); + + // 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다. + useEffect(() => { + if (!open) { + autoSelectedRef.current = false; + return; + } + if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도 + autoSelectedRef.current = true; + setCardDetails((m) => { + const next = new Map(m); + topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard })); + return next; + }); + setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id))); + // topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, topRankedKey]); + // 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다. const selectedCardRows = selectedCardIds.map((id) => { const d = cardDetails.get(id); diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index c07913c..fee33c9 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -23,6 +23,8 @@ type QuotationTableProps = { /** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */ onFilterChain?: (number: string) => void; footer?: ReactNode; + /** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */ + className?: string; }; const statusBadgeClass = (status?: number | null) => { @@ -50,9 +52,10 @@ const outcomeBadgeClass = (state: ChainRoundState) => { } }; -export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer }: QuotationTableProps) { +export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer, className }: QuotationTableProps) { return ( est.id ?? ''} onRowClick={(est) => onOpenDetail(est.id ?? '')} diff --git a/negodata/front/src/features/settings/SettingsView.tsx b/negodata/front/src/features/settings/SettingsView.tsx index 1d3e00b..8815203 100644 --- a/negodata/front/src/features/settings/SettingsView.tsx +++ b/negodata/front/src/features/settings/SettingsView.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload } from 'lucide-react'; import { showToast } from '@/lib/notify'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -59,6 +59,30 @@ export function SettingsView() { } }; + const fileRef = useRef(null); + + // 현재 편집본을 JSON 파일로 내려받는다(회사 설정 전체 백업/이관용). + const handleExport = () => { + const blob = new Blob([JSON.stringify(draft, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'company-settings.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + // JSON 파일 → 편집본 병합. 빈 값(빈 문자열·빈 배열·null)은 "그대로 두기"로 보고 덮어쓰지 않는다. + const handleImport = async (file: File) => { + try { + const parsed = JSON.parse(await file.text()) as CompanySettings; + setDraft((d) => mergeSettings(d, parsed)); + showToast('설정 JSON 을 불러왔습니다. 확인 후 저장하십시오.', 'success'); + } catch (err) { + showToast(err instanceof Error ? `JSON 파싱 실패 - ${err.message}` : 'JSON 파싱 실패', 'error'); + } + }; + // 로고 파일 업로드 — 상품 이미지와 동일한 스토리지 엔드포인트(/v1/item/image, Azure Blob)를 재사용해 URL 을 받는다. const handleUploadLogo = async (file: File): Promise => { const res = await uploadItemImage({ file: file as unknown as string }); @@ -95,6 +119,23 @@ export function SettingsView() { 되돌리기 )} + { + const f = e.target.files?.[0]; + if (f) handleImport(f); + e.target.value = ''; // 같은 파일 재선택 허용 + }} + /> + + @@ -243,6 +284,29 @@ export function SettingsView() { ); } +// 불러온 JSON 을 현재 설정에 병합. 값이 비어있는 키(빈 문자열·빈 배열·null·undefined)는 +// "업데이트 안 함"으로 보고 기존 값을 유지한다 — 부분 JSON 만 던져도 안전하게 갱신되도록. +function mergeSettings(base: CompanySettings, incoming: CompanySettings): CompanySettings { + const mergeMap = (b: Record = {}, i: Record = {}) => { + const out = { ...b }; + for (const [k, v] of Object.entries(i)) if (typeof v === 'string' && v.trim()) out[k] = v; + return out; + }; + const pickFields = (b?: CustomFieldDef[], i?: CustomFieldDef[]) => + Array.isArray(i) && i.length > 0 ? i : (b ?? []); + return { + ...base, + labels: mergeMap(base.labels, incoming.labels), + branding: mergeMap( + base.branding as Record | undefined, + incoming.branding as Record | undefined, + ) as CompanySettings['branding'], + item_fields: pickFields(base.item_fields, incoming.item_fields), + supplier_fields: pickFields(base.supplier_fields, incoming.supplier_fields), + session_fields: pickFields(base.session_fields, incoming.session_fields), + }; +} + function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) { return (
diff --git a/negodata/front/src/features/settings/catalog.ts b/negodata/front/src/features/settings/catalog.ts index 92190bc..d02fb09 100644 --- a/negodata/front/src/features/settings/catalog.ts +++ b/negodata/front/src/features/settings/catalog.ts @@ -33,6 +33,8 @@ export type LabelCatalogEntry = { export const LABEL_CATALOG: LabelCatalogEntry[] = [ { key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' }, { key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식' }, + { key: 'item.code', base: '상품코드', where: '상품 목록·등록, 엑셀 양식' }, + { key: 'item.model_name', base: '모델번호', where: '상품 등록, 엑셀 양식' }, { key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계' }, { key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식' }, { key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1' }, diff --git a/negodata/front/src/features/statistics/StatisticsView.tsx b/negodata/front/src/features/statistics/StatisticsView.tsx index 349f80a..6468f6c 100644 --- a/negodata/front/src/features/statistics/StatisticsView.tsx +++ b/negodata/front/src/features/statistics/StatisticsView.tsx @@ -8,7 +8,9 @@ import { ParticipationChart } from './components/ParticipationChart'; import { TypeSplitChart } from './components/TypeSplitChart'; import { CategoryChart } from './components/CategoryChart'; import { CardEffectChart } from './components/CardEffectChart'; +import { CardTop5 } from './components/CardTop5'; import { wonCompact, pct, signedWonCompact } from './fmt'; +import { fillMonths } from './months'; import type { StatData } from './types'; import { useLabels } from '@/features/settings/useCompanySettings'; @@ -16,6 +18,9 @@ import { useLabels } from '@/features/settings/useCompanySettings'; export function StatisticsView({ data }: { data: StatData }) { const label = useLabels(); // 회사 설정 용어(카테고리 등) const k = data.kpi; + // 월별 차트는 최근 6개월 축을 고정한다(데이터 없는 달은 0) — 한 점만 찍히던 현상 방지. + const trend = fillMonths(data.trend, (month) => ({ month, savings: 0, rate: 0 })); + const markupTrend = fillMonths(data.markupTrend, (month) => ({ month, rate: 0 })); return (
{/* 임팩트 요약 KPI */} @@ -34,26 +39,21 @@ export function StatisticsView({ data }: { data: StatData }) {
- {/* 절감 분석 */} -
- - + {/* 월별 추이 2종 — 같은 성격이라 동일 폭(1:1) */} +
+ + - - - - - +
{/* 성사 · 프로세스 */} -
+
+ + + @@ -63,13 +63,16 @@ export function StatisticsView({ data }: { data: StatData }) {
{/* 카테고리 · 카드 */} -
+
+ + +
); diff --git a/negodata/front/src/features/statistics/components/CardTop5.tsx b/negodata/front/src/features/statistics/components/CardTop5.tsx new file mode 100644 index 0000000..0b82d67 --- /dev/null +++ b/negodata/front/src/features/statistics/components/CardTop5.tsx @@ -0,0 +1,68 @@ +import { Link } from 'react-router'; +import { useListCards } from '@/api/generated/card/card'; +import { mapCardData } from '@/features/cards/types'; +import { Typography, typographyVariants } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; + +// 협상카드 성공률 TOP 5 — 카드 목록 API의 success_rate/used_count 로 계산(통계 요약엔 유형별만 있어서). +// 표본 없는 카드(used_count=0)는 성공률이 0으로 잡혀 의미가 없으므로 제외한다. +const TOP_N = 5; + +export function CardTop5() { + // 랭킹이므로 넉넉히 받아 프론트에서 정렬(회사 카드 + 공용 카드). + const { data, isLoading } = useListCards({ size: 100 }); + const rows = (data?.cards ?? []) + .map(mapCardData) + .filter((c) => c.usedCount > 0) + .sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount) + .slice(0, TOP_N); + + if (isLoading) return 불러오는 중…; + if (rows.length === 0) return 아직 사용 이력이 있는 카드가 없습니다.; + + const max = Math.max(...rows.map((r) => r.successRate), 0.0001); + + return ( +
+ {rows.map((card, i) => ( +
+ + {i + 1} + + +
+
+ + {card.title} + + + {Math.round(card.successRate * 100)}% + +
+ + {/* 성공률 막대 — 1위 대비 상대 길이 */} +
+
+
+ + + {card.isWildcard ? '와일드카드' : '협상카드'} · {card.code} · 사용 {card.usedCount}회 + +
+
+ ))} +
+ ); +} diff --git a/negodata/front/src/features/statistics/months.ts b/negodata/front/src/features/statistics/months.ts new file mode 100644 index 0000000..d18f046 --- /dev/null +++ b/negodata/front/src/features/statistics/months.ts @@ -0,0 +1,30 @@ +// 월별 차트 축 고정 — 백엔드는 데이터가 있는 달만 내려주므로, 빈 달을 0으로 채워 +// 최근 N개월 축을 항상 그린다. (데이터 1건일 때 점 하나만 덩그러니 찍히던 문제) +const WINDOW = 6; + +/** 최근 N개월 'YYYY-MM' 배열 (과거→현재). 기준월 포함. */ +function recentMonths(n: number, now = new Date()): string[] { + const out: string[] = []; + for (let i = n - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`); + } + return out; +} + +/** + * 최근 WINDOW 개월 축에 맞춰 rows 를 채운다. + * 데이터가 있는 달은 그대로 쓰고, 없는 달은 empty(month) 로 채운다. + * 축 밖(더 과거)의 데이터가 있으면 그대로 앞에 붙여 잘리지 않게 한다. + */ +export function fillMonths( + rows: T[], + empty: (month: string) => T, + window = WINDOW, +): T[] { + const byMonth = new Map(rows.map((r) => [r.month, r])); + const axis = recentMonths(window); + // 축보다 과거의 데이터는 버리지 않고 앞에 유지 + const older = rows.filter((r) => r.month < axis[0]).sort((a, b) => a.month.localeCompare(b.month)); + return [...older, ...axis.map((m) => byMonth.get(m) ?? empty(m))]; +} diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index 3334358..91da63a 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -76,7 +76,10 @@ export default function PartnersPage() { return ( + {/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */} +
{isSuperAdmin && ( @@ -128,6 +131,7 @@ export default function PartnersPage() { +
{isFormOpen && ( + {/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */} +
{detailId && activeQuotation && (