[feat] negodata: 견적 목표가·앵커 반올림 + 통계 협상카드 효과 — 목표가=매입가×(1−네고율) 자동입력·산정후보를 낙찰기준탭으로 이동·목표가상한 상시표시 제거, 후보가/앵커 생성가 10원 단위 반올림(신규 세션, schedules 앵커와 동일 공식), 산정모달은 구매담당자 제시가 있어도 후보 전부 노출·MD입력가→구매담당자 제시가, 신규상품 등록에 공급사칸 추가, 상품상세/견적생성 라벨 회사설정 연동, 통계 협상카드 효과(유형별 사용빈도+사용직후 평균 제시가 하락)·와일드카드 1%인하 집계, mono 폰트 폴백
This commit is contained in:
parent
6534d53696
commit
b33ae05cd6
@ -1,7 +1,7 @@
|
||||
"""순수 계산 함수 — DB 접근 없음. 원본: schedules/anchoring/src/anchoring/service.py 에서
|
||||
읽기 경로(칸 해석·앵커가 산출) 두 함수만 발췌 이식. 표본 판정·평가 함수는 배치 전용이라 제외.
|
||||
|
||||
모든 산술은 정수(천분율 ‰). float 금지 — 1원 단위 내림의 정확성 보장.
|
||||
모든 산술은 정수(천분율 ‰). float 금지 — 10원 단위 반올림의 정확성 보장.
|
||||
"""
|
||||
from bisect import bisect_right
|
||||
|
||||
@ -17,5 +17,7 @@ def calc_price_range_index(target_price: int) -> int:
|
||||
|
||||
|
||||
def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
|
||||
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)"""
|
||||
return target_price * (1000 - anchoring_value) // 1000
|
||||
"""앵커링가 = 목표가 × (1 − A), 10원 단위 반올림. (정수 연산만 — float 곱셈 재도입 금지)
|
||||
|
||||
schedules/anchoring 의 제안 시점 앵커와 동일 공식 — 생성·제안 앵커가 10원 단위로 일치."""
|
||||
return (target_price * (1000 - anchoring_value) + 5000) // 10000 * 10
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import select, func, and_, case
|
||||
from sqlalchemy import select, func, and_, or_, case
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations, sessions, items, chats, users
|
||||
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus
|
||||
from common.enums import ErrorType, QuotationStatus, CloseReason, SessionStatus, CardType, ChatSender
|
||||
from common.logger import LOG
|
||||
|
||||
|
||||
@ -67,6 +67,10 @@ class IStatisticsCRUD(ABC):
|
||||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def card_effect_chats(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
pass
|
||||
|
||||
|
||||
class StatisticsCRUD(IStatisticsCRUD):
|
||||
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
@ -278,11 +282,18 @@ class StatisticsCRUD(IStatisticsCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
|
||||
# 카드 유형별 사용 빈도: card_used_yn=True 채팅 + 1% 인하 시스템 카드(meta.step='wild_card_1pct', card_id 미제공)를
|
||||
# 와일드로 함께 집계. (1% 카드는 카탈로그 카드가 아니라 card_type 로그가 없어 step 으로 잡는다.)
|
||||
try:
|
||||
step_1pct = chats.meta["step"].astext == "wild_card_1pct"
|
||||
ctype = case(
|
||||
(chats.card_used_yn.is_(True), chats.card_type),
|
||||
(step_1pct, CardType.WILD.value),
|
||||
else_=None,
|
||||
)
|
||||
conds = [
|
||||
chats.deleted == False, # noqa: E712
|
||||
chats.card_used_yn.is_(True),
|
||||
or_(chats.card_used_yn.is_(True), step_1pct),
|
||||
quotations.deleted == False, # noqa: E712
|
||||
quotations.created_at >= since,
|
||||
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
|
||||
@ -290,12 +301,44 @@ class StatisticsCRUD(IStatisticsCRUD):
|
||||
if owner is not None:
|
||||
conds.append(quotations.user_id == owner)
|
||||
stmt = (
|
||||
select(chats.card_type, func.count())
|
||||
select(ctype, func.count())
|
||||
.select_from(chats)
|
||||
.join(sessions, sessions.session_id == chats.session_id)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(and_(*conds))
|
||||
.group_by(chats.card_type)
|
||||
.group_by(ctype)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def card_effect_chats(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
|
||||
# 카드 사용 직후 제시가 하락 산출용 — 유저 제시가 chat + 카드 사용 chat(1% 인하 포함)을 세션·순번 순으로.
|
||||
try:
|
||||
step_1pct = chats.meta["step"].astext == "wild_card_1pct"
|
||||
conds = [
|
||||
chats.deleted == False, # noqa: E712
|
||||
quotations.deleted == False, # noqa: E712
|
||||
quotations.created_at >= since,
|
||||
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
|
||||
or_(
|
||||
and_(chats.sender == ChatSender.USER.value, chats.target_price > 0),
|
||||
chats.card_used_yn.is_(True),
|
||||
step_1pct,
|
||||
),
|
||||
]
|
||||
if owner is not None:
|
||||
conds.append(quotations.user_id == owner)
|
||||
stmt = (
|
||||
select(chats.session_id, chats.seq, chats.sender, chats.target_price, chats.card_used_yn,
|
||||
chats.card_type, step_1pct, sessions.bid_price, sessions.status)
|
||||
.select_from(chats)
|
||||
.join(sessions, sessions.session_id == chats.session_id)
|
||||
.join(quotations, quotations.qt_id == sessions.quotation_id)
|
||||
.where(and_(*conds))
|
||||
.order_by(chats.session_id, chats.seq)
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
|
||||
return (err, list(rows) if err == ErrorType.SUCCESS else [])
|
||||
|
||||
@ -25,7 +25,7 @@ class PricingMixin:
|
||||
# 강등 시 딸려가는 것: quotation_settings 컬럼(날짜 SQL 파일)·세팅 화면·산정내역 표기·테스트.
|
||||
|
||||
# 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기).
|
||||
_CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
|
||||
_CANDIDATE_LABELS = {"md": "구매담당자 제시가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
|
||||
|
||||
# 가격 소스별로, 회사 설정 hidden_fields 에서 쓰는 필드 이름. 숨긴 가격은 목표가 후보에서도 뺀다.
|
||||
_SOURCE_HIDDEN_FIELD = {"internet": "internet_lowest_price", "purchase": "purchase_price", "selling": "selling_price"}
|
||||
@ -44,10 +44,11 @@ class PricingMixin:
|
||||
if is_new:
|
||||
table = [("internet", internet_lowest, fee)]
|
||||
else:
|
||||
table = [("internet", internet_lowest, fee), ("purchase", purchase, 0.0), ("selling", selling, margin)]
|
||||
table = [("internet", internet_lowest, fee), ("purchase", purchase, margin), ("selling", selling, margin)]
|
||||
hidden = hidden or set()
|
||||
# 후보가는 10원 단위 반올림(IMK #11) — 목표가·산정내역·자동채움이 다 이 값으로 일치.
|
||||
return [
|
||||
(basis, int(price) * (1 - (rate or 0.0)))
|
||||
(basis, round(int(price) * (1 - (rate or 0.0)) / 10) * 10)
|
||||
for basis, price, rate in table
|
||||
if price and PricingMixin._SOURCE_HIDDEN_FIELD[basis] not in hidden
|
||||
]
|
||||
@ -169,7 +170,7 @@ class PricingMixin:
|
||||
value = value_map.get((company, supply_type, price_range)) if company is not None else None
|
||||
if value is None:
|
||||
value = get_base_anchoring_value(price_range)
|
||||
ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성)
|
||||
ap = calc_anchoring_price(tp, value) # 목표가×(1−value), 10원 반올림(정수 연산 — schedules 앵커와 동일)
|
||||
anchors[(iid, sid)] = (value, ap)
|
||||
return anchors
|
||||
|
||||
@ -201,7 +202,10 @@ class PricingMixin:
|
||||
is_new = QuotationType.is_new(quotation.type)
|
||||
md = quotation.md_price
|
||||
|
||||
cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new, hidden)
|
||||
# 산정내역 화면은 구매담당자 제시가(md)가 있어도 후보를 다 보여준다(생성 로직과 달리 '표시용').
|
||||
# 생성 시엔 md 가 있으면 md 하나만 쓰지만(_calc_target_price), 여기선 md + 상품후보를 함께 나열해 근거를 드러낸다.
|
||||
base_cands = self._candidates(None, internet, purchase, selling, fee, margin, is_new, hidden)
|
||||
cands = ([("md", float(int(md)))] if md else []) + base_cands
|
||||
chosen_basis = next((b for b, v in cands if int(v) == sess.target_price), None)
|
||||
is_inherited = chosen_basis is None
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ from fastapi import Depends
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import quotations
|
||||
from common.enums import DBWRType, ErrorType, QuotationType, SessionStatus, CardType, CloseReason
|
||||
from common.enums import DBWRType, ErrorType, QuotationType, SessionStatus, CardType, CloseReason, ChatSender
|
||||
from common.utils.gtime import GTime
|
||||
from crud.statistics_crud import StatisticsCRUD, IStatisticsCRUD
|
||||
from router.v1.statistics.protocol import (
|
||||
@ -55,6 +55,7 @@ class StatisticsService:
|
||||
markup = await self._read_scalar(lambda s: self.stat_crud.markup_suppression(s, company_uuid, owner_uuid, since))
|
||||
markup_rows = await self._read(lambda s: self.stat_crud.markup_suppression_monthly(s, company_uuid, owner_uuid, since))
|
||||
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
|
||||
card_effect_rows = await self._read(lambda s: self.stat_crud.card_effect_chats(s, company_uuid, owner_uuid, since))
|
||||
|
||||
scope.trend = self._trend(win_rows, labels)
|
||||
scope.markup_trend = [StatMarkupPoint(month=r[0], rate=float(r[1] or 0.0)) for r in markup_rows]
|
||||
@ -62,7 +63,7 @@ class StatisticsService:
|
||||
scope.outcome = self._outcome(outcome_rows)
|
||||
scope.participation = self._participation(part_rows)
|
||||
scope.type_split = self._type_split(type_rows, win_rows)
|
||||
scope.cards = self._cards(card_rows)
|
||||
scope.cards = self._cards(card_rows, self._card_drops(card_effect_rows))
|
||||
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen, markup)
|
||||
return scope
|
||||
|
||||
@ -164,13 +165,43 @@ class StatisticsService:
|
||||
out.append(StatTypeRow(label=label, award_rate=rate, avg_savings=avg, count=cnt))
|
||||
return out
|
||||
|
||||
def _cards(self, card_rows) -> list:
|
||||
def _cards(self, card_rows, drops: dict) -> list:
|
||||
by = {int(ct): int(n) for ct, n in card_rows if ct is not None}
|
||||
return [
|
||||
StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.value, 0), avg_drop=0),
|
||||
StatCardUsage(type="wild", label="와일드카드", uses=by.get(CardType.WILD.value, 0), avg_drop=0),
|
||||
StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.value, 0), avg_drop=int(round(drops.get(CardType.NEGO.value, 0)))),
|
||||
StatCardUsage(type="wild", label="와일드카드", uses=by.get(CardType.WILD.value, 0), avg_drop=int(round(drops.get(CardType.WILD.value, 0)))),
|
||||
]
|
||||
|
||||
def _card_drops(self, rows) -> dict:
|
||||
# 카드 사용 직후 제시가 하락(유형별 평균).
|
||||
# - 일반: 카드 직전 유저 제시가 − 직후 유저 제시가.
|
||||
# - 1% 인하(수락은 가격 재입력이 아님): 카드 직전 유저 제시가 − 최종 낙찰가(타결 세션).
|
||||
# rows: (session_id, seq, sender, target_price, card_used_yn, card_type, is_1pct, bid_price, status)
|
||||
by_sess: dict = {}
|
||||
for r in rows:
|
||||
by_sess.setdefault(r[0], []).append(r)
|
||||
sums = {CardType.NEGO.value: 0, CardType.WILD.value: 0}
|
||||
cnts = {CardType.NEGO.value: 0, CardType.WILD.value: 0}
|
||||
for chs in by_sess.values():
|
||||
for i, ch in enumerate(chs):
|
||||
_sid, _seq, _sender, _tp, used, ctype, is_1pct, bid_price, status = ch
|
||||
if not (used or is_1pct):
|
||||
continue
|
||||
ct = int(ctype) if ctype else (CardType.WILD.value if is_1pct else None)
|
||||
if ct not in sums:
|
||||
continue
|
||||
prev = next((c[3] for c in reversed(chs[:i]) if c[2] == ChatSender.USER.value and c[3] and c[3] > 0), None)
|
||||
if is_1pct:
|
||||
if prev is not None and bid_price and status == SessionStatus.DONE.value and prev >= bid_price:
|
||||
sums[ct] += (prev - bid_price)
|
||||
cnts[ct] += 1
|
||||
else:
|
||||
after = next((c[3] for c in chs[i + 1:] if c[2] == ChatSender.USER.value and c[3] and c[3] > 0), None)
|
||||
if prev is not None and after is not None:
|
||||
sums[ct] += (prev - after)
|
||||
cnts[ct] += 1
|
||||
return {ct: (sums[ct] / cnts[ct]) if cnts[ct] else 0 for ct in sums}
|
||||
|
||||
# ── 창(최근 6개월) ─────────────────────────────────────────
|
||||
def _window(self, now):
|
||||
yy, mm = now.year, now.month
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||
import { SUPPLIER_TYPE_OPTIONS, SupplierType, supplierTypeLabel } from '@/lib/enumLabels';
|
||||
|
||||
// 신규 상품 등록 폼용 공급사 선택(IMK #8). itemId 가 아직 없으므로 서버 반영 없이 로컬 상태만 모으고,
|
||||
// 상품 생성 성공 후 부모(ProductFormSheet)가 생성된 item_id 로 supplier_items 매핑을 만든다.
|
||||
export type PickedSupplier = { supplierId: string; label: string; supplyType: number };
|
||||
|
||||
export function NewItemSuppliersPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PickedSupplier[];
|
||||
onChange: (next: PickedSupplier[]) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState('');
|
||||
const catalogQuery = useListSuppliers({ search: q || undefined, size: 30 }); // 서버검색
|
||||
const [pickSupplierId, setPickSupplierId] = useState('');
|
||||
const [pickLabel, setPickLabel] = useState('');
|
||||
const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0)
|
||||
|
||||
const pickedIds = new Set(value.map((v) => v.supplierId));
|
||||
const options: ComboOption[] = (catalogQuery.data?.suppliers ?? [])
|
||||
.filter((s) => !pickedIds.has(s.supplier_id))
|
||||
.map((s) => ({ id: s.supplier_id, label: `${s.name}${s.code ? ` [${s.code}]` : ''}` }));
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!pickSupplierId) return;
|
||||
onChange([...value, { supplierId: pickSupplierId, label: pickLabel, supplyType: Number(pickType) }]);
|
||||
setPickSupplierId('');
|
||||
setPickLabel('');
|
||||
setPickType(String(SupplierType.NONE));
|
||||
setQ('');
|
||||
};
|
||||
|
||||
const handleChangeType = (supplierId: string, v: string) => {
|
||||
onChange(value.map((it) => (it.supplierId === supplierId ? { ...it, supplyType: Number(v) } : it)));
|
||||
};
|
||||
|
||||
const handleRemove = (supplierId: string) => {
|
||||
onChange(value.filter((it) => it.supplierId !== supplierId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
<Typography as="label" variant="label">공급사 ({value.length})</Typography>
|
||||
|
||||
{/* 추가 행 — 공급사 + 공급유형 선택 후 추가(로컬) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Combobox
|
||||
id="new-item-supplier-pick"
|
||||
options={options}
|
||||
loading={catalogQuery.isLoading}
|
||||
onQueryChange={setQ}
|
||||
value={pickSupplierId || undefined}
|
||||
selectedLabel={pickLabel}
|
||||
onSelect={(opt) => { setPickSupplierId(opt.id); setPickLabel(opt.label); }}
|
||||
placeholder="공급사로 추가할 협력사 검색..."
|
||||
searchPlaceholder="협력사명·코드로 검색..."
|
||||
emptyText="일치하는 협력사가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<Select value={pickType} onValueChange={(v) => setPickType(v ?? String(SupplierType.NONE))}>
|
||||
<SelectTrigger id="new-item-supplier-pick-type" className="w-full">
|
||||
<SelectValue>{(value2) => supplierTypeLabel(Number(value2))}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={handleAdd} disabled={!pickSupplierId}>
|
||||
<Plus />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 선택한 공급사 목록(로컬) */}
|
||||
<div className="border border-border rounded divide-y divide-border max-h-48 overflow-y-auto">
|
||||
{value.length === 0 ? (
|
||||
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">
|
||||
선택한 공급사가 없습니다. (선택)
|
||||
</Typography>
|
||||
) : (
|
||||
value.map((m) => (
|
||||
<div key={m.supplierId} className="flex items-center gap-2 p-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Typography as="span" variant="small" className="font-semibold block truncate">
|
||||
{m.label || '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<Select value={String(m.supplyType)} onValueChange={(v) => v != null && handleChangeType(m.supplierId, v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{(value2) => supplierTypeLabel(Number(value2))}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(m.supplierId)}
|
||||
title="공급사 제거"
|
||||
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@ -16,6 +17,8 @@ import { useAuthStore, canManage } from '@/stores/auth';
|
||||
import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
|
||||
import { ItemSuppliersManager } from './ItemSuppliersManager';
|
||||
import { NewItemSuppliersPicker, type PickedSupplier } from './NewItemSuppliersPicker';
|
||||
import { createSupplierItem } from '@/api/generated/supplier-item/supplier-item';
|
||||
import { type Product } from '../types';
|
||||
|
||||
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
|
||||
@ -50,7 +53,7 @@ type ProductFormSheetProps = {
|
||||
categories: string[]; // 서버 items 에서 distinct 로 뽑은 카테고리 목록(선택지)
|
||||
categoryTypeByName: Record<string, number>; // 카테고리명 → category_type(id) 매핑
|
||||
nextCategoryType: number; // 신규 카테고리에 부여할 id(= 기존 max + 1)
|
||||
onCreate: (data: ItemCreate) => Promise<void>;
|
||||
onCreate: (data: ItemCreate) => Promise<string | void>; // 생성된 item_id 반환(공급사 매핑용)
|
||||
onUpdate: (itemId: string, data: ItemUpdate) => Promise<void>;
|
||||
onDelete: (itemId: string, name: string) => void;
|
||||
onClose: () => void;
|
||||
@ -142,6 +145,7 @@ export function ProductFormSheet({
|
||||
const { settings } = useCompanySettings();
|
||||
const itemFields = settings.item_fields ?? [];
|
||||
const customValues = useCustomFieldValues(itemFields, product?.custom);
|
||||
const [newSuppliers, setNewSuppliers] = useState<PickedSupplier[]>([]); // 신규 등록 시 매핑할 공급사(생성 후 매핑)
|
||||
|
||||
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
@ -184,7 +188,17 @@ export function ProductFormSheet({
|
||||
image_url: v.imageUrl || 'https://images.unsplash.com/photo-1593941707882-a5bba14938c7?w=300',
|
||||
};
|
||||
try {
|
||||
await onCreate(payload);
|
||||
const itemId = await onCreate(payload);
|
||||
// 생성된 상품에 선택한 공급사 매핑(supplier_items). 매핑 실패는 상품 등록 자체를 막지 않는다.
|
||||
if (itemId && newSuppliers.length > 0) {
|
||||
for (const s of newSuppliers) {
|
||||
try {
|
||||
await createSupplierItem({ supplier_id: s.supplierId, item_id: itemId, supply_type: s.supplyType });
|
||||
} catch {
|
||||
showToast(`공급사 '${s.label}' 매핑에 실패했습니다.`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
showToast('신규 B2B 상품이 안전하게 등록되었습니다.', 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@ -481,6 +495,8 @@ export function ProductFormSheet({
|
||||
|
||||
{/* 공급사 관리(IMK #20) — 수정 모드(상품 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||
{mode === 'edit' && product && <ItemSuppliersManager itemId={product.item_id} />}
|
||||
{/* 공급사 선택(IMK #8) — 신규 등록 모드. 로컬로 모으고 상품 생성 후 매핑한다. */}
|
||||
{mode === 'create' && <NewItemSuppliersPicker value={newSuppliers} onChange={setNewSuppliers} />}
|
||||
|
||||
{/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */}
|
||||
<CustomFieldInputs fields={itemFields} state={customValues} title="회사 추가 항목" />
|
||||
|
||||
@ -54,10 +54,12 @@ export function useProducts(params: ListItemsParams) {
|
||||
queryClient.invalidateQueries({ queryKey: ['/v1/item/categories'] }),
|
||||
]);
|
||||
|
||||
const createProduct = async (data: ReqCreateItem) => {
|
||||
const msg = itemError(await createItem(data));
|
||||
const createProduct = async (data: ReqCreateItem): Promise<string | undefined> => {
|
||||
const res = await createItem(data);
|
||||
const msg = itemError(res);
|
||||
if (msg) throw new Error(msg);
|
||||
await refresh();
|
||||
return res.item?.item_id; // 공급사 매핑용 item_id 반환
|
||||
};
|
||||
const updateProduct = async (itemId: string, data: ReqUpdateItem) => {
|
||||
await updateItem(itemId, data);
|
||||
|
||||
@ -7,7 +7,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
import { useListCards } from '@/api/generated/card/card';
|
||||
import { mapCardData } from '@/features/cards/types';
|
||||
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -96,6 +96,7 @@ export function QuotationCreateModal({
|
||||
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
|
||||
|
||||
const type = toQuotationType(mode, isNew); // 4코드 합성값
|
||||
const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다
|
||||
@ -128,6 +129,7 @@ export function QuotationCreateModal({
|
||||
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
|
||||
const supplyTypeBySupplier = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
||||
@ -174,11 +176,18 @@ export function QuotationCreateModal({
|
||||
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
|
||||
const lowestPriceLeak = (c: NegotiationCard) =>
|
||||
lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}');
|
||||
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
|
||||
const blockReason = (c: NegotiationCard): string | null =>
|
||||
conditionUnfilled(c) ? `${CONDITION_LABEL} 미작성` : lowestPriceLeak(c) ? '인터넷 최저가 미수집' : null;
|
||||
c.isWildcard && c.status !== 'ACTIVE'
|
||||
? '미승인 와일드카드'
|
||||
: conditionUnfilled(c)
|
||||
? `${CONDITION_LABEL} 미작성`
|
||||
: lowestPriceLeak(c)
|
||||
? '인터넷 최저가 미수집'
|
||||
: null;
|
||||
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
|
||||
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
|
||||
const rankedCards = cardRows
|
||||
.filter((c) => !c.isWildcard || c.status === 'ACTIVE')
|
||||
.slice()
|
||||
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
|
||||
const cardOptions: ComboOption[] = rankedCards
|
||||
@ -253,22 +262,27 @@ export function QuotationCreateModal({
|
||||
const d = cardDetails.get(id);
|
||||
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
||||
});
|
||||
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
|
||||
const mdNum = Number(mdPrice) || 0;
|
||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
const mdRequired = !!productId && !hasItemCandidate;
|
||||
// 목표가 산정 가능 여부: MD가가 있으면 무조건 OK. 없으면 상품 후보 중 하나라도 있어야.
|
||||
const targetReady = mdNum > 0 || hasItemCandidate;
|
||||
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
|
||||
const margin = parsePercent(selectedSetting?.target_margin);
|
||||
const targetCandidates = mdNum > 0
|
||||
? [mdNum]
|
||||
: [
|
||||
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null,
|
||||
!isReType || purchase == null ? null : purchase,
|
||||
!isReType || selling == null ? null : selling * (1 - margin),
|
||||
].filter((v): v is number => v != null && v > 0);
|
||||
const estimatedTargetPrice = targetCandidates.length ? Math.trunc(Math.min(...targetCandidates)) : null;
|
||||
// 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
|
||||
const targetBreakdown = [
|
||||
{ key: 'internet_lowest_price', label: `${label('item.internet_lowest_price')} × (1−수수료 ${+(INTERNET_AVERAGE_FEE * 100).toFixed(1)}%)`, raw: internetLowest, rate: INTERNET_AVERAGE_FEE, show: internetLowest != null },
|
||||
{ key: 'purchase_price', label: `${label('item.purchase_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: purchase, rate: margin, show: isReType && purchase != null },
|
||||
{ key: 'selling_price', label: `${label('item.selling_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: selling, rate: margin, show: isReType && selling != null },
|
||||
]
|
||||
.filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
|
||||
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
|
||||
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
|
||||
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
|
||||
// 구매담당자 제시가 필드엔 자동 산출값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다.
|
||||
// (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.)
|
||||
const effectiveMdPrice = mdTouched ? mdPrice : (autoTarget != null ? String(autoTarget) : mdPrice);
|
||||
const mdNum = Number(effectiveMdPrice) || 0;
|
||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
const mdRequired = !!productId && !hasItemCandidate;
|
||||
const targetReady = mdNum > 0 || hasItemCandidate;
|
||||
// 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 자동 산출값.
|
||||
const estimatedTargetPrice = mdNum > 0 ? mdNum : autoTarget;
|
||||
const targetPriceLimit = unitPrice != null && unitPrice > 0
|
||||
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
|
||||
: null;
|
||||
@ -304,7 +318,7 @@ export function QuotationCreateModal({
|
||||
};
|
||||
// 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지.
|
||||
const selectAllCards = () => {
|
||||
const rows = cardRows.filter((c) => !c.isWildcard || c.status === 'ACTIVE');
|
||||
const rows = cardRows.filter((c) => !blockReason(c));
|
||||
setCardDetails((m) => {
|
||||
const next = new Map(m);
|
||||
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
|
||||
@ -320,7 +334,7 @@ export function QuotationCreateModal({
|
||||
try {
|
||||
// 목표가 산정에 쓸 값이 없으면(MD가·상품 후보 전무) 생성 차단 — 서버 산정불가 에러 선제 방어.
|
||||
if (productId && !targetReady) {
|
||||
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
|
||||
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
|
||||
return; // finally 에서 submitting 해제
|
||||
}
|
||||
if (!isFutureLocalInput(dueDate)) {
|
||||
@ -341,7 +355,7 @@ export function QuotationCreateModal({
|
||||
settingId,
|
||||
cardIds: oneToOne ? selectedCardIds : [],
|
||||
memo,
|
||||
mdPrice: mdPrice ? Number(mdPrice) : null,
|
||||
mdPrice: mdTouched && mdPrice ? Number(mdPrice) : null,
|
||||
midAction: oneToOne ? midAction : undefined,
|
||||
overAction: oneToOne ? overAction : undefined,
|
||||
});
|
||||
@ -422,7 +436,7 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">마감기한</Typography>
|
||||
<Typography as="label" variant="label">{label('quotation.due_date')}</Typography>
|
||||
<input
|
||||
id="wizard-date"
|
||||
type="datetime-local"
|
||||
@ -434,7 +448,7 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">견적건명</Typography>
|
||||
<Typography as="label" variant="label">{label('quotation.title')}</Typography>
|
||||
<Input
|
||||
id="wizard-title"
|
||||
type="text"
|
||||
@ -460,79 +474,6 @@ export function QuotationCreateModal({
|
||||
emptyText="일치하는 상품이 없습니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label" className={mdRequired ? 'text-rose-500' : undefined}>
|
||||
MD 제시가 {mdRequired ? '(필수 — 다른 후보 없음)' : '(선택)'}
|
||||
</Typography>
|
||||
<Input
|
||||
id="wizard-md-price"
|
||||
type="number"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
value={mdPrice}
|
||||
onChange={(e) => setMdPrice(e.target.value)}
|
||||
placeholder={mdRequired ? '상품에 산정값이 없어 MD가 입력이 필요합니다' : '입력 시 목표가로 사용 · 미입력 시 자동 산정'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 목표가 산정 후보 — 상품 값(읽기전용). 신규=인터넷최저가, 재=+매입가·판매가. 수정은 상품 상세에서. */}
|
||||
{productId && (
|
||||
<div className="rounded border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="label">
|
||||
목표가 산정 후보 <span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
|
||||
</Typography>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/products?detail=${productId}`)}
|
||||
className={cn(typographyVariants({ variant: 'link' }), 'text-[10px]')}
|
||||
>
|
||||
상품 상세에서 수정
|
||||
</button>
|
||||
</div>
|
||||
<div className={`grid ${isReType ? 'grid-cols-3' : 'grid-cols-1'} gap-2`}>
|
||||
{[
|
||||
{ label: '인터넷 최저가', value: internetLowest, show: true },
|
||||
{ label: '매입가', value: purchase, show: isReType },
|
||||
{ label: '판매가', value: selling, show: isReType },
|
||||
]
|
||||
.filter((r) => r.show)
|
||||
.map((r) => (
|
||||
<div key={r.label} className="space-y-0.5">
|
||||
<Typography as="span" variant="label" className="text-muted-foreground">{r.label}</Typography>
|
||||
<Typography as="span" variant="small" className="font-mono block">
|
||||
{r.value != null ? `₩${Number(r.value).toLocaleString()}` : '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border/60 pt-2">
|
||||
{[
|
||||
{ label: '상품단가', value: unitPrice },
|
||||
{ label: `목표가 상한 (상품단가 × ${TARGET_PRICE_UNIT_LIMIT_MULTIPLIER})`, value: targetPriceLimit },
|
||||
].map((r) => (
|
||||
<div key={r.label} className="space-y-0.5">
|
||||
<Typography as="span" variant="label" className="text-muted-foreground">{r.label}</Typography>
|
||||
<Typography as="span" variant="small" className="font-mono block">
|
||||
{r.value != null ? `₩${Number(r.value).toLocaleString()}` : '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!targetReady && (
|
||||
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
|
||||
⚠ MD 제시가도 없고 상품에 산정할 값이 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
|
||||
</Typography>
|
||||
)}
|
||||
{targetLimitExceeded && (
|
||||
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
|
||||
목표가 예상값 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — MD 제시가 또는 상품 가격 정보를 조정해 주세요.
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -622,6 +563,73 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목표가 산정 후보(계산식+결과값) — 참고용, 위. 채택된 최저는 녹색 강조. */}
|
||||
{productId && targetBreakdown.length > 0 && (
|
||||
<div className="rounded border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography as="span" variant="label">
|
||||
목표가 산정 후보 <span className="text-muted-foreground font-normal">({isReType ? '재' : '신규'})</span>
|
||||
</Typography>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/products?detail=${productId}`)}
|
||||
className={cn(typographyVariants({ variant: 'link' }), 'text-[10px]')}
|
||||
>
|
||||
상품 상세에서 수정
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{targetBreakdown.map((c) => {
|
||||
const isMin = autoTarget != null && c.value === autoTarget;
|
||||
return (
|
||||
<div key={c.key} className={cn('flex items-center gap-2 rounded px-2 py-1', isMin && 'bg-emerald-50 dark:bg-emerald-950/30')}>
|
||||
<Typography as="span" variant="small" className="flex-1 min-w-0 truncate text-[11px] text-muted-foreground">{c.label}</Typography>
|
||||
<Typography as="span" variant="small" className="w-16 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground/70">{c.raw.toLocaleString()}</Typography>
|
||||
<Typography as="span" variant="small" className={cn('w-20 shrink-0 text-right font-mono font-bold tabular-nums', isMin && 'text-emerald-700 dark:text-emerald-400')}>₩{c.value.toLocaleString()}</Typography>
|
||||
<span className="w-9 shrink-0 text-right">
|
||||
{isMin && <span className="rounded-full border border-emerald-600 px-1.5 text-[8px] font-bold text-emerald-700 dark:text-emerald-400">최저</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목표가(구매담당자 제시가) — 후보 최저를 자동 입력. 값이 높든 낮든 이 값이 1순위(실제 목표가). 수정 가능. */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label" className={mdRequired ? 'text-rose-500' : undefined}>
|
||||
목표가 (구매담당자 제시가) {mdRequired ? '(필수 — 산정값 없음)' : ''}
|
||||
</Typography>
|
||||
<Input
|
||||
id="wizard-md-price"
|
||||
type="number"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
value={effectiveMdPrice}
|
||||
onChange={(e) => {
|
||||
setMdTouched(true);
|
||||
setMdPrice(e.target.value);
|
||||
}}
|
||||
placeholder={mdRequired ? '상품에 산정값이 없어 직접 입력이 필요합니다' : '자동 산출값 · 수정 가능'}
|
||||
/>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
{autoTarget != null
|
||||
? '후보 중 최저가 자동 입력됨 · 이 값이 목표가(1순위)로 쓰입니다. 수정 가능.'
|
||||
: '자동 산출값이 없어 직접 입력이 필요합니다.'}
|
||||
</Typography>
|
||||
{!targetReady && (
|
||||
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
|
||||
⚠ 목표가를 산정할 값이 없습니다 — 직접 입력하거나 상품 상세에서 인터넷최저가·매입가를 채워주세요.
|
||||
</Typography>
|
||||
)}
|
||||
{targetLimitExceeded && (
|
||||
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
|
||||
목표가 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — 조정해 주세요.
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">메모 (선택)</Typography>
|
||||
<textarea
|
||||
@ -697,15 +705,15 @@ export function QuotationCreateModal({
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (step === 1 && productId && !targetReady) {
|
||||
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (step === 1 && !isFutureLocalInput(dueDate)) {
|
||||
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (step === 1 && targetLimitExceeded) {
|
||||
if (step === 3 && productId && !targetReady) {
|
||||
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (step === 3 && targetLimitExceeded) {
|
||||
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import { InfoField } from './InfoField';
|
||||
import { QuotationStatusBadge } from './StatusPill';
|
||||
import { ResultSummaryBand } from './ResultSummaryBand';
|
||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||
import {
|
||||
type Product,
|
||||
type QuotationSetting,
|
||||
@ -52,6 +52,7 @@ export function DrawerHeaderCards({
|
||||
collapsed = false,
|
||||
}: DrawerHeaderCardsProps) {
|
||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||
const isHidden = useHiddenFields(); // 회사설정으로 감춘 가격 필드는 상품 카드에서도 제외
|
||||
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
|
||||
if (collapsed) {
|
||||
return (
|
||||
@ -83,11 +84,11 @@ export function DrawerHeaderCards({
|
||||
const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-');
|
||||
const productPriceRows = currentProduct
|
||||
? [
|
||||
{ label: '상품단가', value: won(currentProduct.price) },
|
||||
{ label: '매입가', value: won(currentProduct.purchase_price) },
|
||||
{ label: '판매가', value: won(currentProduct.selling_price) },
|
||||
{ label: '인터넷 최저가', value: won(currentProduct.internet_lowest_price) },
|
||||
]
|
||||
{ key: 'price', label: label('item.price'), value: won(currentProduct.price) },
|
||||
{ key: 'purchase_price', label: label('item.purchase_price'), value: won(currentProduct.purchase_price) },
|
||||
{ key: 'selling_price', label: label('item.selling_price'), value: won(currentProduct.selling_price) },
|
||||
{ key: 'internet_lowest_price', label: label('item.internet_lowest_price'), value: won(currentProduct.internet_lowest_price) },
|
||||
].filter((r) => !isHidden(r.key))
|
||||
: [];
|
||||
|
||||
return (
|
||||
@ -161,7 +162,7 @@ export function DrawerHeaderCards({
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
|
||||
{productPriceRows.map((row) => (
|
||||
<InfoField
|
||||
key={row.label}
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
value={row.value}
|
||||
valueClassName="font-sans truncate block"
|
||||
|
||||
@ -22,6 +22,7 @@ const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'
|
||||
// 회사 설정 용어(목표 마진)를 반영해 컴포넌트 안에서 만든다.
|
||||
const candidateSub = (marginLabel: string): Record<string, string> => ({
|
||||
internet: '인터넷 평균 수수료 적용',
|
||||
purchase: `${marginLabel} 적용`,
|
||||
selling: `${marginLabel} 적용`,
|
||||
});
|
||||
|
||||
@ -81,13 +82,13 @@ export function TargetPriceModal({
|
||||
{/* 선정방식 */}
|
||||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. 구매담당자 제시가 존재 시, 최우선 적용</Typography>
|
||||
{/* 설명 문구는 회사 설정(숨김 필드)에 따라 실제 후보와 일치하게 바꾼다. */}
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||||
2. 다음 중 가장 작은 값 — {[
|
||||
!hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)',
|
||||
!hiddenPrice.includes('purchase_price') && '매입가',
|
||||
!hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`,
|
||||
!hiddenPrice.includes('internet_lowest_price') && `${label('item.internet_lowest_price')}×(1−수수료)`,
|
||||
!hiddenPrice.includes('purchase_price') && `${label('item.purchase_price')}×(1−${label('target_margin')})`,
|
||||
!hiddenPrice.includes('selling_price') && `${label('item.selling_price')}×(1−${label('target_margin')})`,
|
||||
].filter(Boolean).join(' | ')}
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
|
||||
@ -38,7 +38,7 @@ export function CardEffectChart({ data }: { data: CardTypeUsage[] }) {
|
||||
{r.label} 평균 하락
|
||||
</Typography>
|
||||
<Typography as="p" variant="small" className="font-mono text-[12px] font-bold text-foreground">
|
||||
{/* TODO: 제시가 하락 델타 미배선 — 백엔드 avg_drop=0 동안 '측정 예정' */}
|
||||
{/* 사용 직후 유저 제시가 하락(카드 직전−직후 제시가 평균). 표본 없으면 '측정 예정'. */}
|
||||
{r.avgDrop > 0 ? won(r.avgDrop) : '측정 예정'}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
@ -58,7 +58,7 @@ export function CardTop5() {
|
||||
</div>
|
||||
|
||||
<Typography as="p" variant="caption" className="mt-1 truncate">
|
||||
{card.isWildcard ? '와일드카드' : '협상카드'} · {card.code} · 사용 {card.usedCount}회
|
||||
{card.isWildcard ? '와일드카드' : '협상카드'} · 사용 {card.usedCount}회
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -40,7 +40,7 @@ export function StatTile({
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<Typography variant="caption" className="block truncate">
|
||||
<Typography variant="caption" className="block break-keep">
|
||||
{label}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
--font-mono: "JetBrains Mono", Consolas, "Segoe UI Mono", ui-monospace, SFMono-Regular, "Menlo", monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user