o2o-negosium-original/frontend/src/features/chat/components/templates/ExtraInfoForm.tsx

110 lines
4.6 KiB
TypeScript

import { useEffect, useState } from 'react'
import { CheckCircle2 } from 'lucide-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'
// 협상 타결(Summary) 직후 부가정보 입력 폼. 필드 정의(session_fields)는 회사 설정(/me)에서 온다.
// 저장하면 sessions.custom 에 기록되고 negodata 견적상세에 표시된다. 정의가 없으면 렌더하지 않는다.
export function ExtraInfoForm() {
const sessionId = useChatStore((s) => s.sessionId)
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const save = useSaveExtraInfoMutation()
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
const [values, setValues] = useState<Record<string, unknown>>({})
const [inited, setInited] = useState(false)
const [saved, setSaved] = useState(false)
// fields(회사 설정)와 기존값(sessions.custom)이 준비된 첫 시점에 프리필 — 이후 사용자 편집은 보존.
useEffect(() => {
if (inited || fields.length === 0) return
const init: Record<string, unknown> = {}
for (const f of fields) init[f.key] = existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
setValues(init)
setInited(true)
}, [inited, fields, existing])
if (fields.length === 0) return null
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
const handleSave = () => {
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
}
save.mutate(
{ sessionId, request: { custom } },
{
onSuccess: () => {
setSaved(true)
toast.success('부가정보가 저장되었습니다.')
},
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)
}
return (
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<CheckCircle2 className="size-5 text-brand-600" />
<h3 className="text-sm font-bold text-neutral-90">부가정보 입력</h3>
</div>
<p className="text-sm leading-relaxed text-neutral-70 break-keep">
협상이 완료되었습니다. 아래 정보를 입력해 주세요. (저장 후에도 세션 목록에서 수정할 수 있습니다.)
</p>
<div className="mt-4 space-y-4">
{fields.map((f) => (
<div key={f.key} className="space-y-1.5">
<label className="block 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])}
disabled={saved}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${
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>
) : (
<input
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
disabled={saved}
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:bg-neutral-10"
placeholder={f.label}
/>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleSave}
disabled={save.isPending || saved}
className="mt-4 h-11 w-full rounded-xl bg-brand-600 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
>
{saved ? '저장 완료' : save.isPending ? '저장 중…' : '부가정보 저장'}
</button>
</div>
)
}