o2o-negosium-original/negodata/front/src/features/cards/components/CardFormSheet.tsx
Mina Choi c62b66e35b [feat] negodata: 소유권 게이팅 + 견적 낙찰 + 작성자명 표시 + 전화입력 + 카드 엑셀
- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용
- 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드
- supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:18:33 +09:00

385 lines
15 KiB
TypeScript

import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import type { Descendant } from 'slate';
import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { type NegotiationCard, type CardTab, generateCardCode } from '../types';
import { CardUsageType } from '@/api/generated/model';
import { CARD_USAGE_TYPE_LABEL, CARD_USAGE_TYPE_OPTIONS } from '@/lib/enumLabels';
import type { CardInput } from '../hooks/useCards';
import { CardScriptEditor, deserialize, serializeToText } from '../editor';
const schema = z.object({
isWildcard: z.boolean(),
isShared: z.boolean(),
usageType: z.number(),
code: z.string().trim().min(1, '카드번호는 필수 기입 사항입니다.'),
title: z.string().trim().min(1, '카드이름은 필수 기입 사항입니다.'),
editorScript: z
.custom<Descendant[]>((v) => Array.isArray(v))
.refine((v) => serializeToText(v).trim().length > 0, '스크립트를 작성해 주십시오.'),
status: z.enum(['ACTIVE', 'INACTIVE']),
triggerCondition: z.string(),
memo: z.string(),
});
type FormValues = z.infer<typeof schema>;
type CardFormSheetProps = {
open: boolean;
mode: 'create' | 'edit';
card: NegotiationCard | null; // edit 모드 초기값 출처
activeTab: CardTab; // create 시 기본 카드 종류 결정
onCreate: (input: CardInput) => Promise<void>;
onUpdate: (id: string, input: CardInput) => Promise<void>;
onDelete: (id: string, title: string) => void;
onClose: () => void;
};
function buildDefaults(
mode: 'create' | 'edit',
card: NegotiationCard | null,
activeTab: CardTab,
): FormValues {
if (mode === 'edit' && card) {
return {
isWildcard: card.isWildcard,
isShared: card.isShared,
usageType: card.usageType,
code: card.code,
title: card.title,
// 저장된 Slate JSON 우선, 없으면 레거시 평문 script 를 변수 노드로 복원.
editorScript: deserialize(card.editorScript, card.scriptPreview),
status: card.status,
triggerCondition: card.triggerCondition || '',
memo: card.memo || '',
};
}
const wild = activeTab === 'WILD';
return {
isWildcard: wild,
isShared: false, // 기본: 개인(나만) — 전체 공용은 등록 시 명시 선택
usageType: CardUsageType.COMMON, // 기본: 공통(신규·재 모두)
code: generateCardCode(wild),
title: '',
editorScript: deserialize(), // 새 빈 값(공용 상수 mutate 방지)
status: 'ACTIVE',
triggerCondition: '',
memo: '',
};
}
// 협상카드/와일드카드 등록·수정 우측 드로어 폼. react-hook-form + zod로 검증.
// 상품·협력사 폼과 동일하게 공통 Sheet 셸을 쓴다(우측 슬라이드).
// 페이지는 key={mode + card?.id}로 리마운트시켜 초기값을 주입한다.
export function CardFormSheet({
open,
mode,
card,
activeTab,
onCreate,
onUpdate,
onDelete,
onClose,
}: CardFormSheetProps) {
const {
register,
control,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: buildDefaults(mode, card, activeTab),
});
const isWildcard = watch('isWildcard');
const onValid = async (v: FormValues) => {
const input: CardInput = {
title: v.title,
code: v.code,
editorScript: v.editorScript,
status: v.status,
isWildcard: v.isWildcard,
isShared: v.isShared,
usageType: v.usageType,
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');
}
};
const title =
mode === 'create'
? isWildcard
? '신규 와일드카드 등록'
: '신규 협상카드 등록'
: isWildcard
? '와일드카드 정보 수정'
: '협상카드 정보 수정';
return (
<Sheet open={open} title={title} onClose={onClose}>
<form onSubmit={handleSubmit(onValid)} className="mt-6 space-y-4 text-xs font-mono">
<div className="grid grid-cols-1 sm: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>
{/* 작성자(등록자) — 읽기전용, 편집 시에만. 공용 카드는 작성자 없음. */}
{mode === 'edit' && (
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">작성자</Typography>
<Typography as="p" variant="small" className="text-muted-foreground">
{card?.isShared ? '공용' : card?.creatorName ?? '-'}
</Typography>
</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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* 공개 범위(scope): 개인=나만 / 전체=모두 공용(user_id NULL). 등록 시에만 결정, 수정 시 고정 */}
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">공개 범위</Typography>
<Controller
control={control}
name="isShared"
render={({ field }) => (
<Select
value={field.value ? 'ALL' : 'MINE'}
onValueChange={(v) => {
if (mode !== 'create') return; // 스코프는 등록 시에만 결정(수정 시 이동 불가)
field.onChange(v === 'ALL');
}}
>
<SelectTrigger id="form-card-scope" className="w-full" disabled={mode === 'edit'}>
<SelectValue>
{(value) => (value === 'ALL' ? '전체 (모두 공용)' : '개인 (나만)')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="MINE">개인 (나만)</SelectItem>
<SelectItem value="ALL">전체 (모두 공용)</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */}
<div className="space-y-1">
<Typography as="label" variant="small" className="font-semibold">카드 용도</Typography>
<Controller
control={control}
name="usageType"
render={({ field }) => (
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}>
<SelectTrigger id="form-card-usage-type" className="w-full">
<SelectValue>
{(value) => CARD_USAGE_TYPE_LABEL[Number(value) as CardUsageType] ?? '공통'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CARD_USAGE_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</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">
<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>
<Controller
control={control}
name="editorScript"
render={({ field }) => (
<CardScriptEditor
value={field.value}
onChange={field.onChange}
placeholder="협상 중에 상대에게 발송할 멘트 스크립트를 기술하십시오. 굵게·색상·변수 칩을 활용하세요."
/>
)}
/>
{errors.editorScript && (
<p className="text-[10px] text-rose-500">{errors.editorScript.message as string}</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 — 다른 폼 sheet와 동일: 좌측 삭제(edit), 우측 취소/저장 */}
<div className="pt-4 flex items-center justify-between gap-2 border-t border-border mt-8">
{mode === 'edit' && card && (
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
onDelete(card.id, card.title);
onClose();
}}
>
삭제
</Button>
)}
<div className="flex items-center gap-2 flex-1 justify-end">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
취소
</Button>
<Button type="submit" id="form-card-submit" size="sm" disabled={isSubmitting}>
{mode === 'create' ? '저장' : '수정'}
</Button>
</div>
</div>
</form>
</Sheet>
);
}