[feat] negodata: 카드 성공률 순위·상품 엑셀 TO-BE 싱크·회사설정 JSON 입출력·목록 포털형 UI
- 협상카드 성공률(사용 세션 대비 타결) 집계 추가, 견적 생성 카드선택 순위 정렬·통계 TOP5 - 상품 엑셀: 품목코드·모델명 라벨화, 리드타임 suffix 제거, 공급사 컬럼 신설(등록 후 supplier_items 매핑) - 설정 화면 JSON 불러오기/내보내기(빈 값은 미갱신), IMK 설정 JSON 보관 - 설정·회원 페이지 새로고침 forbidden 수정(자식 loader 가 initAuth 대기) - 협력사·견적 목록도 포털형 카드(툴바+테이블 결합)로 통일
This commit is contained in:
parent
fa9113da55
commit
03322d6ca3
@ -1,12 +1,12 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional, Tuple
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import users
|
from common.database.model.models import users, chats, sessions
|
||||||
from common.enums import ErrorType
|
from common.enums import ErrorType, SessionStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
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]:
|
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def card_success_map(self, cdb: AsyncSession) -> Tuple[ErrorType, dict]:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def owner_company_id(self, cdb: AsyncSession, owner_user_id) -> Tuple[ErrorType, Optional[object]]:
|
async def owner_company_id(self, cdb: AsyncSession, owner_user_id) -> Tuple[ErrorType, Optional[object]]:
|
||||||
pass
|
pass
|
||||||
@ -79,6 +83,28 @@ class CardCRUD(ICardCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED, [], 0
|
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]:
|
async def get_by_id(self, cdb: AsyncSession, model, pk_col, card_id) -> Tuple[ErrorType, object]:
|
||||||
try:
|
try:
|
||||||
query = select(model).where(pk_col == card_id, model.deleted == False).limit(1) # noqa: E712
|
query = select(model).where(pk_col == card_id, model.deleted == False).limit(1) # noqa: E712
|
||||||
|
|||||||
@ -55,6 +55,8 @@ class CardData(WebPacketProtocol):
|
|||||||
memo: Optional[str] = None
|
memo: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
success_rate: float = 0.0 # 카드 성공률(사용 세션 중 타결 비율). #12 순위용
|
||||||
|
used_count: int = 0 # 카드 사용 세션 수(표본)
|
||||||
|
|
||||||
|
|
||||||
class Res_Card(Res_WebPacketProtocol):
|
class Res_Card(Res_WebPacketProtocol):
|
||||||
|
|||||||
@ -137,6 +137,17 @@ class CardService:
|
|||||||
merged.sort(key=lambda c: c.created_at or "", reverse=True)
|
merged.sort(key=lambda c: c.created_at or "", reverse=True)
|
||||||
page = merged[pg.skip : pg.skip + pg.size]
|
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} 맵을 만들어 매핑.
|
# 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑.
|
||||||
# (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음.
|
# (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음.
|
||||||
author_ids = list({c.user_id for c in page if c.user_id is not None})
|
author_ids = list({c.user_id for c in page if c.user_id is not None})
|
||||||
|
|||||||
@ -33,4 +33,6 @@ export interface CardData {
|
|||||||
memo?: CardDataMemo;
|
memo?: CardDataMemo;
|
||||||
created_at?: CardDataCreatedAt;
|
created_at?: CardDataCreatedAt;
|
||||||
updated_at?: CardDataUpdatedAt;
|
updated_at?: CardDataUpdatedAt;
|
||||||
|
success_rate?: number;
|
||||||
|
used_count?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,15 +84,21 @@ export const router = createBrowserRouter([
|
|||||||
{path: 'cards', Component: CardsPage},
|
{path: 'cards', Component: CardsPage},
|
||||||
{path: 'notifications', Component: NotificationsPage},
|
{path: 'notifications', Component: NotificationsPage},
|
||||||
{
|
{
|
||||||
// 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다.
|
// 최고관리자 전용. 자식 loader 는 부모와 병렬 실행되므로 여기서도 initAuth 를 기다린다(멱등).
|
||||||
path: 'members',
|
path: 'members',
|
||||||
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
loader: async () => {
|
||||||
|
await initAuth();
|
||||||
|
return hasRole('최고관리자') ? null : redirect('/forbidden');
|
||||||
|
},
|
||||||
Component: MembersPage,
|
Component: MembersPage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정.
|
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. (자식 loader 는 부모와 병렬 → initAuth 대기 필수)
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
|
loader: async () => {
|
||||||
|
await initAuth();
|
||||||
|
return hasRole('최고관리자') ? null : redirect('/forbidden');
|
||||||
|
},
|
||||||
Component: SettingsPage,
|
Component: SettingsPage,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export function mapCardData(c: CardData): NegotiationCard {
|
|||||||
triggerCondition: c.condition ?? undefined,
|
triggerCondition: c.condition ?? undefined,
|
||||||
memo: c.memo ?? undefined,
|
memo: c.memo ?? undefined,
|
||||||
creatorName: c.creator_name ?? undefined,
|
creatorName: c.creator_name ?? undefined,
|
||||||
|
successRate: c.success_rate ?? 0,
|
||||||
|
usedCount: c.used_count ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,8 @@ type PartnerTableProps = {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
/** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PartnerTable({
|
export function PartnerTable({
|
||||||
@ -25,9 +27,11 @@ export function PartnerTable({
|
|||||||
totalCount,
|
totalCount,
|
||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
className,
|
||||||
}: PartnerTableProps) {
|
}: PartnerTableProps) {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
|
className={className}
|
||||||
data={data}
|
data={data}
|
||||||
rowKey={(part) => part.supplier_id}
|
rowKey={(part) => part.supplier_id}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { customFetch } from '@/api/mutator/custom-fetch';
|
|||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
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 { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import type { CustomFieldDef } from '@/features/settings/catalog';
|
import type { CustomFieldDef } from '@/features/settings/catalog';
|
||||||
import type { Product } from '../types';
|
import type { Product } from '../types';
|
||||||
@ -22,6 +23,7 @@ type RawRow = {
|
|||||||
category: string;
|
category: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
|
suppliers: string; // 공급사명(복수는 쉼표/세미콜론 구분) → 등록 후 supplier_items 매핑
|
||||||
made_in: string;
|
made_in: string;
|
||||||
price: number;
|
price: number;
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
@ -53,11 +55,12 @@ const isYnToken = (s: string): boolean =>
|
|||||||
// base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다.
|
// base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다.
|
||||||
const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>; labelKey?: string; suffix?: string }[] = [
|
const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>; labelKey?: string; suffix?: string }[] = [
|
||||||
{ base: '상품명', key: 'name' },
|
{ base: '상품명', key: 'name' },
|
||||||
{ base: '상품코드', key: 'code' },
|
{ base: '상품코드', key: 'code', labelKey: 'item.code' },
|
||||||
{ base: '모델번호', key: 'model_name' },
|
{ base: '모델번호', key: 'model_name', labelKey: 'item.model_name' },
|
||||||
{ base: '카테고리', key: 'category', labelKey: 'category' },
|
{ base: '카테고리', key: 'category', labelKey: 'category' },
|
||||||
{ base: '규격', key: 'spec' },
|
{ base: '규격', key: 'spec' },
|
||||||
{ base: '제조사', key: 'manufacturer' },
|
{ base: '제조사', key: 'manufacturer' },
|
||||||
|
{ base: '공급사', key: 'suppliers' },
|
||||||
{ base: '원산지', key: 'made_in' },
|
{ base: '원산지', key: 'made_in' },
|
||||||
{ base: '상품 단가', key: 'price', labelKey: 'item.price' },
|
{ base: '상품 단가', key: 'price', labelKey: 'item.price' },
|
||||||
{ base: '최저한도', key: 'minPrice' },
|
{ base: '최저한도', key: 'minPrice' },
|
||||||
@ -65,7 +68,7 @@ const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNu
|
|||||||
{ base: '판매가', key: 'selling_price' },
|
{ base: '판매가', key: 'selling_price' },
|
||||||
{ base: '이미지URL', key: 'image_url' },
|
{ base: '이미지URL', key: 'image_url' },
|
||||||
{ base: '최소주문수량', key: 'moq' },
|
{ base: '최소주문수량', key: 'moq' },
|
||||||
{ base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time', suffix: '(일)' },
|
{ base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time' },
|
||||||
{ base: '단위', key: 'quantity_unit' },
|
{ base: '단위', key: 'quantity_unit' },
|
||||||
{ base: '배송형태', key: 'delivery_type' },
|
{ base: '배송형태', key: 'delivery_type' },
|
||||||
{ base: '부가세포함(Y/N)', key: 'vat_yn' },
|
{ base: '부가세포함(Y/N)', key: 'vat_yn' },
|
||||||
@ -108,6 +111,10 @@ function buildColumns(label: LabelFn, itemFields: CustomFieldDef[]): UploadColum
|
|||||||
return [...standard, ...custom];
|
return [...standard, ...custom];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 공급사 셀 → 이름 배열(쉼표·세미콜론·슬래시 구분, 공백 제거).
|
||||||
|
const parseSupplierNames = (s: string): string[] =>
|
||||||
|
s.split(/[,;/]/).map((v) => v.trim()).filter(Boolean);
|
||||||
|
|
||||||
// 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다.
|
// 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다.
|
||||||
function buildDeliveryMap(label: LabelFn): Record<string, number> {
|
function buildDeliveryMap(label: LabelFn): Record<string, number> {
|
||||||
const map: Record<string, number> = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 };
|
const map: Record<string, number> = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 };
|
||||||
@ -154,7 +161,8 @@ export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldD
|
|||||||
type ExcelUploadModalProps = {
|
type ExcelUploadModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
products: Product[]; // 코드 중복 검사용
|
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;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -168,6 +176,7 @@ function validateRows(
|
|||||||
deliveryLabels: string[],
|
deliveryLabels: string[],
|
||||||
priceLabel: string,
|
priceLabel: string,
|
||||||
itemFields: CustomFieldDef[],
|
itemFields: CustomFieldDef[],
|
||||||
|
supplierIdByName: Map<string, string>,
|
||||||
): ValidatedRow[] {
|
): ValidatedRow[] {
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
|
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())) {
|
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
|
||||||
return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.');
|
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=숫자.
|
// 커스텀필드 형식 검증(값이 있을 때만). boolean=Y/N 토큰, number=숫자.
|
||||||
for (const f of itemFields) {
|
for (const f of itemFields) {
|
||||||
const v = (row.custom[f.key] ?? '').trim();
|
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 itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]);
|
||||||
const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]);
|
const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]);
|
||||||
const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]);
|
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 [excelFile, setExcelFile] = useState<string | null>(null);
|
||||||
const [rows, setRows] = useState<RawRow[]>([]);
|
const [rows, setRows] = useState<RawRow[]>([]);
|
||||||
@ -266,9 +285,9 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
() => validateRows(
|
() => validateRows(
|
||||||
rows, products, serverErrors, deliveryMap,
|
rows, products, serverErrors, deliveryMap,
|
||||||
[1, 2, 3].map((c) => label(`delivery_type.${c}`)),
|
[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 validRows = validated.filter((r) => r.status === '정상');
|
||||||
const validCount = validRows.length;
|
const validCount = validRows.length;
|
||||||
@ -295,7 +314,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
const row: RawRow = {
|
const row: RawRow = {
|
||||||
id: `row-${i + 1}`,
|
id: `row-${i + 1}`,
|
||||||
rowNum: i + 2,
|
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,
|
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: '',
|
image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '',
|
||||||
custom: {},
|
custom: {},
|
||||||
@ -352,7 +371,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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;
|
const okCount = validRows.length - failures.length;
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
|
showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
|
||||||
|
|||||||
@ -233,7 +233,7 @@ export function ProductFormSheet({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Code */}
|
{/* Code */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">상품코드</Typography>
|
<Typography as="label" variant="label">{label('item.code')}</Typography>
|
||||||
<Input
|
<Input
|
||||||
id="form-product-code"
|
id="form-product-code"
|
||||||
type="text"
|
type="text"
|
||||||
@ -327,7 +327,7 @@ export function ProductFormSheet({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Model Name */}
|
{/* Model Name */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">모델명</Typography>
|
<Typography as="label" variant="label">{label('item.model_name')}</Typography>
|
||||||
<Input
|
<Input
|
||||||
id="form-product-model"
|
id="form-product-model"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
} from '@/api/generated/item/item';
|
} from '@/api/generated/item/item';
|
||||||
|
import { createSupplierItem } from '@/api/generated/supplier-item/supplier-item';
|
||||||
import type { ListItemsParams } from '@/api/generated/model/listItemsParams';
|
import type { ListItemsParams } from '@/api/generated/model/listItemsParams';
|
||||||
import type { ReqCreateItem } from '@/api/generated/model/reqCreateItem';
|
import type { ReqCreateItem } from '@/api/generated/model/reqCreateItem';
|
||||||
import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem';
|
import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem';
|
||||||
@ -68,12 +69,32 @@ export function useProducts(params: ListItemsParams) {
|
|||||||
};
|
};
|
||||||
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
|
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
|
||||||
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
|
// 서버 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[] = [];
|
const failures: BulkFailure[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
try {
|
||||||
const msg = itemError(await createItem(row));
|
const res = await createItem(row);
|
||||||
if (msg) failures.push({ code: row.code ?? '', message: msg });
|
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) {
|
} catch (err) {
|
||||||
failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
|
failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
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 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')
|
.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,
|
id: card.id,
|
||||||
label: card.title,
|
label: card.title,
|
||||||
node: (
|
node: (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5">
|
<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>
|
<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'}`}>
|
<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 ? '와일드' : '협상'}
|
{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 selectedCardRows = selectedCardIds.map((id) => {
|
||||||
const d = cardDetails.get(id);
|
const d = cardDetails.get(id);
|
||||||
|
|||||||
@ -23,6 +23,8 @@ type QuotationTableProps = {
|
|||||||
/** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */
|
/** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */
|
||||||
onFilterChain?: (number: string) => void;
|
onFilterChain?: (number: string) => void;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusBadgeClass = (status?: number | null) => {
|
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 (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
|
className={className}
|
||||||
data={data}
|
data={data}
|
||||||
rowKey={(est) => est.id ?? ''}
|
rowKey={(est) => est.id ?? ''}
|
||||||
onRowClick={(est) => onOpenDetail(est.id ?? '')}
|
onRowClick={(est) => onOpenDetail(est.id ?? '')}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw } from 'lucide-react';
|
import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload } from 'lucide-react';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 을 받는다.
|
// 로고 파일 업로드 — 상품 이미지와 동일한 스토리지 엔드포인트(/v1/item/image, Azure Blob)를 재사용해 URL 을 받는다.
|
||||||
const handleUploadLogo = async (file: File): Promise<string> => {
|
const handleUploadLogo = async (file: File): Promise<string> => {
|
||||||
const res = await uploadItemImage({ file: file as unknown as string });
|
const res = await uploadItemImage({ file: file as unknown as string });
|
||||||
@ -95,6 +119,23 @@ export function SettingsView() {
|
|||||||
<RotateCcw size={13} /> 되돌리기
|
<RotateCcw size={13} /> 되돌리기
|
||||||
</Button>
|
</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}>
|
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
|
||||||
{saving ? '저장 중...' : '변경사항 저장'}
|
{saving ? '저장 중...' : '변경사항 저장'}
|
||||||
</Button>
|
</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 }) {
|
function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg p-5">
|
<div className="bg-card border border-border rounded-lg p-5">
|
||||||
|
|||||||
@ -33,6 +33,8 @@ export type LabelCatalogEntry = {
|
|||||||
export const LABEL_CATALOG: LabelCatalogEntry[] = [
|
export const LABEL_CATALOG: LabelCatalogEntry[] = [
|
||||||
{ key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' },
|
{ key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' },
|
||||||
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식' },
|
{ key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식' },
|
||||||
|
{ key: 'item.code', base: '상품코드', where: '상품 목록·등록, 엑셀 양식' },
|
||||||
|
{ key: 'item.model_name', base: '모델번호', where: '상품 등록, 엑셀 양식' },
|
||||||
{ key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계' },
|
{ key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계' },
|
||||||
{ key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식' },
|
{ key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식' },
|
||||||
{ key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1' },
|
{ key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1' },
|
||||||
|
|||||||
@ -8,7 +8,9 @@ import { ParticipationChart } from './components/ParticipationChart';
|
|||||||
import { TypeSplitChart } from './components/TypeSplitChart';
|
import { TypeSplitChart } from './components/TypeSplitChart';
|
||||||
import { CategoryChart } from './components/CategoryChart';
|
import { CategoryChart } from './components/CategoryChart';
|
||||||
import { CardEffectChart } from './components/CardEffectChart';
|
import { CardEffectChart } from './components/CardEffectChart';
|
||||||
|
import { CardTop5 } from './components/CardTop5';
|
||||||
import { wonCompact, pct, signedWonCompact } from './fmt';
|
import { wonCompact, pct, signedWonCompact } from './fmt';
|
||||||
|
import { fillMonths } from './months';
|
||||||
import type { StatData } from './types';
|
import type { StatData } from './types';
|
||||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
|
|
||||||
@ -16,6 +18,9 @@ import { useLabels } from '@/features/settings/useCompanySettings';
|
|||||||
export function StatisticsView({ data }: { data: StatData }) {
|
export function StatisticsView({ data }: { data: StatData }) {
|
||||||
const label = useLabels(); // 회사 설정 용어(카테고리 등)
|
const label = useLabels(); // 회사 설정 용어(카테고리 등)
|
||||||
const k = data.kpi;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 임팩트 요약 KPI */}
|
{/* 임팩트 요약 KPI */}
|
||||||
@ -34,26 +39,21 @@ export function StatisticsView({ data }: { data: StatData }) {
|
|||||||
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />
|
<StatTile label="평균 재견적 라운드" value={k.regenAvgRound.toFixed(1)} icon={RefreshCw} tone="amber" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 절감 분석 */}
|
{/* 월별 추이 2종 — 같은 성격이라 동일 폭(1:1) */}
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Panel
|
<Panel title="월별 절감 추이" subtitle="목표가 대비 절감액 · 막대에 마우스를 올리면 절감률">
|
||||||
title="월별 절감 추이"
|
<SavingsTrendChart data={trend} />
|
||||||
subtitle="목표가 대비 절감액 · 막대에 마우스를 올리면 절감률"
|
|
||||||
className="lg:col-span-2"
|
|
||||||
>
|
|
||||||
<SavingsTrendChart data={data.trend} />
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="월별 인상억제율" subtitle="재협상: 직전 라운드 투찰가 대비 인하율">
|
<Panel title="월별 인상억제율" subtitle="재협상: 직전 라운드 투찰가 대비 인하율">
|
||||||
<MarkupTrendChart data={data.markupTrend} />
|
<MarkupTrendChart data={markupTrend} />
|
||||||
</Panel>
|
|
||||||
<Panel title="마감 결과" subtitle="낙찰 vs 개찰 사유 4종">
|
|
||||||
<OutcomeChart data={data.outcome} />
|
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</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="초대 세션 대비 응찰·미응찰·거부">
|
<Panel title="협력사 참여" subtitle="초대 세션 대비 응찰·미응찰·거부">
|
||||||
<ParticipationChart data={data.participation} />
|
<ParticipationChart data={data.participation} />
|
||||||
</Panel>
|
</Panel>
|
||||||
@ -63,13 +63,16 @@ export function StatisticsView({ data }: { data: StatData }) {
|
|||||||
</div>
|
</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="어디서 절감이 났나">
|
<Panel title={`${label('category')}별 절감`} subtitle="어디서 절감이 났나">
|
||||||
<CategoryChart data={data.categories} />
|
<CategoryChart data={data.categories} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel title="협상카드 효과" subtitle="유형별 사용빈도 + 사용 직후 평균 제시가 하락">
|
<Panel title="협상카드 효과" subtitle="유형별 사용빈도 + 사용 직후 평균 제시가 하락">
|
||||||
<CardEffectChart data={data.cards} />
|
<CardEffectChart data={data.cards} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
<Panel title="협상카드 성공률 TOP 5" subtitle="사용 이력 있는 카드 · 사용 세션 중 타결 비율">
|
||||||
|
<CardTop5 />
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
negodata/front/src/features/statistics/months.ts
Normal file
30
negodata/front/src/features/statistics/months.ts
Normal 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))];
|
||||||
|
}
|
||||||
@ -76,7 +76,10 @@ export default function PartnersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
|
{/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */}
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||||
<PageToolbar
|
<PageToolbar
|
||||||
|
className="rounded-none border-0 border-b border-border"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
@ -128,6 +131,7 @@ export default function PartnersPage() {
|
|||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
<PartnerTable
|
<PartnerTable
|
||||||
|
className="rounded-none border-0"
|
||||||
data={partners}
|
data={partners}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
@ -138,6 +142,7 @@ export default function PartnersPage() {
|
|||||||
pageSize={list.pageSize}
|
pageSize={list.pageSize}
|
||||||
onPageChange={list.setPage}
|
onPageChange={list.setPage}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isFormOpen && (
|
{isFormOpen && (
|
||||||
<PartnerFormSheet
|
<PartnerFormSheet
|
||||||
|
|||||||
@ -71,7 +71,10 @@ export default function QuotationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
|
{/* 검색/액션 바 + 테이블을 한 카드로 붙인다(포털형). */}
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||||
<PageToolbar
|
<PageToolbar
|
||||||
|
className="rounded-none border-0 border-b border-border"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button id="quotation-create-btn" onClick={() => overlay.open('create')}>
|
<Button id="quotation-create-btn" onClick={() => overlay.open('create')}>
|
||||||
@ -154,6 +157,7 @@ export default function QuotationPage() {
|
|||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
<QuotationTable
|
<QuotationTable
|
||||||
|
className="rounded-none border-0"
|
||||||
data={quotations}
|
data={quotations}
|
||||||
products={products}
|
products={products}
|
||||||
onOpenDetail={(id) => overlay.open('detail', id)}
|
onOpenDetail={(id) => overlay.open('detail', id)}
|
||||||
@ -174,6 +178,7 @@ export default function QuotationPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{detailId && activeQuotation && (
|
{detailId && activeQuotation && (
|
||||||
<QuotationDetailSheet
|
<QuotationDetailSheet
|
||||||
|
|||||||
@ -33,6 +33,8 @@ export interface NegotiationCard {
|
|||||||
triggerCondition?: string;
|
triggerCondition?: string;
|
||||||
memo?: string;
|
memo?: string;
|
||||||
creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음
|
creatorName?: string; // 등록자(작성자) 이름. 공용(user_id NULL) 카드는 없음
|
||||||
|
successRate: number; // 카드 성공률(사용 세션 중 타결 비율). #12 순위
|
||||||
|
usedCount: number; // 카드 사용 세션 수(표본)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'NOTIFICATIONS';
|
export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'NOTIFICATIONS';
|
||||||
|
|||||||
59
scripts/imk-company-settings.json
Normal file
59
scripts/imk-company-settings.json
Normal 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": []
|
||||||
|
}
|
||||||
36
scripts/seed_demo_card_usage.sql
Normal file
36
scripts/seed_demo_card_usage.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user