- 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용 - 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드 - supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
385 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|