[feat] negodata: 카드 성공률 순위·상품 엑셀 TO-BE 싱크·회사설정 JSON 입출력·목록 포털형 UI

- 협상카드 성공률(사용 세션 대비 타결) 집계 추가, 견적 생성 카드선택 순위 정렬·통계 TOP5
- 상품 엑셀: 품목코드·모델명 라벨화, 리드타임 suffix 제거, 공급사 컬럼 신설(등록 후 supplier_items 매핑)
- 설정 화면 JSON 불러오기/내보내기(빈 값은 미갱신), IMK 설정 JSON 보관
- 설정·회원 페이지 새로고침 forbidden 수정(자식 loader 가 initAuth 대기)
- 협력사·견적 목록도 포털형 카드(툴바+테이블 결합)로 통일
This commit is contained in:
Mina Choi 2026-07-22 17:24:58 +09:00
parent fa9113da55
commit 03322d6ca3
22 changed files with 448 additions and 41 deletions

View File

@ -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

View File

@ -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):

View File

@ -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})

View File

@ -33,4 +33,6 @@ export interface CardData {
memo?: CardDataMemo;
created_at?: CardDataCreatedAt;
updated_at?: CardDataUpdatedAt;
success_rate?: number;
used_count?: number;
}

View File

@ -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,
},
],

View File

@ -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,
};
}

View File

@ -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 (
<DataTable
className={className}
data={data}
rowKey={(part) => part.supplier_id}
onRowClick={onRowClick}

View File

@ -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<keyof RawRow, 'id' | 'rowNum' | 'custom'>; 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<keyof RawRow, 'id' | 'rowNu
{ base: '판매가', key: 'selling_price' },
{ base: '이미지URL', key: 'image_url' },
{ base: '최소주문수량', key: 'moq' },
{ base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time', suffix: '(일)' },
{ base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time' },
{ base: '단위', key: 'quantity_unit' },
{ base: '배송형태', key: 'delivery_type' },
{ base: '부가세포함(Y/N)', key: 'vat_yn' },
@ -108,6 +111,10 @@ function buildColumns(label: LabelFn, itemFields: CustomFieldDef[]): UploadColum
return [...standard, ...custom];
}
// 공급사 셀 → 이름 배열(쉼표·세미콜론·슬래시 구분, 공백 제거).
const parseSupplierNames = (s: string): string[] =>
s.split(/[,;/]/).map((v) => v.trim()).filter(Boolean);
// 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다.
function buildDeliveryMap(label: LabelFn): Record<string, number> {
const map: Record<string, number> = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 };
@ -154,7 +161,8 @@ export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldD
type ExcelUploadModalProps = {
open: boolean;
products: Product[]; // 코드 중복 검사용
onConfirm: (rows: ItemCreate[]) => Promise<BulkFailure[]>;
// supplierIdsByCode: 상품코드 → 공급사 supplier_id 목록. 등록된 item_id 로 supplier_items 매핑에 쓴다.
onConfirm: (rows: ItemCreate[], supplierIdsByCode: Record<string, string[]>) => Promise<BulkFailure[]>;
onClose: () => void;
};
@ -168,6 +176,7 @@ function validateRows(
deliveryLabels: string[],
priceLabel: string,
itemFields: CustomFieldDef[],
supplierIdByName: Map<string, string>,
): 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<string, string>();
(supplierList.data?.suppliers ?? []).forEach((sp) => m.set(sp.name.trim(), sp.supplier_id));
return m;
}, [supplierList.data]);
const [excelFile, setExcelFile] = useState<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]);
@ -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<string, string[]> = {};
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');

View File

@ -233,7 +233,7 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4">
{/* Code */}
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Typography as="label" variant="label">{label('item.code')}</Typography>
<Input
id="form-product-code"
type="text"
@ -327,7 +327,7 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4">
{/* Model Name */}
<div className="space-y-1">
<Typography as="label" variant="label"></Typography>
<Typography as="label" variant="label">{label('item.model_name')}</Typography>
<Input
id="form-product-model"
type="text"

View File

@ -6,6 +6,7 @@ import {
updateItem,
deleteItem,
} from '@/api/generated/item/item';
import { createSupplierItem } from '@/api/generated/supplier-item/supplier-item';
import type { ListItemsParams } from '@/api/generated/model/listItemsParams';
import type { ReqCreateItem } from '@/api/generated/model/reqCreateItem';
import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem';
@ -68,12 +69,32 @@ export function useProducts(params: ListItemsParams) {
};
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
const bulkCreate = async (rows: ReqCreateItem[]): Promise<BulkFailure[]> => {
// supplierIdsByCode 가 있으면 생성된 item_id 로 공급사(supplier_items) 매핑까지 이어 만든다.
const bulkCreate = async (
rows: ReqCreateItem[],
supplierIdsByCode: Record<string, string[]> = {},
): Promise<BulkFailure[]> => {
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 : '등록 실패' });
}

View File

