Files
sam-react-prod/src/components/clients/ClientRegistration.tsx
유병철 cfa72fe19b refactor(WEB): 폼 템플릿 통합 및 미사용 컴포넌트 정리
- ResponsiveFormTemplate → IntegratedDetailTemplate 마이그레이션
  - ClientRegistration, OrderRegistration, QuoteRegistration 완료
  - QuoteRegistrationV2 미사용 import 정리
- 미사용 컴포넌트 삭제
  - ListPageTemplate.tsx
  - ResponsiveFormTemplate.tsx
  - common/DataTable 폴더 전체 (SearchFilter 누락 export 에러 해결)
- Config 파일 추가
  - clientConfig.ts, orderConfig.ts
  - quoteConfig.ts에 edit 모드 config 추가

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 20:41:45 +09:00

466 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 거래처 등록/수정 컴포넌트
*
* IntegratedDetailTemplate 마이그레이션 (2026-01-20)
* - 데스크톱/태블릿/모바일 통합 폼 레이아웃
* - 섹션 기반 정보 입력
* - 유효성 검사 및 에러 표시
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import { Label } from '../ui/label';
import { Building2, UserCircle, Phone, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { Alert, AlertDescription } from '../ui/alert';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { clientCreateConfig, clientEditConfig } from './clientConfig';
import { FormSection } from '../organisms/FormSection';
import { FormFieldGrid } from '../organisms/FormFieldGrid';
import { FormField } from '../molecules/FormField';
import {
ClientFormData,
INITIAL_CLIENT_FORM,
ClientType,
} from '../../hooks/useClientList';
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
name: '거래처명',
businessNo: '사업자등록번호',
representative: '대표자명',
phone: '전화번호',
email: '이메일',
};
interface ClientRegistrationProps {
onBack: () => void;
onSave: (client: ClientFormData) => Promise<void>;
editingClient?: ClientFormData | null;
isLoading?: boolean;
}
// 4자리 영문+숫자 조합 코드 생성 (중복 방지를 위해 타임스탬프 기반)
const generateClientCode = (): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const timestamp = Date.now().toString(36).toUpperCase().slice(-2); // 타임스탬프 2자리
let random = '';
for (let i = 0; i < 2; i++) {
random += chars.charAt(Math.floor(Math.random() * chars.length));
}
return timestamp + random;
};
export function ClientRegistration({
onBack,
onSave,
editingClient,
isLoading = false,
}: ClientRegistrationProps) {
const [formData, setFormData] = useState<ClientFormData>(() => {
if (editingClient) {
return editingClient;
}
// 신규 등록 시 클라이언트 코드 자동 생성
return {
...INITIAL_CLIENT_FORM,
clientCode: generateClientCode(),
};
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
const isEditMode = !!editingClient;
// editingClient가 변경되면 formData 업데이트
useEffect(() => {
if (editingClient) {
setFormData(editingClient);
}
}, [editingClient]);
// 유효성 검사
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name || formData.name.length < 2) {
newErrors.name = '거래처명은 2자 이상 입력해주세요';
}
// 하이픈 제거 후 10자리 숫자 검증 (123-45-67890 또는 1234567890 허용)
const businessNoDigits = formData.businessNo.replace(/-/g, '').trim();
if (!formData.businessNo || !/^\d{10}$/.test(businessNoDigits)) {
newErrors.businessNo = '사업자등록번호는 10자리 숫자여야 합니다';
}
if (!formData.representative || formData.representative.length < 2) {
newErrors.representative = '대표자명은 2자 이상 입력해주세요';
}
// 전화번호 형식 검사 (선택적)
const phonePattern = /^[0-9-]+$/;
if (formData.phone && !phonePattern.test(formData.phone)) {
newErrors.phone = '올바른 전화번호 형식이 아닙니다';
}
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = '올바른 이메일 형식이 아닙니다';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
// 에러 초기화
setErrors({});
setIsSaving(true);
try {
await onSave(formData);
toast.success(
editingClient ? '거래처가 수정되었습니다.' : '거래처가 등록되었습니다.'
);
onBack();
} catch (error) {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [formData, editingClient, onSave, onBack]);
const handleFieldChange = (
field: keyof ClientFormData,
value: string | boolean
) => {
setFormData({ ...formData, [field]: value });
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// Config 선택
const config = isEditMode ? clientEditConfig : clientCreateConfig;
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(
() => (
<div className="space-y-6 max-w-4xl">
{/* Validation 에러 표시 */}
{Object.keys(errors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(errors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(errors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 1. 기본 정보 */}
<FormSection
title="기본 정보"
description="거래처의 기본 정보를 입력하세요"
icon={Building2}
>
<FormFieldGrid columns={2}>
<FormField
label="사업자등록번호"
required
error={errors.businessNo}
htmlFor="businessNo"
type="custom"
>
<Input
id="businessNo"
placeholder="10자리 숫자 (예: 123-45-67890)"
value={formData.businessNo}
onChange={(e) => handleFieldChange('businessNo', e.target.value)}
/>
</FormField>
<FormField
label="거래처 코드"
htmlFor="clientCode"
helpText="자동 생성됩니다"
type="custom"
>
<Input
id="clientCode"
placeholder="자동생성"
value={formData.clientCode || ''}
disabled
/>
</FormField>
</FormFieldGrid>
<FormFieldGrid columns={2}>
<FormField
label="거래처명"
required
error={errors.name}
htmlFor="name"
type="custom"
>
<Input
id="name"
placeholder="거래처명 입력"
value={formData.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
/>
</FormField>
<FormField
label="대표자명"
required
error={errors.representative}
htmlFor="representative"
type="custom"
>
<Input
id="representative"
placeholder="대표자명 입력"
value={formData.representative}
onChange={(e) =>
handleFieldChange('representative', e.target.value)
}
/>
</FormField>
</FormFieldGrid>
<FormField label="거래처 유형" required type="custom">
<RadioGroup
value={formData.clientType}
onValueChange={(value) =>
handleFieldChange('clientType', value as ClientType)
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="매입" id="type-purchase" />
<Label htmlFor="type-purchase"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="매출" id="type-sales" />
<Label htmlFor="type-sales"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="매입매출" id="type-both" />
<Label htmlFor="type-both"></Label>
</div>
</RadioGroup>
</FormField>
<FormFieldGrid columns={2}>
<FormField label="업태" htmlFor="businessType" type="custom">
<Input
id="businessType"
placeholder="제조업, 도소매업 등"
value={formData.businessType}
onChange={(e) =>
handleFieldChange('businessType', e.target.value)
}
/>
</FormField>
<FormField label="종목" htmlFor="businessItem" type="custom">
<Input
id="businessItem"
placeholder="철강, 건설 등"
value={formData.businessItem}
onChange={(e) =>
handleFieldChange('businessItem', e.target.value)
}
/>
</FormField>
</FormFieldGrid>
</FormSection>
{/* 2. 연락처 정보 */}
<FormSection
title="연락처 정보"
description="거래처의 연락처 정보를 입력하세요"
icon={Phone}
>
<FormField label="주소" htmlFor="address" type="custom">
<Input
id="address"
placeholder="주소 입력"
value={formData.address}
onChange={(e) => handleFieldChange('address', e.target.value)}
/>
</FormField>
<FormFieldGrid columns={3}>
<FormField
label="전화번호"
error={errors.phone}
htmlFor="phone"
type="custom"
>
<Input
id="phone"
placeholder="02-1234-5678"
value={formData.phone}
onChange={(e) => handleFieldChange('phone', e.target.value)}
/>
</FormField>
<FormField label="모바일" htmlFor="mobile" type="custom">
<Input
id="mobile"
placeholder="010-1234-5678"
value={formData.mobile}
onChange={(e) => handleFieldChange('mobile', e.target.value)}
/>
</FormField>
<FormField label="팩스" htmlFor="fax" type="custom">
<Input
id="fax"
placeholder="02-1234-5678"
value={formData.fax}
onChange={(e) => handleFieldChange('fax', e.target.value)}
/>
</FormField>
</FormFieldGrid>
<FormField
label="이메일"
error={errors.email}
htmlFor="email"
type="custom"
>
<Input
id="email"
type="email"
placeholder="example@company.com"
value={formData.email}
onChange={(e) => handleFieldChange('email', e.target.value)}
/>
</FormField>
</FormSection>
{/* 3. 담당자 정보 */}
<FormSection
title="담당자 정보"
description="거래처 담당자 정보를 입력하세요"
icon={UserCircle}
>
<FormFieldGrid columns={2}>
<FormField label="담당자명" htmlFor="managerName" type="custom">
<Input
id="managerName"
placeholder="담당자명 입력"
value={formData.managerName}
onChange={(e) => handleFieldChange('managerName', e.target.value)}
/>
</FormField>
<FormField label="담당자 전화" htmlFor="managerTel" type="custom">
<Input
id="managerTel"
placeholder="010-1234-5678"
value={formData.managerTel}
onChange={(e) => handleFieldChange('managerTel', e.target.value)}
/>
</FormField>
</FormFieldGrid>
<FormField label="시스템 관리자" htmlFor="systemManager" type="custom">
<Input
id="systemManager"
placeholder="시스템 관리자명"
value={formData.systemManager}
onChange={(e) =>
handleFieldChange('systemManager', e.target.value)
}
/>
</FormField>
</FormSection>
{/* 4. 기타 */}
<FormSection
title="기타 정보"
description="추가 정보를 입력하세요"
icon={FileText}
>
<FormField label="메모" htmlFor="memo" type="custom">
<Textarea
id="memo"
placeholder="메모 입력"
value={formData.memo}
onChange={(e) => handleFieldChange('memo', e.target.value)}
rows={4}
/>
</FormField>
<FormField label="상태" type="custom">
<RadioGroup
value={formData.isActive ? '활성' : '비활성'}
onValueChange={(value) =>
handleFieldChange('isActive', value === '활성')
}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="활성" id="status-active" />
<Label htmlFor="status-active"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="비활성" id="status-inactive" />
<Label htmlFor="status-inactive"></Label>
</div>
</RadioGroup>
</FormField>
</FormSection>
</div>
),
[formData, errors, handleFieldChange]
);
return (
<IntegratedDetailTemplate
config={config}
mode={isEditMode ? 'edit' : 'create'}
isLoading={isLoading}
isSubmitting={isSaving}
onBack={onBack}
onCancel={onBack}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
);
}