o2o-negosium-original/negodata/front/src/features/cards/components/CardFormModal.tsx

318 lines
13 KiB
TypeScript

import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { X, Layers } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { type NegotiationCard, type CardTab, generateCardCode } from '../types';
import type { CardInput } from '../hooks/useCards';
const schema = z.object({
isWildcard: z.boolean(),
code: z.string().trim().min(1, '카드번호는 필수 기입 사항입니다.'),
title: z.string().trim().min(1, '카드이름은 필수 기입 사항입니다.'),
scriptPreview: z.string().trim().min(1, '스크립트를 작성해 주십시오.'),
status: z.enum(['ACTIVE', 'INACTIVE']),
triggerCondition: z.string(),
memo: z.string(),
});
type FormValues = z.infer<typeof schema>;
type CardFormModalProps = {
open: boolean;
mode: 'create' | 'edit';
card: NegotiationCard | null; // edit 모드 초기값 출처
activeTab: CardTab; // create 시 기본 카드 종류 결정
onCreate: (input: CardInput) => Promise<void>;
onUpdate: (id: string, input: CardInput) => Promise<void>;
onClose: () => void;
};
function buildDefaults(
mode: 'create' | 'edit',
card: NegotiationCard | null,
activeTab: CardTab,
): FormValues {
if (mode === 'edit' && card) {
return {
isWildcard: card.isWildcard,
code: card.code,
title: card.title,
scriptPreview: card.scriptPreview,
status: card.status,
triggerCondition: card.triggerCondition || '',
memo: card.memo || '',
};
}
const wild = activeTab === 'WILD';
return {
isWildcard: wild,
code: generateCardCode(wild),
title: '',
scriptPreview: '',
status: 'ACTIVE',
triggerCondition: '',
memo: '',
};
}
// 협상카드/와일드카드 등록·수정 모달. react-hook-form + zod로 검증.
// 페이지는 key={mode + card?.id}로 리마운트시켜 초기값을 주입한다.
export function CardFormModal({
open,
mode,
card,
activeTab,
onCreate,
onUpdate,
onClose,
}: CardFormModalProps) {
const {
register,
control,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: buildDefaults(mode, card, activeTab),
});
const isWildcard = watch('isWildcard');
if (!open) return null;
const onValid = async (v: FormValues) => {
const input: CardInput = {
title: v.title,
code: v.code,
scriptPreview: v.scriptPreview,
status: v.status,
isWildcard: v.isWildcard,
triggerCondition: v.triggerCondition,
memo: v.memo,
};
const kind = v.isWildcard ? '와일드카드' : '협상카드';
try {
if (mode === 'create') {
await onCreate(input);
showToast(`${kind}가 추가되었습니다.`, 'success');
} else if (card) {
await onUpdate(card.id, input);
showToast('정보가 수정되었습니다.', 'success');
}
onClose();
} catch (err) {
showToast(err instanceof Error ? err.message : `${kind} 저장 실패`, 'error');
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4">
<div className="w-full max-w-lg bg-card border border-border rounded-lg shadow-2xl p-6 overflow-y-auto max-h-[90vh] animate-scale-up font-mono text-xs">
<div className="flex items-center justify-between pb-4 border-b border-border mb-4">
<div className="flex items-center gap-2">
<Layers className="text-foreground" size={16} />
<Typography variant="h3" className="text-sm">
{mode === 'create'
? isWildcard
? '신규 와일드카드 등록'
: '신규 협상카드 등록'
: isWildcard
? '와일드카드 정보 수정'
: '협상카드 정보 수정'}
</Typography>
</div>
<button
onClick={onClose}
className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer"
>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit(onValid)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{/* Card Type Selection */}
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">카드 종류</Typography>
<Controller
control={control}
name="isWildcard"
render={({ field }) => (
<Select
value={field.value ? 'WILD' : 'CARD'}
onValueChange={(v) => {
// 카드 종류는 등록 시에만 정한다(수정 시 테이블 이동 불가 → 고정).
if (mode !== 'create') return;
const wild = v === 'WILD';
field.onChange(wild);
setValue('code', generateCardCode(wild));
}}
>
<SelectTrigger id="form-card-is-wildcard" className="w-full" disabled={mode === 'edit'}>
<SelectValue>
{(value) => (value === 'WILD' ? '와일드카드' : '일반 협상카드')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="CARD">일반 협상카드</SelectItem>
<SelectItem value="WILD">와일드카드</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{/* Code */}
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">카드번호</Typography>
<Input
id="form-card-code"
type="text"
{...register('code')}
placeholder="CARD-XXX 또는 WILD-XXX"
/>
{errors.code && <p className="text-[10px] text-rose-500">{errors.code.message}</p>}
</div>
{/* Status */}
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold block mb-1">협상 적용 여부</Typography>
{isWildcard ? (
<Controller
control={control}
name="status"
render={({ field }) => (
<div className="flex items-center gap-3 p-2 bg-muted/30 border border-border rounded-md h-[34px]">
<button
id="form-card-status-switch"
type="button"
role="switch"
aria-checked={field.value === 'ACTIVE'}
onClick={() => field.onChange(field.value === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE')}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-all duration-200 focus:outline-none ${
field.value === 'ACTIVE' ? 'bg-emerald-600 dark:bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-700'
}`}
>
<span
className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow transition-all duration-200 ${
field.value === 'ACTIVE' ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
<span className="text-[11px] font-bold text-foreground">
{field.value === 'ACTIVE' ? '적용 승인' : '적용 대기'}
</span>
</div>
)}
/>
) : (
<div className="flex items-center gap-3 p-2 bg-muted/60 border border-border/60 rounded-md h-[34px] cursor-not-allowed select-none">
<button
type="button"
disabled
className="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent transition-all duration-200 bg-blue-400/40 dark:bg-blue-600/30 cursor-not-allowed"
>
<span className="pointer-events-none block h-4 w-4 rounded-full bg-white/70 dark:bg-zinc-400 translate-x-4" />
</button>
<span className="text-[11px] text-muted-foreground font-semibold">상시 항상 적용</span>
</div>
)}
</div>
</div>
{/* Title */}
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">카드이름</Typography>
<Input
id="form-card-title"
type="text"
{...register('title')}
placeholder="예: 최우수 등급 부여 카드"
/>
{errors.title && <p className="text-[10px] text-rose-500">{errors.title.message}</p>}
</div>
{/* Script */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Typography as="label" variant="small" className="font-bold flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
스크립트
</Typography>
<div className="text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded border border-border">
치환 변수 사용: {'{target_price}'}, {'{partner_name}'}
</div>
</div>
<div className="border border-border rounded-md overflow-hidden bg-background focus-within:border-foreground/30 transition-all">
<div className="bg-muted/45 px-3 py-1.5 border-b border-border flex items-center justify-between text-[10px] text-muted-foreground select-none">
<span>에디터</span>
</div>
<textarea
id="form-card-script"
{...register('scriptPreview')}
rows={3}
className="w-full p-3 bg-transparent text-xs leading-relaxed resize-none focus:outline-none font-sans border-none"
placeholder="협상 중에 상대에게 발송할 멘트 스크립트를 기술하십시오."
/>
</div>
{errors.scriptPreview && <p className="text-[10px] text-rose-500">{errors.scriptPreview.message}</p>}
</div>
{/* Wildcard-only fields */}
{isWildcard && (
<div className="space-y-4 p-3 bg-muted/40 rounded border border-border">
<Typography variant="label" className="font-bold text-[10px] block">와일드카드 세부조항</Typography>
<div className="space-y-3 font-mono">
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">사용 조건</Typography>
<Input
id="form-card-trigger"
type="text"
{...register('triggerCondition')}
placeholder="예: 원부자재 시세가 계약일 대조 3.5% 상회 시"
/>
</div>
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">메모</Typography>
<Input
id="form-card-memo"
type="text"
{...register('memo')}
placeholder="예: 특정 원재료 포함 입찰에만 적용"
/>
</div>
</div>
</div>
)}
{/* Footer */}
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8">
<button
type="button"
onClick={onClose}
className="flex-1 py-1.5 border border-border rounded text-foreground cursor-pointer text-center"
>
취소
</button>
<button
type="submit"
id="form-card-submit"
className="flex-1 py-1.5 bg-primary text-primary-foreground font-bold rounded hover:opacity-95 cursor-pointer text-center"
>
{mode === 'create' ? '저장' : '수정'}
</button>
</div>
</form>
</div>
</div>
);
}