Files
sam-react-prod/src/components/accounting/VendorManagement/VendorDetail.tsx
유병철 f6551c7e8b feat(WEB): 전체 페이지 ?mode= URL 네비게이션 패턴 적용
- 등록(?mode=new), 상세(?mode=view), 수정(?mode=edit) URL 패턴 일괄 적용
- 중복 패턴 제거: /edit?mode=edit → ?mode=edit (16개 파일)
- 제목 일관성: {기능} 등록/상세/수정 패턴 적용
- 검수 체크리스트 문서 추가 (79개 페이지)
- UniversalListPage, IntegratedDetailTemplate 공통 컴포넌트 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:27:43 +09:00

684 lines
24 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.

'use client';
import { useState, useCallback, useEffect } from 'react';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useRouter } from 'next/navigation';
import { Plus, X } from 'lucide-react';
import { toast } from 'sonner';
import { getClientById, createClient, updateClient, deleteClient } from './actions';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { vendorConfig } from './vendorConfig';
import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal';
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
businessNumber: '사업자등록번호',
vendorName: '거래처명',
category: '거래처 유형',
};
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
// 새 입력 컴포넌트
import { PhoneInput } from '@/components/ui/phone-input';
import { BusinessNumberInput } from '@/components/ui/business-number-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import { NumberInput } from '@/components/ui/number-input';
import type {
Vendor,
VendorMemo,
} from './types';
import {
VENDOR_CATEGORY_SELECTOR_OPTIONS,
CREDIT_RATING_SELECTOR_OPTIONS,
TRANSACTION_GRADE_SELECTOR_OPTIONS,
BAD_DEBT_STATUS_SELECTOR_OPTIONS,
PAYMENT_DAY_OPTIONS,
BANK_OPTIONS,
} from './types';
interface VendorDetailProps {
mode: 'view' | 'edit' | 'new';
vendorId?: string;
/** URL 쿼리 파라미터로 전달된 모달 타입 (예: 'credit') */
openModal?: string | null;
}
// 빈 Vendor 데이터 (신규 등록용)
const getEmptyVendor = (): Omit<Vendor, 'id' | 'createdAt' | 'updatedAt'> => ({
vendorCode: '',
businessNumber: '',
vendorName: '',
representativeName: '',
category: 'both',
businessType: '',
businessCategory: '',
zipCode: '',
address1: '',
address2: '',
phone: '',
mobile: '',
fax: '',
email: '',
managerName: '',
managerPhone: '',
systemManager: '',
logoUrl: '',
purchasePaymentDay: 10,
salesPaymentDay: 15,
creditRating: 'AAA',
transactionGrade: 'A',
taxInvoiceEmail: '',
bankName: 'none',
accountNumber: '',
accountHolder: '',
outstandingAmount: 0,
overdueAmount: 0,
overdueDays: 0,
unpaidAmount: 0,
badDebtAmount: 0,
badDebtStatus: 'none',
overdueToggle: false,
badDebtToggle: false,
memos: [],
});
export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// IntegratedDetailTemplate 모드 변환
const templateMode = isNewMode ? 'create' : mode;
// 폼 데이터
const [formData, setFormData] = useState<Omit<Vendor, 'id' | 'createdAt' | 'updatedAt'> | Vendor>(getEmptyVendor());
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// API에서 데이터 로드 (view/edit 모드)
useEffect(() => {
const loadVendor = async () => {
if (!vendorId || isNewMode) return;
setIsLoading(true);
try {
const data = await getClientById(vendorId);
if (data) {
setFormData(data);
} else {
toast.error('거래처 정보를 불러오는데 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
loadVendor();
}, [vendorId, isNewMode]);
// Daum 우편번호 서비스
const { openPostcode } = useDaumPostcode({
onComplete: (result) => {
setFormData(prev => ({
...prev,
zipCode: result.zonecode,
address1: result.address,
}));
},
});
// Validation 에러 상태
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// 새 메모 입력
const [newMemo, setNewMemo] = useState('');
// 신용분석 모달
const [isCreditModalOpen, setIsCreditModalOpen] = useState(false);
// URL 쿼리 파라미터로 모달 자동 오픈 (알람에서 접근 시)
useEffect(() => {
if (openModal === 'credit' && !isLoading) {
setIsCreditModalOpen(true);
}
}, [openModal, isLoading]);
// Validation 함수
const validateForm = useCallback(() => {
const errors: Record<string, string> = {};
if (!formData.businessNumber?.trim()) {
errors.businessNumber = '사업자등록번호를 입력해주세요';
}
if (!formData.vendorName?.trim()) {
errors.vendorName = '거래처명을 입력해주세요';
}
if (!formData.category) {
errors.category = '거래처 유형을 선택해주세요';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
}, [formData.businessNumber, formData.vendorName, formData.category]);
// 필드 변경 핸들러
const handleChange = useCallback((field: string, value: string | number | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 메모 추가 핸들러
const handleAddMemo = useCallback(() => {
if (!newMemo.trim()) return;
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
const timeStr = now.toTimeString().slice(0, 5);
const memo: VendorMemo = {
id: String(Date.now()),
content: `${dateStr} ${timeStr} [사용자] ${newMemo}`,
createdAt: now.toISOString(),
};
setFormData(prev => ({
...prev,
memos: [...prev.memos, memo],
}));
setNewMemo('');
}, [newMemo]);
// 메모 삭제 핸들러
const handleDeleteMemo = useCallback((memoId: string) => {
setFormData(prev => ({
...prev,
memos: prev.memos.filter(m => m.id !== memoId),
}));
}, []);
// 저장 핸들러 (IntegratedDetailTemplate용)
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
window.scrollTo({ top: 0, behavior: 'smooth' });
return { success: false, error: '입력 내용을 확인해주세요.' };
}
try {
const result = isNewMode
? await createClient(formData)
: await updateClient(vendorId!, formData);
if (result.success) {
router.refresh();
return { success: true };
} else {
return { success: false, error: result.error || '저장에 실패했습니다.' };
}
} catch {
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}, [formData, validateForm, isNewMode, vendorId, router]);
// 삭제 핸들러 (IntegratedDetailTemplate용)
const handleDelete = useCallback(async () => {
if (!vendorId) return { success: false, error: 'ID가 없습니다.' };
try {
const result = await deleteClient(vendorId);
if (result.success) {
router.refresh();
return { success: true };
} else {
return { success: false, error: result.error || '삭제에 실패했습니다.' };
}
} catch {
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}, [vendorId, router]);
// 입력 필드 렌더링 헬퍼
const renderField = (
label: string,
field: string,
value: string | number,
options?: {
required?: boolean;
type?: 'text' | 'tel' | 'email' | 'number';
placeholder?: string;
disabled?: boolean;
}
) => {
const { required, type = 'text', placeholder, disabled } = options || {};
return (
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">
{label} {required && <span className="text-red-500">*</span>}
</Label>
<Input
type={type}
value={value}
onChange={(e) => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
placeholder={placeholder}
disabled={isViewMode || disabled}
className="bg-white"
/>
</div>
);
};
// 셀렉트 필드 렌더링 헬퍼
const renderSelectField = (
label: string,
field: string,
value: string,
options: { value: string; label: string }[],
required?: boolean
) => {
return (
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">
{label} {required && <span className="text-red-500">*</span>}
</Label>
<Select
value={value}
onValueChange={(val) => handleChange(field, val)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
// 폼 콘텐츠 렌더링 (View/Edit 공통)
const renderFormContent = () => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(validationErrors).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(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).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>
)}
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 사업자등록번호 - BusinessNumberInput 사용 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</Label>
<BusinessNumberInput
value={formData.businessNumber}
onChange={(value) => handleChange('businessNumber', value)}
placeholder="000-00-00000"
disabled={isViewMode}
showValidation
error={!!validationErrors.businessNumber}
/>
</div>
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성' })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
{renderField('대표자명', 'representativeName', formData.representativeName)}
{renderSelectField('거래처 유형', 'category', formData.category, VENDOR_CATEGORY_SELECTOR_OPTIONS, true)}
{renderField('업태', 'businessType', formData.businessType)}
{renderField('업종', 'businessCategory', formData.businessCategory)}
</CardContent>
</Card>
{/* 연락처 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 주소 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex gap-2">
<Input
value={formData.zipCode}
onChange={(e) => handleChange('zipCode', e.target.value)}
placeholder="우편번호"
disabled={isViewMode}
className="w-[120px] bg-white"
/>
<Button variant="outline" disabled={isViewMode} onClick={openPostcode} className="shrink-0">
</Button>
</div>
<Input
value={formData.address1}
onChange={(e) => handleChange('address1', e.target.value)}
placeholder="기본주소"
disabled={isViewMode}
className="bg-white"
/>
<Input
value={formData.address2}
onChange={(e) => handleChange('address2', e.target.value)}
placeholder="상세주소"
disabled={isViewMode}
className="bg-white"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 전화번호 - PhoneInput 사용 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<PhoneInput
value={formData.phone}
onChange={(value) => handleChange('phone', value)}
placeholder="02-0000-0000"
disabled={isViewMode}
/>
</div>
{/* 모바일 - PhoneInput 사용 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<PhoneInput
value={formData.mobile}
onChange={(value) => handleChange('mobile', value)}
placeholder="010-0000-0000"
disabled={isViewMode}
/>
</div>
{/* 팩스 - PhoneInput 사용 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<PhoneInput
value={formData.fax}
onChange={(value) => handleChange('fax', value)}
placeholder="02-0000-0000"
disabled={isViewMode}
/>
</div>
{renderField('이메일', 'email', formData.email, { type: 'email' })}
</div>
</CardContent>
</Card>
{/* 담당자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('담당자명', 'managerName', formData.managerName)}
{/* 담당자 전화 - PhoneInput 사용 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<PhoneInput
value={formData.managerPhone}
onChange={(value) => handleChange('managerPhone', value)}
placeholder="010-0000-0000"
disabled={isViewMode}
/>
</div>
{renderField('시스템 관리자', 'systemManager', formData.systemManager)}
</CardContent>
</Card>
{/* 회사 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 회사 로고 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<p className="text-sm text-gray-500">750 X 250px, 10MB PNG, JPEG, GIF</p>
{!isViewMode && (
<Button variant="outline" className="mt-2">
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('매입 결제일', 'purchasePaymentDay', String(formData.purchasePaymentDay), PAYMENT_DAY_OPTIONS)}
{renderSelectField('매출 결제일', 'salesPaymentDay', String(formData.salesPaymentDay), PAYMENT_DAY_OPTIONS)}
</div>
</CardContent>
</Card>
{/* 신용/거래 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">/ </CardTitle>
{isViewMode && (
<Button
variant="outline"
size="sm"
onClick={() => setIsCreditModalOpen(true)}
>
</Button>
)}
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('신용등급', 'creditRating', formData.creditRating, CREDIT_RATING_SELECTOR_OPTIONS)}
{renderSelectField('거래등급', 'transactionGrade', formData.transactionGrade, TRANSACTION_GRADE_SELECTOR_OPTIONS)}
{renderField('세금계산서 이메일', 'taxInvoiceEmail', formData.taxInvoiceEmail, { type: 'email' })}
{renderSelectField('입금계좌 은행', 'bankName', formData.bankName, BANK_OPTIONS)}
{renderField('계좌', 'accountNumber', formData.accountNumber, { placeholder: '계좌번호' })}
{renderField('예금주', 'accountHolder', formData.accountHolder)}
</CardContent>
</Card>
{/* 추가 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 미수금 - CurrencyInput 사용 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<CurrencyInput
value={formData.outstandingAmount}
onChange={(value) => handleChange('outstandingAmount', value ?? 0)}
disabled={isViewMode}
className="bg-white"
/>
</div>
{/* 연체 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700"></Label>
<Switch
checked={formData.overdueToggle}
onCheckedChange={(checked) => handleChange('overdueToggle', checked)}
disabled={isViewMode}
className="data-[state=checked]:bg-orange-500"
/>
</div>
<NumberInput
value={formData.overdueDays}
onChange={(value) => handleChange('overdueDays', value ?? 0)}
disabled={isViewMode}
className="bg-white"
placeholder="일"
suffix="일"
min={0}
/>
</div>
{/* 미지급 - CurrencyInput 사용 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<CurrencyInput
value={formData.unpaidAmount}
onChange={(value) => handleChange('unpaidAmount', value ?? 0)}
disabled={isViewMode}
className="bg-white"
/>
</div>
{/* 악성채권 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700"></Label>
<Switch
checked={formData.badDebtToggle}
onCheckedChange={(checked) => handleChange('badDebtToggle', checked)}
disabled={isViewMode}
className="data-[state=checked]:bg-orange-500"
/>
</div>
<Select
value={formData.badDebtStatus}
onValueChange={(val) => handleChange('badDebtStatus', val)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="-" />
</SelectTrigger>
<SelectContent>
{BAD_DEBT_STATUS_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 메모 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 메모 입력 */}
{!isViewMode && (
<div className="flex gap-2">
<Textarea
value={newMemo}
onChange={(e) => setNewMemo(e.target.value)}
placeholder="메모를 입력하세요..."
className="flex-1 bg-white"
rows={2}
/>
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
)}
{/* 메모 리스트 */}
{formData.memos.length > 0 ? (
<div className="space-y-2">
{formData.memos.map((memo) => (
<div
key={memo.id}
className="flex items-start justify-between p-3 bg-gray-50 rounded-lg"
>
<p className="text-sm text-gray-700 flex-1">{memo.content}</p>
{!isViewMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={() => handleDeleteMemo(memo.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4"> .</p>
)}
</CardContent>
</Card>
</div>
);
// config 동적 수정 (등록 모드일 때 타이틀 변경)
// IntegratedDetailTemplate: create → "{title} 등록", view → "{title}", edit → "{title} 수정"
// view 모드에서 "거래처 상세"로 표시하려면 직접 설정 필요
const dynamicConfig = {
...vendorConfig,
title: isViewMode ? '거래처 상세' : '거래처',
description: isNewMode ? '새로운 거래처를 등록합니다' : '거래처 상세 정보 및 신용등급을 관리합니다',
actions: {
...vendorConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={formData as unknown as Record<string, unknown>}
itemId={vendorId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={vendorId ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 신용분석 모달 */}
<CreditAnalysisModal
open={isCreditModalOpen}
onOpenChange={setIsCreditModalOpen}
data={MOCK_CREDIT_DATA}
/>
</>
);
}