[feat] negosium: 협상완료 부가정보 수취·표시·잠금 + VAT 표기 통일

- 완료 요약 카드(투찰/협상 결과)에 부가정보·의견을 항상 구조로 표시, 미입력 필드는 '-'
- 협상 리스트 부가정보를 완전 읽기전용으로 잠금 — 입력은 완료 챗 마무리에서만
- 어드민 협상상세(현황·챗 탭)에 협력사 의견(custom.opinion) 노출
- 협상 화면 VAT 표기를 회사설정(vat_yn) 기준으로 통일 — hidden이면 라벨 생략
This commit is contained in:
Mina Choi 2026-07-29 17:28:56 +09:00
parent 671904c762
commit 9dca78dc72
10 changed files with 115 additions and 40 deletions

View File

@ -260,6 +260,11 @@ class ChatService:
lambda s: self.user_crud.get_company_settings(s, sess.supplier_id),
)
res.labels = (settings.get("labels") or {}) if _e == ErrorType.SUCCESS and settings else {}
# 회사가 VAT(vat_yn)를 관리하지 않으면(hidden_fields) 협상 화면 VAT 표기를 숨긴다(값 null → 프론트 라벨 생략).
_hidden = (settings.get("hidden_fields") or []) if _e == ErrorType.SUCCESS and settings else []
if "vat_yn" in _hidden:
res.item_vat_yn = None
return res
async def _ensure_in_progress(self, sess, quote) -> None:

View File

