[feat] negodata: 견적 목표가·앵커 반올림 + 통계 협상카드 효과 — 목표가=매입가×(1−네고율) 자동입력·산정후보를 낙찰기준탭으로 이동·목표가상한 상시표시 제거, 후보가/앵커 생성가 10원 단위 반올림(신규 세션, schedules 앵커와 동일 공식), 산정모달은 구매담당자 제시가 있어도 후보 전부 노출·MD입력가→구매담당자 제시가, 신규상품 등록에 공급사칸 추가, 상품상세/견적생성 라벨 회사설정 연동, 통계 협상카드 효과(유형별 사용빈도+사용직후 평균 제시가 하락)·와일드카드 1%인하 집계, mono 폰트 폴백

This commit is contained in:
Mina Choi 2026-07-29 09:51:03 +09:00
parent 6534d53696
commit b33ae05cd6
14 changed files with 373 additions and 138 deletions

View File

@ -1,7 +1,7 @@
"""순수 계산 함수 — DB 접근 없음. 원본: schedules/anchoring/src/anchoring/service.py 에서 """순수 계산 함수 — DB 접근 없음. 원본: schedules/anchoring/src/anchoring/service.py 에서
읽기 경로(칸 해석·앵커가 산출) 두 함수만 발췌 이식. 표본 판정·평가 함수는 배치 전용이라 제외. 읽기 경로(칸 해석·앵커가 산출) 두 함수만 발췌 이식. 표본 판정·평가 함수는 배치 전용이라 제외.
모든 산술은 정수(천분율 ‰). float 금지 — 1원 단위 내림의 정확성 보장. 모든 산술은 정수(천분율 ‰). float 금지 — 10원 단위 반올림의 정확성 보장.
""" """
from bisect import bisect_right 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: def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)""" """앵커링가 = 목표가 × (1 − A), 10원 단위 반올림. (정수 연산만 — float 곱셈 재도입 금지)
return target_price * (1000 - anchoring_value) // 1000
schedules/anchoring 의 제안 시점 앵커와 동일 공식 — 생성·제안 앵커가 10원 단위로 일치."""
return (target_price * (1000 - anchoring_value) + 5000) // 10000 * 10

View File

@ -1,13 +1,13 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple 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.orm import aliased
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 quotations, sessions, items, chats, users 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 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]: async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass pass
@abstractmethod
async def card_effect_chats(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
class StatisticsCRUD(IStatisticsCRUD): class StatisticsCRUD(IStatisticsCRUD):
async def winning_sessions(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: 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, [] return ErrorType.DB_RUN_FAILED, []
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: 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: 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 = [ conds = [
chats.deleted == False, # noqa: E712 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.deleted == False, # noqa: E712
quotations.created_at >= since, quotations.created_at >= since,
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)), 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: if owner is not None:
conds.append(quotations.user_id == owner) conds.append(quotations.user_id == owner)
stmt = ( stmt = (
select(chats.card_type, func.count()) select(ctype, func.count())
.select_from(chats) .select_from(chats)
.join(sessions, sessions.session_id == chats.session_id) .join(sessions, sessions.session_id == chats.session_id)
.join(quotations, quotations.qt_id == sessions.quotation_id) .join(quotations, quotations.qt_id == sessions.quotation_id)
.where(and_(*conds)) .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) err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else []) return (err, list(rows) if err == ErrorType.SUCCESS else [])

View File

@ -25,7 +25,7 @@ class PricingMixin:
# 강등 시 딸려가는 것: quotation_settings 컬럼(날짜 SQL 파일)·세팅 화면·산정내역 표기·테스트. # 강등 시 딸려가는 것: quotation_settings 컬럼(날짜 SQL 파일)·세팅 화면·산정내역 표기·테스트.
# 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기). # 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기).
_CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"} _CANDIDATE_LABELS = {"md": "구매담당자 제시가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
# 가격 소스별로, 회사 설정 hidden_fields 에서 쓰는 필드 이름. 숨긴 가격은 목표가 후보에서도 뺀다. # 가격 소스별로, 회사 설정 hidden_fields 에서 쓰는 필드 이름. 숨긴 가격은 목표가 후보에서도 뺀다.
_SOURCE_HIDDEN_FIELD = {"internet": "internet_lowest_price", "purchase": "purchase_price", "selling": "selling_price"} _SOURCE_HIDDEN_FIELD = {"internet": "internet_lowest_price", "purchase": "purchase_price", "selling": "selling_price"}
@ -44,10 +44,11 @@ class PricingMixin:
if is_new: if is_new:
table = [("internet", internet_lowest, fee)] table = [("internet", internet_lowest, fee)]
else: 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() hidden = hidden or set()
# 후보가는 10원 단위 반올림(IMK #11) — 목표가·산정내역·자동채움이 다 이 값으로 일치.
return [ 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 for basis, price, rate in table
if price and PricingMixin._SOURCE_HIDDEN_FIELD[basis] not in hidden 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 value = value_map.get((company, supply_type, price_range)) if company is not None else None
if value is None: if value is None:
value = get_base_anchoring_value(price_range) 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) anchors[(iid, sid)] = (value, ap)
return anchors return anchors
@ -201,7 +202,10 @@ class PricingMixin:
is_new = QuotationType.is_new(quotation.type) is_new = QuotationType.is_new(quotation.type)
md = quotation.md_price 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) chosen_basis = next((b for b, v in cands if int(v) == sess.target_price), None)
is_inherited = chosen_basis is None is_inherited = chosen_basis is None

View File

@ -4,7 +4,7 @@ from fastapi import Depends
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 quotations 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 common.utils.gtime import GTime
from crud.statistics_crud import StatisticsCRUD, IStatisticsCRUD from crud.statistics_crud import StatisticsCRUD, IStatisticsCRUD
from router.v1.statistics.protocol import ( 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 = 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)) 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_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.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] 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.outcome = self._outcome(outcome_rows)
scope.participation = self._participation(part_rows) scope.participation = self._participation(part_rows)
scope.type_split = self._type_split(type_rows, win_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) scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen, markup)
return scope return scope
@ -164,13 +165,43 @@ class StatisticsService:
out.append(StatTypeRow(label=label, award_rate=rate, avg_savings=avg, count=cnt)) out.append(StatTypeRow(label=label, award_rate=rate, avg_savings=avg, count=cnt))
return out 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} by = {int(ct): int(n) for ct, n in card_rows if ct is not None}
return [ return [
StatCardUsage(type="nego", label="협상카드", uses=by.get(CardType.NEGO.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=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개월) ───────────────────────────────────────── # ── 창(최근 6개월) ─────────────────────────────────────────
def _window(self, now): def _window(self, now):
yy, mm = now.year, now.month yy, mm = now.year, now.month

View File

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

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@ -16,6 +17,8 @@ import { useAuthStore, canManage } from '@/stores/auth';
import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings'; import { useCompanySettings, useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs'; import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
import { ItemSuppliersManager } from './ItemSuppliersManager'; import { ItemSuppliersManager } from './ItemSuppliersManager';
import { NewItemSuppliersPicker, type PickedSupplier } from './NewItemSuppliersPicker';
import { createSupplierItem } from '@/api/generated/supplier-item/supplier-item';
import { type Product } from '../types'; import { type Product } from '../types';
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택. // 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
@ -50,7 +53,7 @@ type ProductFormSheetProps = {
categories: string[]; // 서버 items 에서 distinct 로 뽑은 카테고리 목록(선택지) categories: string[]; // 서버 items 에서 distinct 로 뽑은 카테고리 목록(선택지)
categoryTypeByName: Record<string, number>; // 카테고리명 → category_type(id) 매핑 categoryTypeByName: Record<string, number>; // 카테고리명 → category_type(id) 매핑
nextCategoryType: number; // 신규 카테고리에 부여할 id(= 기존 max + 1) 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>; onUpdate: (itemId: string, data: ItemUpdate) => Promise<void>;
onDelete: (itemId: string, name: string) => void; onDelete: (itemId: string, name: string) => void;
onClose: () => void; onClose: () => void;
@ -142,6 +145,7 @@ export function ProductFormSheet({
const { settings } = useCompanySettings(); const { settings } = useCompanySettings();
const itemFields = settings.item_fields ?? []; const itemFields = settings.item_fields ?? [];
const customValues = useCustomFieldValues(itemFields, product?.custom); const customValues = useCustomFieldValues(itemFields, product?.custom);
const [newSuppliers, setNewSuppliers] = useState<PickedSupplier[]>([]); // 신규 등록 시 매핑할 공급사(생성 후 매핑)
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제). // 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
const myUserId = useAuthStore((s) => s.user?.userId); 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', image_url: v.imageUrl || 'https://images.unsplash.com/photo-1593941707882-a5bba14938c7?w=300',
}; };
try { 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'); showToast('신규 B2B 상품이 안전하게 등록되었습니다.', 'success');
onClose(); onClose();
} catch (err) { } catch (err) {
@ -481,6 +495,8 @@ export function ProductFormSheet({
{/* 공급사 관리(IMK #20) — 수정 모드(상품 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */} {/* 공급사 관리(IMK #20) — 수정 모드(상품 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
{mode === 'edit' && product && <ItemSuppliersManager itemId={product.item_id} />} {mode === 'edit' && product && <ItemSuppliersManager itemId={product.item_id} />}
{/* 공급사 선택(IMK #8) — 신규 등록 모드. 로컬로 모으고 상품 생성 후 매핑한다. */}
{mode === 'create' && <NewItemSuppliersPicker value={newSuppliers} onChange={setNewSuppliers} />}
{/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */} {/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */}
<CustomFieldInputs fields={itemFields} state={customValues} title="회사 추가 항목" /> <CustomFieldInputs fields={itemFields} state={customValues} title="회사 추가 항목" />

View File

@ -54,10 +54,12 @@ export function useProducts(params: ListItemsParams) {
queryClient.invalidateQueries({ queryKey: ['/v1/item/categories'] }), queryClient.invalidateQueries({ queryKey: ['/v1/item/categories'] }),
]); ]);
const createProduct = async (data: ReqCreateItem) => { const createProduct = async (data: ReqCreateItem): Promise<string | undefined> => {
const msg = itemError(await createItem(data)); const res = await createItem(data);
const msg = itemError(res);
if (msg) throw new Error(msg); if (msg) throw new Error(msg);
await refresh(); await refresh();
return res.item?.item_id; // 공급사 매핑용 item_id 반환
}; };
const updateProduct = async (itemId: string, data: ReqUpdateItem) => { const updateProduct = async (itemId: string, data: ReqUpdateItem) => {
await updateItem(itemId, data); await updateItem(itemId, data);

View File

@ -7,7 +7,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card'; import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types'; import { mapCardData } from '@/features/cards/types';
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables'; 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 { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography'; import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -96,6 +96,7 @@ export function QuotationCreateModal({
const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용) const [midAction, setMidAction] = useState<number>(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용)
const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용) const [overAction, setOverAction] = useState<number>(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용)
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [mdTouched, setMdTouched] = useState(false); // 담당자가 제시가를 직접 건드렸는지 — 안 건드렸으면 자동 산출값을 채운다
const type = toQuotationType(mode, isNew); // 4코드 합성값 const type = toQuotationType(mode, isNew); // 4코드 합성값
const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다 const oneToOne = is1v1(type); // 1:1 협상 여부 — 협력사 단일선택·낙찰기준·협상카드 노출을 가른다
@ -128,6 +129,7 @@ export function QuotationCreateModal({
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함). // 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } }); const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
const label = useLabels(); // 회사 설정 용어(목표 마진 등) const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
const supplyTypeBySupplier = useMemo(() => { const supplyTypeBySupplier = useMemo(() => {
const m = new Map<string, number>(); const m = new Map<string, number>();
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type)); (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 lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
const lowestPriceLeak = (c: NegotiationCard) => const lowestPriceLeak = (c: NegotiationCard) =>
lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}'); lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}');
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
const blockReason = (c: NegotiationCard): string | null => 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위 배지가 붙는다. // 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
const rankedCards = cardRows const rankedCards = cardRows
.filter((c) => !c.isWildcard || c.status === 'ACTIVE')
.slice() .slice()
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount); .sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
const cardOptions: ComboOption[] = rankedCards const cardOptions: ComboOption[] = rankedCards
@ -253,22 +262,27 @@ export function QuotationCreateModal({
const d = cardDetails.get(id); const d = cardDetails.get(id);
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false }; 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 selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
const margin = parsePercent(selectedSetting?.target_margin); const margin = parsePercent(selectedSetting?.target_margin);
const targetCandidates = mdNum > 0 // 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
? [mdNum] 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 },
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null, { key: 'purchase_price', label: `${label('item.purchase_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: purchase, rate: margin, show: isReType && purchase != null },
!isReType || purchase == null ? null : purchase, { key: 'selling_price', label: `${label('item.selling_price')} × (1−네고율 ${+(margin * 100).toFixed(1)}%)`, raw: selling, rate: margin, show: isReType && selling != null },
!isReType || selling == null ? null : selling * (1 - margin), ]
].filter((v): v is number => v != null && v > 0); .filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
const estimatedTargetPrice = targetCandidates.length ? Math.trunc(Math.min(...targetCandidates)) : null; // 서버 _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 const targetPriceLimit = unitPrice != null && unitPrice > 0
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER ? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
: null; : null;
@ -304,7 +318,7 @@ export function QuotationCreateModal({
}; };
// 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지. // 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지.
const selectAllCards = () => { const selectAllCards = () => {
const rows = cardRows.filter((c) => !c.isWildcard || c.status === 'ACTIVE'); const rows = cardRows.filter((c) => !blockReason(c));
setCardDetails((m) => { setCardDetails((m) => {
const next = new Map(m); const next = new Map(m);
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard })); rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
@ -320,7 +334,7 @@ export function QuotationCreateModal({
try { try {
// 목표가 산정에 쓸 값이 없으면(MD가·상품 후보 전무) 생성 차단 — 서버 산정불가 에러 선제 방어. // 목표가 산정에 쓸 값이 없으면(MD가·상품 후보 전무) 생성 차단 — 서버 산정불가 에러 선제 방어.
if (productId && !targetReady) { if (productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error'); showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
return; // finally 에서 submitting 해제 return; // finally 에서 submitting 해제
} }
if (!isFutureLocalInput(dueDate)) { if (!isFutureLocalInput(dueDate)) {
@ -341,7 +355,7 @@ export function QuotationCreateModal({
settingId, settingId,
cardIds: oneToOne ? selectedCardIds : [], cardIds: oneToOne ? selectedCardIds : [],
memo, memo,
mdPrice: mdPrice ? Number(mdPrice) : null, mdPrice: mdTouched && mdPrice ? Number(mdPrice) : null,
midAction: oneToOne ? midAction : undefined, midAction: oneToOne ? midAction : undefined,
overAction: oneToOne ? overAction : undefined, overAction: oneToOne ? overAction : undefined,
}); });
@ -422,7 +436,7 @@ export function QuotationCreateModal({
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">마감기한</Typography> <Typography as="label" variant="label">{label('quotation.due_date')}</Typography>
<input <input
id="wizard-date" id="wizard-date"
type="datetime-local" type="datetime-local"
@ -434,7 +448,7 @@ export function QuotationCreateModal({
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">견적건명</Typography> <Typography as="label" variant="label">{label('quotation.title')}</Typography>
<Input <Input
id="wizard-title" id="wizard-title"
type="text" type="text"
@ -460,79 +474,6 @@ export function QuotationCreateModal({
emptyText="일치하는 상품이 없습니다" emptyText="일치하는 상품이 없습니다"
/> />
</div> </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> </div>
)} )}
@ -622,6 +563,73 @@ export function QuotationCreateModal({
</div> </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"> <div className="space-y-1">
<Typography as="label" variant="label">메모 (선택)</Typography> <Typography as="label" variant="label">메모 (선택)</Typography>
<textarea <textarea
@ -697,15 +705,15 @@ export function QuotationCreateModal({
type="button" type="button"
size="sm" size="sm"
onClick={() => { onClick={() => {
if (step === 1 && productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
return;
}
if (step === 1 && !isFutureLocalInput(dueDate)) { if (step === 1 && !isFutureLocalInput(dueDate)) {
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error'); showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
return; return;
} }
if (step === 1 && targetLimitExceeded) { if (step === 3 && productId && !targetReady) {
showToast('목표가 산정에 쓸 값이 없습니다 — 구매담당자 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
return;
}
if (step === 3 && targetLimitExceeded) {
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error'); showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
return; return;
} }

View File

@ -8,7 +8,7 @@ import { InfoField } from './InfoField';
import { QuotationStatusBadge } from './StatusPill'; import { QuotationStatusBadge } from './StatusPill';
import { ResultSummaryBand } from './ResultSummaryBand'; import { ResultSummaryBand } from './ResultSummaryBand';
import type { QuotationData } from '@/api/generated/model/quotationData'; import type { QuotationData } from '@/api/generated/model/quotationData';
import { useLabels } from '@/features/settings/useCompanySettings'; import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
import { import {
type Product, type Product,
type QuotationSetting, type QuotationSetting,
@ -52,6 +52,7 @@ export function DrawerHeaderCards({
collapsed = false, collapsed = false,
}: DrawerHeaderCardsProps) { }: DrawerHeaderCardsProps) {
const label = useLabels(); // 회사 설정 용어(목표 마진 등) const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const isHidden = useHiddenFields(); // 회사설정으로 감춘 가격 필드는 상품 카드에서도 제외
// 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다. // 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다.
if (collapsed) { if (collapsed) {
return ( return (
@ -83,11 +84,11 @@ export function DrawerHeaderCards({
const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-'); const won = (n?: number | null) => (n != null ? `₩${Number(n).toLocaleString()}` : '-');
const productPriceRows = currentProduct const productPriceRows = currentProduct
? [ ? [
{ label: '상품단가', value: won(currentProduct.price) }, { key: 'price', label: label('item.price'), value: won(currentProduct.price) },
{ label: '매입가', value: won(currentProduct.purchase_price) }, { key: 'purchase_price', label: label('item.purchase_price'), value: won(currentProduct.purchase_price) },
{ label: '판매가', value: won(currentProduct.selling_price) }, { key: 'selling_price', label: label('item.selling_price'), value: won(currentProduct.selling_price) },
{ label: '인터넷 최저가', value: won(currentProduct.internet_lowest_price) }, { key: 'internet_lowest_price', label: label('item.internet_lowest_price'), value: won(currentProduct.internet_lowest_price) },
] ].filter((r) => !isHidden(r.key))
: []; : [];
return ( 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"> <div className="grid grid-cols-2 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
{productPriceRows.map((row) => ( {productPriceRows.map((row) => (
<InfoField <InfoField
key={row.label} key={row.key}
label={row.label} label={row.label}
value={row.value} value={row.value}
valueClassName="font-sans truncate block" valueClassName="font-sans truncate block"

View File

@ -22,6 +22,7 @@ const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'
// 회사 설정 용어(목표 마진)를 반영해 컴포넌트 안에서 만든다. // 회사 설정 용어(목표 마진)를 반영해 컴포넌트 안에서 만든다.
const candidateSub = (marginLabel: string): Record<string, string> => ({ const candidateSub = (marginLabel: string): Record<string, string> => ({
internet: '인터넷 평균 수수료 적용', internet: '인터넷 평균 수수료 적용',
purchase: `${marginLabel} 적용`,
selling: `${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"> <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="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"> <Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
2. 다음 중 가장 작은 값 — {[ 2. 다음 중 가장 작은 값 — {[
!hiddenPrice.includes('internet_lowest_price') && '인터넷최저가×(1−수수료)', !hiddenPrice.includes('internet_lowest_price') && `${label('item.internet_lowest_price')}×(1−수수료)`,
!hiddenPrice.includes('purchase_price') && '매입가', !hiddenPrice.includes('purchase_price') && `${label('item.purchase_price')}×(1−${label('target_margin')})`,
!hiddenPrice.includes('selling_price') && `판매가×(1−${label('target_margin')})`, !hiddenPrice.includes('selling_price') && `${label('item.selling_price')}×(1−${label('target_margin')})`,
].filter(Boolean).join(' | ')} ].filter(Boolean).join(' | ')}
</Typography> </Typography>
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground"> <Typography as="p" variant="small" className="text-[10px] text-muted-foreground">

View File

@ -38,7 +38,7 @@ export function CardEffectChart({ data }: { data: CardTypeUsage[] }) {
{r.label} 평균 하락 {r.label} 평균 하락
</Typography> </Typography>
<Typography as="p" variant="small" className="font-mono text-[12px] font-bold text-foreground"> <Typography as="p" variant="small" className="font-mono text-[12px] font-bold text-foreground">
{/* TODO: 제시가 하락 델타 미배선 — 백엔드 avg_drop=0 동안 '측정 예정' */} {/* 사용 직후 유저 제시가 하락(카드 직전−직후 제시가 평균). 표본 없으면 '측정 예정'. */}
{r.avgDrop > 0 ? won(r.avgDrop) : '측정 예정'} {r.avgDrop > 0 ? won(r.avgDrop) : '측정 예정'}
</Typography> </Typography>
</div> </div>

View File

@ -58,7 +58,7 @@ export function CardTop5() {
</div> </div>
<Typography as="p" variant="caption" className="mt-1 truncate"> <Typography as="p" variant="caption" className="mt-1 truncate">
{card.isWildcard ? '와일드카드' : '협상카드'} · {card.code} · 사용 {card.usedCount}회 {card.isWildcard ? '와일드카드' : '협상카드'} · 사용 {card.usedCount}회
</Typography> </Typography>
</div> </div>
</div> </div>

View File

@ -40,7 +40,7 @@ export function StatTile({
</Typography> </Typography>
)} )}
</div> </div>
<Typography variant="caption" className="block truncate"> <Typography variant="caption" className="block break-keep">
{label} {label}
</Typography> </Typography>
</div> </div>

View File

@ -36,7 +36,7 @@
--color-ring: var(--ring); --color-ring: var(--ring);
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --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 { :root {