@ -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: (
<div>
<div className="flex items-center gap-1.5">
{card.usedCount > 0 && (
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
{i + 1} · {Math.round(card.successRate * 100)}%
</span>
)}
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
{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);

View File

@ -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 (
<DataTable
className={className}
data={data}
rowKey={(est) => est.id ?? ''}
onRowClick={(est) => onOpenDetail(est.id ?? '')}

View File

@ -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<HTMLInputElement>(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<string> => {
const res = await uploadItemImage({ file: file as unknown as string });
@ -95,6 +119,23 @@ export function SettingsView() {
<RotateCcw size={13} />
</Button>
)}
<input
ref={fileRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleImport(f);
e.target.value = ''; // 같은 파일 재선택 허용
}}
/>
<Button variant="ghost" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={13} /> JSON
</Button>
<Button variant="ghost" size="sm" onClick={handleExport}>
<Download size={13} /> JSON
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
{saving ? '저장 중...' : '변경사항 저장'}
</Button>
@ -243,6 +284,29 @@ export function SettingsView() {
);
}
// 불러온 JSON 을 현재 설정에 병합. 값이 비어있는 키(빈 문자열·빈 배열·null·undefined)는
// "업데이트 안 함"으로 보고 기존 값을 유지한다 — 부분 JSON 만 던져도 안전하게 갱신되도록.
function mergeSettings(base: CompanySettings, incoming: CompanySettings): CompanySettings {
const mergeMap = (b: Record<string, string> = {}, i: Record<string, string> = {}) => {
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<string, string> | undefined,
incoming.branding as Record<string, string> | 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 (
<div className="bg-card border border-border rounded-lg p-5">

View File

@ -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' },

View File

@ -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 (
<div className="space-y-4">
{/* 임팩트 요약 KPI */}
@ -34,26 +39,21 @@ export function StatisticsView({ data }: { data: StatData }) {
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />
</div>
{/* 절감 분석 */}
<div className="grid gap-4 lg:grid-cols-3">
<Panel
title="월별 절감 추이"
subtitle="목표가 대비 절감액 · 막대에 마우스를 올리면 절감률"
className="lg:col-span-2"
>
<SavingsTrendChart data={data.trend} />
{/* 월별 추이 2종 — 같은 성격이라 동일 폭(1:1) */}
<div className="grid gap-4 lg:grid-cols-2">
<Panel title="월별 절감 추이" subtitle="목표가 대비 절감액 · 막대에 마우스를 올리면 절감률">
<SavingsTrendChart data={trend} />
</Panel>
<Panel title="월별 인상억제율" subtitle="재협상: 직전 라운드 투찰가 대비 인하율">
<MarkupTrendChart data={data.markupTrend} />
</Panel>
<Panel title="마감 결과" subtitle="낙찰 vs 개찰 사유 4종">
<OutcomeChart data={data.outcome} />
<MarkupTrendChart data={markupTrend} />
</Panel>
</div>
{/* 성사 · 프로세스 */}
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Panel title="마감 결과" subtitle="낙찰 vs 개찰 사유 4종">
<OutcomeChart data={data.outcome} />
</Panel>
<Panel title="협력사 참여" subtitle="초대 세션 대비 응찰·미응찰·거부">
<ParticipationChart data={data.participation} />
</Panel>
@ -63,13 +63,16 @@ export function StatisticsView({ data }: { data: StatData }) {
</div>
{/* 카테고리 · 카드 */}
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Panel title={`${label('category')}별 절감`} subtitle="어디서 절감이 났나">
<CategoryChart data={data.categories} />
</Panel>
<Panel title="협상카드 효과" subtitle="유형별 사용빈도 + 사용 직후 평균 제시가 하락">
<CardEffectChart data={data.cards} />
</Panel>
<Panel title="협상카드 성공률 TOP 5" subtitle="사용 이력 있는 카드 · 사용 세션 중 타결 비율">
<CardTop5 />
</Panel>
</div>
</div>
);

View File

@ -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 <Typography variant="muted"> </Typography>;
if (rows.length === 0) return <Typography variant="muted"> .</Typography>;
const max = Math.max(...rows.map((r) => r.successRate), 0.0001);
return (
<div className="flex flex-col gap-2.5">
{rows.map((card, i) => (
<div key={card.id} className="flex items-center gap-3">
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded text-[10px] font-bold',
i === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
>
{i + 1}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<Link
to={`/cards?detail=${card.id}`}
className={cn(typographyVariants({ variant: 'small' }), 'truncate font-semibold hover:underline')}
title={`${card.title} — 협상카드 상세로 이동`}
>
{card.title}
</Link>
<Typography as="span" variant="small" className="shrink-0 font-mono font-bold text-foreground">
{Math.round(card.successRate * 100)}%
</Typography>
</div>
{/* 성공률 막대 — 1위 대비 상대 길이 */}
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full', card.isWildcard ? 'bg-rose-400' : 'bg-primary')}
style={{ width: `${Math.max(4, (card.successRate / max) * 100)}%` }}
/>
</div>
<Typography as="p" variant="caption" className="mt-1 truncate">
{card.isWildcard ? '와일드카드' : '협상카드'} · {card.code} · {card.usedCount}
</Typography>
</div>
</div>
))}
</div>
);
}

View File

@ -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<T extends { month: string }>(
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))];
}

View File

@ -76,7 +76,10 @@ export default function PartnersPage() {
return (
<PageContainer>
{/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */}
<div className="overflow-hidden rounded-lg border border-border bg-card">
<PageToolbar
className="rounded-none border-0 border-b border-border"
actions={
<>
{isSuperAdmin && (
@ -128,6 +131,7 @@ export default function PartnersPage() {
</PageToolbar>
<PartnerTable
className="rounded-none border-0"
data={partners}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
@ -138,6 +142,7 @@ export default function PartnersPage() {
pageSize={list.pageSize}
onPageChange={list.setPage}
/>
</div>
{isFormOpen && (
<PartnerFormSheet

View File

@ -71,7 +71,10 @@ export default function QuotationPage() {
return (
<PageContainer>
{/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */}
<div className="overflow-hidden rounded-lg border border-border bg-card">
<PageToolbar
className="rounded-none border-0 border-b border-border"
actions={
<>
<Button id="quotation-create-btn" onClick={() => overlay.open('create')}>
@ -154,6 +157,7 @@ export default function QuotationPage() {
</PageToolbar>
<QuotationTable
className="rounded-none border-0"
data={quotations}
products={products}
onOpenDetail={(id) => overlay.open('detail', id)}
@ -174,6 +178,7 @@ export default function QuotationPage() {
/>
}
/>
</div>
{detailId && activeQuotation && (
<QuotationDetailSheet

View File

@ -33,6 +33,8 @@ export interface NegotiationCard {
triggerCondition?: string;
memo?: string;
creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음
successRate: number; // 카드 성공률(사용 세션 중 타결 비율). #12 순위
usedCount: number; // 카드 사용 세션 수(표본)
}
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'NOTIFICATIONS';

View File

@ -0,0 +1,59 @@
{
"labels": {
"category": "SG명",
"item.code": "품목코드",
"lead_time": "표준납기",
"item.price": "공급가",
"target_margin": "네고율",
"delivery_type.1": "직납",
"delivery_type.2": "IMK물류(배송)",
"delivery_type.3": "IMK물류(집배송)",
"item.model_name": "모델명"
},
"branding": {
"logo_url": "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/negodata/a35152d2-db61-4760-9f1e-beb9736d957f/items/4ab23e35e4f14d888591ca0f6d343fc8.jpg",
"email_header": "iMarketKorea",
"service_name": "아이좋아네고",
"primary_color": "#f551a0"
},
"item_fields": [
{
"key": "order_multiple",
"type": "number",
"label": "발주배수"
},
{
"key": "subcontract_yn",
"type": "boolean",
"label": "하도급"
},
{
"key": "price_linked_yn",
"type": "boolean",
"label": "납품대금연동"
}
],
"session_fields": [
{
"key": "std_lead_time",
"type": "number",
"label": "표준납기"
},
{
"key": "moq",
"type": "text",
"label": "최소주문수량"
},
{
"key": "order_multiple",
"type": "number",
"label": "발주배수"
},
{
"key": "delivery_type",
"type": "text",
"label": "배송유형"
}
],
"supplier_fields": []
}

View File

@ -0,0 +1,36 @@
-- 데모용 카드 사용 이력 시드 (#12 카드 성공률 순위)
-- 과거 채팅 302건은 카드 메타(card_id/card_type/card_used_yn) 저장 전에 쌓여 전부 NULL 이라
-- 성공률 표본이 0 → 카드 선택 UI 에 순위가 뜨지 않는다. 실제 세션 결과(status)는 그대로 두고
-- 봇 발화 행에만 카드를 결정적으로 배정해 표본을 만든다. 멱등(같은 chat_id → 같은 카드).
WITH bot AS (
SELECT c.chat_id,
c.session_id,
row_number() OVER (PARTITION BY c.session_id ORDER BY c.seq) AS rn
FROM negotiation.chats c
WHERE c.sender = 1 AND c.deleted = false
),
sess AS (
SELECT session_id, row_number() OVER (ORDER BY session_id) AS sn
FROM negotiation.sessions
),
cards AS (
SELECT nego_card_id,
row_number() OVER (ORDER BY (number)::int) AS cn,
count(*) OVER () AS total
FROM card.nego_cards
),
pick AS (
-- 세션당 앞쪽 봇 발화 4개까지 카드 사용으로 표시. 세션·발화 순번을 섞어 카드가 고르게 퍼지게.
SELECT b.chat_id,
((s.sn * 3 + b.rn) % (SELECT max(total) FROM cards)) + 1 AS cn
FROM bot b
JOIN sess s ON s.session_id = b.session_id
WHERE b.rn <= 4
)
UPDATE negotiation.chats t
SET card_id = c.nego_card_id,
card_type = 1,
card_used_yn = true
FROM pick p
JOIN cards c ON c.cn = p.cn
WHERE t.chat_id = p.chat_id;