@ -15,6 +15,7 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
const fields: SessionField[] = user?.sessionFields ?? []
const save = useSaveExtraInfoMutation()
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
const setInitData = useChatInitStore((s) => s.setInitData)
// 의견은 session_fields 와 무관한 공통 필드 — 타결·결렬 모두 항상 받는다. custom.opinion 에 저장.
const [opinion, setOpinion] = useState<string | null>(null)
const opinionValue = opinion ?? String(existing?.opinion ?? '')
@ -42,7 +43,10 @@ export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
save.mutate(
{ sessionId, request: { custom } },
{
onSuccess: () => proceed(),
onSuccess: () => {
setInitData({ custom: { ...existing, ...custom } }) // 요약 카드가 방금 입력값을 즉시 반영하도록 store 갱신
proceed()
},
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)

View File

@ -44,6 +44,7 @@ function ItemInfo() {
item_lead_time,
item_spec,
item_name,
item_vat_yn,
labels,
} = useChatInitStore()
// 회사 설정 라벨(companies.settings.labels)로 필드명 치환. 미설정 시 기본 라벨.
@ -73,7 +74,8 @@ function ItemInfo() {
const formatPrice = (price: number) => price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const isNewItem = !item_code && !item_price
const priceText = isNewItem ? '신규' : `${formatPrice(item_price)}`
const formattedPrice = `${priceText}(VAT별도)` // 협상 화면 상품 단가는 VAT별도 표기(IMK #11)
// VAT 표기는 회사 설정 기준(item_vat_yn). vat_yn 미관리(hidden)면 값이 비어 라벨을 생략한다.
const formattedPrice = item_vat_yn ? `${priceText}(${item_vat_yn})` : priceText
const renderRow = (title: string, data: string) => {
const displayData = data || '-'

View File

@ -1,17 +1,40 @@
import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { useMeQuery } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
interface BidSummaryProps {
itemName: string
itemCode: string
bidPrice: number
deliveryType: string
isVAT: boolean
}
// 부가정보 값 표시 포맷(불리언→예/아니오, 숫자→천단위, 그 외→문자열)
function formatFieldValue(type: SessionField['type'], value: unknown): string {
if (type === 'boolean') return value ? '예' : '아니오'
if (type === 'number') return Number(value).toLocaleString()
return String(value)
}
// 투찰 결과 요약 카드 (보고서식 final-summary)
export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }: BidSummaryProps) {
// 공급사가 마무리에서 입력한 부가정보(회사설정 session_fields + 의견)가 있으면 함께 표시한다(입력완료 시 요약 갱신).
export function BidSummary({ itemName, itemCode, bidPrice }: BidSummaryProps) {
// VAT 표기는 회사 설정 기준(item_vat_yn). vat_yn 미관리(hidden)면 비어 라벨을 생략한다.
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
const custom = useChatInitStore((s) => s.custom)
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const priceStr = (bidPrice || 0).toLocaleString()
// 부가정보(회사설정 session_fields) — 완료 요약엔 구조를 항상 노출(미입력 값은 '-')
const extraRows = fields.map((f) => {
const v = custom?.[f.key]
const has = v !== undefined && v !== null && v !== ''
return { key: f.key, label: f.label, value: has ? formatFieldValue(f.type, v) : '-' }
})
const opinion = custom?.opinion ? String(custom.opinion) : ''
return (
<div className="w-full rounded-2xl border-2 border-brand-light bg-white p-5 shadow-md">
<div className="mb-4 flex items-center gap-2">
@ -27,10 +50,14 @@ export function BidSummary({ itemName, itemCode, bidPrice, deliveryType, isVAT }
<span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{priceStr}</span>
<span className="text-neutral-70"> ({numberToKorean(bidPrice || 0)})</span>
<span className="text-neutral-70"> {isVAT ? 'VAT포함' : 'VAT별도'}</span>
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</span>
</div>
<Row label="배송형태" value={deliveryType || '-'} />
{extraRows.map((r) => (
<Row key={r.key} label={r.label} value={r.value} />
))}
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div>
</div>
)

View File

@ -1,11 +1,17 @@
import { CheckCircle2 } from 'lucide-react'
import { numberToKorean } from '@/features/chat/lib/koreanNumber'
import { formatLeadTime } from '@/features/chat/lib/format'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import type { ChatSummary } from '@/features/chat/types'
// 협상 결과 요약 카드
export function Summary({ data }: { data: ChatSummary }) {
const priceStr = (data.final_price || 0).toLocaleString()
// VAT 표기는 회사 설정 기준(item_vat_yn). vat_yn 미관리(hidden)면 비어 라벨을 생략한다.
const item_vat_yn = useChatInitStore((s) => s.item_vat_yn)
// 공급사가 마무리에서 남긴 의견(있을 때만 표시)
const custom = useChatInitStore((s) => s.custom)
const opinion = custom?.opinion ? String(custom.opinion) : ''
return (
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
@ -43,16 +49,18 @@ export function Summary({ data }: { data: ChatSummary }) {
<span className="min-w-0 flex-1 break-keep text-right text-sm">
<span className="font-bold text-[#E5484D]">{priceStr}</span>
<span className="text-neutral-70"> ({numberToKorean(data.final_price || 0)})</span>
<span className="text-neutral-70"> {data.item_isVAT ? 'VAT포함' : 'VAT별도'}</span>
{item_vat_yn ? <span className="text-neutral-70"> {item_vat_yn}</span> : null}
</span>
</div>
{/* 의견은 타결·결렬 공통 기본 필드 — 값이 없어도 항상 표시 */}
<Row label="의견" value={opinion || '-'} />
</div>
{/* 계약 / 담당 MD */}
{/* 계약 / 구매담당자 */}
<div className="mt-3 space-y-1 text-xs text-neutral-70">
<p> 기간: 협상 1 ({addOneYear(data.nego_end_date)})</p>
<p>
MD: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'}
: {data.md_name || '-'} ({data.md_phone_number || '-'}) {data.md_email || '-'}
</p>
</div>

View File

@ -23,6 +23,8 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
return init
})
const [opinion, setOpinion] = useState<string>(() => String(target.custom?.opinion ?? ''))
// 부가정보는 협상 완료(동의) 시점에 확정 — 리스트에서는 항상 읽기전용(보기). 입력은 완료 챗에서만.
const readOnly = true
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
@ -44,8 +46,11 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5">
<div>
<h3 className="text-base font-bold text-neutral-90"> </h3>
<p className="mt-0.5 text-xs text-neutral-60">{target.qt_number} · {target.item_name}</p>
<h3 className="text-base font-bold text-neutral-90"> {readOnly ? ' (보기)' : ''}</h3>
<p className="mt-0.5 text-xs text-neutral-60">
{target.qt_number} · {target.item_name}
{readOnly ? ' · 제출 후에는 수정할 수 없습니다.' : ''}
</p>
</div>
<button
type="button"
@ -66,8 +71,9 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
type="button"
role="switch"
aria-checked={!!values[f.key]}
disabled={readOnly}
onClick={() => set(f.key, !values[f.key])}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
}`}
>
@ -81,7 +87,8 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
<select
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
disabled={readOnly}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:cursor-not-allowed disabled:bg-neutral-10 disabled:text-neutral-60"
>
<option value=""></option>
{(f.options ?? []).map((o) => (
@ -93,7 +100,8 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
disabled={readOnly}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:cursor-not-allowed disabled:bg-neutral-10 disabled:text-neutral-60"
placeholder={f.label}
/>
)}
@ -108,13 +116,24 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
onChange={(e) => setOpinion(e.target.value)}
rows={2}
maxLength={255}
disabled={readOnly}
placeholder="추가로 남길 의견"
className="w-full resize-none rounded-xl border border-border bg-white px-3 py-2 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
className="w-full resize-none rounded-xl border border-border bg-white px-3 py-2 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:cursor-not-allowed disabled:bg-neutral-10 disabled:text-neutral-60"
/>
</div>
</div>
<div className="flex gap-2 border-t border-border p-5">
{readOnly ? (
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
>
</button>
) : (
<>
<button
type="button"
onClick={onClose}
@ -129,6 +148,8 @@ export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProp
>
</button>
</>
)}
</div>
</div>
</Modal>

View File

@ -59,7 +59,6 @@ function Card({
const canReject = ['협상생성', '협상중'].includes(item.session_status)
const isDone = item.session_status === '협상완료'
const enterLabel = isDone ? '결과 보기' : '협상 입장'
const hasExtra = item.custom && Object.keys(item.custom).length > 0
// 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다.
const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? ''
@ -130,7 +129,7 @@ function Card({
onClick={() => onExtraInfo(item)}
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
</button>
)}
{canReject && (

View File

@ -77,7 +77,6 @@ function Row({
const canReject = ['협상생성', '협상중'].includes(item.session_status)
const isDone = item.session_status === '협상완료'
const enterLabel = isDone ? '결과 보기' : '협상 입장'
const hasExtra = item.custom && Object.keys(item.custom).length > 0
return (
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
@ -142,7 +141,7 @@ function Row({
onClick={() => onExtraInfo(item)}
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
</button>
)}
{canReject && (

View File

@ -80,10 +80,15 @@ export function ChatTab({
// 협상완료 부가정보(sessions.custom) — 공급사가 타결 후 입력. 라벨은 회사 설정(session_fields)에서.
const { settings } = useCompanySettings();
const sessionFields = settings.session_fields ?? [];
const extraRows = (sd: SessionData | null) =>
sessionFields
.map((f) => ({ label: f.label, value: (sd?.custom as Record<string, unknown> | undefined)?.[f.key] }))
const extraRows = (sd: SessionData | null) => {
const custom = sd?.custom as Record<string, unknown> | undefined;
const rows = sessionFields
.map((f) => ({ label: f.label, value: custom?.[f.key] }))
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
// 의견은 회사설정과 무관한 내장 공통 필드(custom.opinion) — 값 있으면 항상 표시
if (custom?.opinion) rows.push({ label: '의견', value: custom.opinion });
return rows;
};
return (
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
{/* Sessions list */}

View File

@ -42,10 +42,15 @@ export function SessionsStatusTab({
const { settings } = useCompanySettings();
const sessionFields = settings.session_fields ?? [];
const [extraSession, setExtraSession] = useState<SessionView | null>(null);
const extraRows = (sv: SessionView | null) =>
sessionFields
.map((f) => ({ label: f.label, value: (sv?.custom as Record<string, unknown> | undefined)?.[f.key] }))
const extraRows = (sv: SessionView | null) => {
const custom = sv?.custom as Record<string, unknown> | undefined;
const rows = sessionFields
.map((f) => ({ label: f.label, value: custom?.[f.key] }))
.filter((r) => r.value !== undefined && r.value !== null && r.value !== '');
// 의견은 회사설정과 무관한 내장 공통 필드(custom.opinion) — 값 있으면 항상 표시
if (custom?.opinion) rows.push({ label: '의견', value: custom.opinion });
return rows;
};
const [sendingAll, setSendingAll] = useState(false);
const [sendingId, setSendingId] = useState<string | null>(null);
const [selectedWinnerId, setSelectedWinnerId] = useState<string | null>(null);