- 완료 요약 카드(투찰/협상 결과)에 부가정보·의견을 항상 구조로 표시, 미입력 필드는 '-' - 협상 리스트 부가정보를 완전 읽기전용으로 잠금 — 입력은 완료 챗 마무리에서만 - 어드민 협상상세(현황·챗 탭)에 협력사 의견(custom.opinion) 노출 - 협상 화면 VAT 표기를 회사설정(vat_yn) 기준으로 통일 — hidden이면 라벨 생략
127 lines
6.6 KiB
TypeScript
127 lines
6.6 KiB
TypeScript
import { useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { useMeQuery, useSaveExtraInfoMutation, getApiErrorMessage } from '@/apis'
|
|
import type { SessionField } from '@/apis/auth/auth.type'
|
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
|
|
|
// 타결 요약의 '동의' 단계 하단 액션바 = 부가정보 입력 + 동의를 한 자리에서.
|
|
// 다른 입력(가격·배송)과 같은 자리에서 받는다. proceedText = 동의 문구(누르면 저장 후 그 텍스트로 협상 종료).
|
|
// 필드 정의(session_fields)가 없으면 폼 없이 동의 버튼만 둔다. 저장 후에도 세션 목록에서 수정할 수 있다.
|
|
export function ExtraInfoBar({ proceedText }: { proceedText: string }) {
|
|
const sessionId = useChatStore((s) => s.sessionId)
|
|
const sendMessage = useChatStore((s) => s.sendMessage)
|
|
const { data: user } = useMeQuery()
|
|
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 ?? '')
|
|
|
|
// 사용자가 건드린 값만 state 로 두고, 나머지는 기존값/기본값에서 렌더마다 파생한다(초기화 effect 불필요).
|
|
const [overrides, setOverrides] = useState<Record<string, unknown>>({})
|
|
const values: Record<string, unknown> = {}
|
|
for (const f of fields) values[f.key] = overrides[f.key] ?? existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
|
|
|
|
const proceed = () => sendMessage(proceedText)
|
|
|
|
const set = (key: string, value: unknown) => setOverrides((v) => ({ ...v, [key]: value }))
|
|
|
|
// 저장 성공 후에만 동의(협상 종료)로 넘어간다 — 저장 실패 시 화면 유지.
|
|
const handleSaveAndProceed = () => {
|
|
if (save.isPending) return
|
|
const custom: Record<string, unknown> = {}
|
|
for (const f of fields) {
|
|
const v = values[f.key]
|
|
if (f.type === 'boolean') custom[f.key] = !!v
|
|
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
|
|
}
|
|
if (opinionValue.trim()) custom.opinion = opinionValue.trim()
|
|
if (Object.keys(custom).length === 0) { proceed(); return } // 입력 없음 → 저장 생략하고 종료
|
|
save.mutate(
|
|
{ sessionId, request: { custom } },
|
|
{
|
|
onSuccess: () => {
|
|
setInitData({ custom: { ...existing, ...custom } }) // 요약 카드가 방금 입력값을 즉시 반영하도록 store 갱신
|
|
proceed()
|
|
},
|
|
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
|
|
},
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<p className="mb-3 text-sm font-semibold text-neutral-90">아래 정보를 입력하고 협상을 마무리해 주세요.</p>
|
|
{/* overflow-y-auto 는 overflow-x 도 auto 로 만들어(스펙상) 인풋 focus ring 을 좌우로 잘라낸다.
|
|
p-1 로 링 여백을 주고 -m-1 로 원래 정렬(버튼과 좌우폭)을 유지한다. */}
|
|
<div className="max-h-[36vh] space-y-3 overflow-y-auto p-1 -m-1">
|
|
{fields.map((f) => (
|
|
<div key={f.key} className="flex items-center gap-3">
|
|
<label className="w-28 shrink-0 text-sm font-semibold text-neutral-80">{f.label}</label>
|
|
{f.type === 'boolean' ? (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={!!values[f.key]}
|
|
onClick={() => set(f.key, !values[f.key])}
|
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
|
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
|
|
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
|
|
}`}
|
|
/>
|
|
</button>
|
|
) : f.type === 'select' ? (
|
|
<select
|
|
value={String(values[f.key] ?? '')}
|
|
onChange={(e) => set(f.key, e.target.value)}
|
|
className="h-10 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"
|
|
>
|
|
<option value="">선택</option>
|
|
{(f.options ?? []).map((o) => (
|
|
<option key={o} value={o}>{o}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type={f.type === 'number' ? 'number' : 'text'}
|
|
value={String(values[f.key] ?? '')}
|
|
onChange={(e) => set(f.key, e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleSaveAndProceed()}
|
|
className="h-10 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"
|
|
placeholder={f.label}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
{/* 의견 — 타결·결렬 공통 필드(IMK #18). custom.opinion 저장. */}
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-sm font-semibold text-neutral-80">
|
|
의견 <span className="font-normal text-neutral-50">(선택)</span>
|
|
</label>
|
|
<textarea
|
|
value={opinionValue}
|
|
onChange={(e) => setOpinion(e.target.value)}
|
|
rows={2}
|
|
maxLength={255}
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={handleSaveAndProceed} disabled={save.isPending} className={`${PRIMARY} mt-3 w-full`}>
|
|
{save.isPending ? '저장 중…' : proceedText || '저장하고 마무리'}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const PRIMARY =
|
|
'h-[46px] min-w-[120px] rounded-xl bg-brand-600 px-6 text-sm font-bold text-white shadow-sm transition-all hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50'
|