318 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|