refactor(WEB): V2 파일 통합, store 구조 정리 및 대시보드 개선
- V2 컴포넌트를 원본에 통합 후 V2 파일 삭제 (InspectionModal, BillDetail, ContractDocumentModal, LaborDetailClient, PricingDetailClient, QuoteRegistration) - store → stores 디렉토리 이동 및 favoritesStore 추가 - dashboard_type3~5 추가 및 기존 대시보드 차트/훅 분리 - Sidebar 리팩토링 및 HeaderFavoritesBar 추가 - DashboardSwitcher 컴포넌트 추가 - 백업 파일(.v1-backup) 및 불필요 코드 정리 - InspectionPreviewModal 레이아웃 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
} from './types';
|
||||
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
|
||||
|
||||
// ===== 새 훅 import =====
|
||||
import { useDetailData, useCRUDHandlers } from '@/hooks';
|
||||
|
||||
// ===== Props =====
|
||||
interface BillDetailProps {
|
||||
billId: string;
|
||||
@@ -46,157 +49,199 @@ interface ClientOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
|
||||
interface BillFormData {
|
||||
billNumber: string;
|
||||
billType: BillType;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: BillStatus;
|
||||
note: string;
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM_DATA: BillFormData = {
|
||||
billNumber: '',
|
||||
billType: 'received',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
|
||||
// ===== 폼 상태 =====
|
||||
const [billNumber, setBillNumber] = useState('');
|
||||
const [billType, setBillType] = useState<BillType>('received');
|
||||
const [vendorId, setVendorId] = useState('');
|
||||
const [amount, setAmount] = useState(0);
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [maturityDate, setMaturityDate] = useState('');
|
||||
const [status, setStatus] = useState<BillStatus>('stored');
|
||||
const [note, setNote] = useState('');
|
||||
const [installments, setInstallments] = useState<InstallmentRecord[]>([]);
|
||||
// ===== 폼 상태 (통합된 단일 state) =====
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
|
||||
|
||||
// ===== 초기 데이터 로드 (거래처 + 어음 상세 병렬) =====
|
||||
// ===== 폼 필드 업데이트 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
const isEditMode = billId && billId !== 'new';
|
||||
setIsLoading(!!isEditMode);
|
||||
|
||||
const [clientsResult, billResult] = await Promise.all([
|
||||
getClients(),
|
||||
isEditMode ? getBill(billId) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// 거래처 목록
|
||||
if (clientsResult.success && clientsResult.data) {
|
||||
setClients(clientsResult.data.map(c => ({ id: String(c.id), name: c.name })));
|
||||
async function loadClients() {
|
||||
const result = await getClients();
|
||||
if (result.success && result.data) {
|
||||
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
|
||||
}
|
||||
}
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// 어음 상세
|
||||
if (billResult) {
|
||||
if (billResult.success && billResult.data) {
|
||||
const data = billResult.data;
|
||||
setBillNumber(data.billNumber);
|
||||
setBillType(data.billType);
|
||||
setVendorId(data.vendorId);
|
||||
setAmount(data.amount);
|
||||
setIssueDate(data.issueDate);
|
||||
setMaturityDate(data.maturityDate);
|
||||
setStatus(data.status);
|
||||
setNote(data.note);
|
||||
setInstallments(data.installments);
|
||||
} else {
|
||||
toast.error(billResult.error || '어음 정보를 불러올 수 없습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
}
|
||||
}
|
||||
// ===== 새 훅: useDetailData로 데이터 로딩 =====
|
||||
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
|
||||
const fetchBillWrapper = useCallback(
|
||||
(id: string | number) => getBill(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
const {
|
||||
data: billData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
} = useDetailData<BillRecord>(
|
||||
billId !== 'new' ? billId : null,
|
||||
fetchBillWrapper,
|
||||
{ skip: isNewMode }
|
||||
);
|
||||
|
||||
loadInitialData();
|
||||
}, [billId, router]);
|
||||
// ===== 데이터 로드 시 폼에 반영 =====
|
||||
useEffect(() => {
|
||||
if (billData) {
|
||||
setFormData({
|
||||
billNumber: billData.billNumber,
|
||||
billType: billData.billType,
|
||||
vendorId: billData.vendorId,
|
||||
amount: billData.amount,
|
||||
issueDate: billData.issueDate,
|
||||
maturityDate: billData.maturityDate,
|
||||
status: billData.status,
|
||||
note: billData.note,
|
||||
installments: billData.installments,
|
||||
});
|
||||
}
|
||||
}, [billData]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 유효성 검사
|
||||
if (!billNumber.trim()) {
|
||||
toast.error('어음번호를 입력해주세요.');
|
||||
return { success: false, error: '어음번호를 입력해주세요.' };
|
||||
// ===== 로드 에러 처리 =====
|
||||
useEffect(() => {
|
||||
if (loadError) {
|
||||
toast.error(loadError);
|
||||
router.push('/ko/accounting/bills');
|
||||
}
|
||||
if (!vendorId) {
|
||||
toast.error('거래처를 선택해주세요.');
|
||||
return { success: false, error: '거래처를 선택해주세요.' };
|
||||
}, [loadError, router]);
|
||||
|
||||
// ===== 유효성 검사 함수 =====
|
||||
const validateForm = useCallback((): { valid: boolean; error?: string } => {
|
||||
if (!formData.billNumber.trim()) {
|
||||
return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
}
|
||||
if (amount <= 0) {
|
||||
toast.error('금액을 입력해주세요.');
|
||||
return { success: false, error: '금액을 입력해주세요.' };
|
||||
if (!formData.vendorId) {
|
||||
return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (!issueDate) {
|
||||
toast.error('발행일을 입력해주세요.');
|
||||
return { success: false, error: '발행일을 입력해주세요.' };
|
||||
if (formData.amount <= 0) {
|
||||
return { valid: false, error: '금액을 입력해주세요.' };
|
||||
}
|
||||
if (!maturityDate) {
|
||||
toast.error('만기일을 입력해주세요.');
|
||||
return { success: false, error: '만기일을 입력해주세요.' };
|
||||
if (!formData.issueDate) {
|
||||
return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.maturityDate) {
|
||||
return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 차수 유효성 검사
|
||||
for (let i = 0; i < installments.length; i++) {
|
||||
const inst = installments[i];
|
||||
for (let i = 0; i < formData.installments.length; i++) {
|
||||
const inst = formData.installments[i];
|
||||
if (!inst.date) {
|
||||
const errorMsg = `차수 ${i + 1}번의 일자를 입력해주세요.`;
|
||||
toast.error(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
|
||||
}
|
||||
if (inst.amount <= 0) {
|
||||
const errorMsg = `차수 ${i + 1}번의 금액을 입력해주세요.`;
|
||||
toast.error(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}, [formData]);
|
||||
|
||||
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
|
||||
const updateBillWrapper = useCallback(
|
||||
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
|
||||
[]
|
||||
);
|
||||
|
||||
const deleteBillWrapper = useCallback(
|
||||
(id: string | number) => deleteBill(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
|
||||
const {
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleDelete: crudDelete,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
|
||||
onCreate: createBill,
|
||||
onUpdate: updateBillWrapper,
|
||||
onDelete: deleteBillWrapper,
|
||||
successRedirect: '/ko/accounting/bills',
|
||||
successMessages: {
|
||||
create: '어음이 등록되었습니다.',
|
||||
update: '어음이 수정되었습니다.',
|
||||
delete: '어음이 삭제되었습니다.',
|
||||
},
|
||||
// 수정 성공 시 view 모드로 이동
|
||||
disableRedirect: !isNewMode,
|
||||
onSuccess: (action) => {
|
||||
if (action === 'update') {
|
||||
router.push(`/ko/accounting/bills/${billId}?mode=view`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 유효성 검사
|
||||
const validation = validateForm();
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error!);
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
const billData: Partial<BillRecord> = {
|
||||
billNumber,
|
||||
billType,
|
||||
vendorId,
|
||||
vendorName: clients.find(c => c.id === vendorId)?.name || '',
|
||||
amount,
|
||||
issueDate,
|
||||
maturityDate,
|
||||
status,
|
||||
note,
|
||||
installments,
|
||||
...formData,
|
||||
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
|
||||
};
|
||||
|
||||
let result;
|
||||
if (isNewMode) {
|
||||
result = await createBill(billData);
|
||||
return handleCreate(billData);
|
||||
} else {
|
||||
result = await updateBill(billId, billData);
|
||||
return handleUpdate(billId, billData);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(isNewMode ? '어음이 등록되었습니다.' : '어음이 수정되었습니다.');
|
||||
if (isNewMode) {
|
||||
router.push('/ko/accounting/bills');
|
||||
} else {
|
||||
router.push(`/ko/accounting/bills/${billId}?mode=view`);
|
||||
}
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
return { success: false, error: result.error || '저장에 실패했습니다.' };
|
||||
}
|
||||
}, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode, clients]);
|
||||
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
const result = await deleteBill(billId);
|
||||
return crudDelete(billId);
|
||||
}, [billId, crudDelete]);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('어음이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
return { success: false, error: result.error || '삭제에 실패했습니다.' };
|
||||
}
|
||||
}, [billId, router]);
|
||||
|
||||
// ===== 차수 추가 =====
|
||||
// ===== 차수 관리 핸들러 =====
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
const newInstallment: InstallmentRecord = {
|
||||
id: `inst-${Date.now()}`,
|
||||
@@ -204,23 +249,37 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
setInstallments(prev => [...prev, newInstallment]);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, newInstallment],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 차수 삭제 =====
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setInstallments(prev => prev.filter(inst => inst.id !== id));
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 차수 업데이트 =====
|
||||
const handleUpdateInstallment = useCallback((id: string, field: keyof InstallmentRecord, value: string | number) => {
|
||||
setInstallments(prev => prev.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
));
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 상태 옵션 (구분에 따라 변경) =====
|
||||
const statusOptions = getBillStatusOptions(billType);
|
||||
const statusOptions = useMemo(
|
||||
() => getBillStatusOptions(formData.billType),
|
||||
[formData.billType]
|
||||
);
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
const renderFormContent = () => (
|
||||
@@ -239,8 +298,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</Label>
|
||||
<Input
|
||||
id="billNumber"
|
||||
value={billNumber}
|
||||
onChange={(e) => setBillNumber(e.target.value)}
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="어음번호를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
@@ -251,7 +310,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="billType">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={billType} onValueChange={(v) => setBillType(v as BillType)} disabled={isViewMode}>
|
||||
<Select
|
||||
value={formData.billType}
|
||||
onValueChange={(v) => updateField('billType', v as BillType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
@@ -270,7 +333,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="vendorId">
|
||||
거래처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
|
||||
<Select
|
||||
value={formData.vendorId}
|
||||
onValueChange={(v) => updateField('vendorId', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
@@ -291,8 +358,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={amount}
|
||||
onChange={(value) => setAmount(value ?? 0)}
|
||||
value={formData.amount}
|
||||
onChange={(value) => updateField('amount', value ?? 0)}
|
||||
placeholder="금액을 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
@@ -304,8 +371,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={issueDate}
|
||||
onChange={setIssueDate}
|
||||
value={formData.issueDate}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -316,8 +383,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={maturityDate}
|
||||
onChange={setMaturityDate}
|
||||
value={formData.maturityDate}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -327,7 +394,11 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="status">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as BillStatus)} disabled={isViewMode}>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as BillStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
@@ -346,8 +417,8 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
@@ -386,14 +457,14 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{installments.length === 0 ? (
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
installments.map((inst, index) => (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
@@ -442,8 +513,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
);
|
||||
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
// IntegratedDetailTemplate: create → "{title} 등록", view → "{title}", edit → "{title} 수정"
|
||||
// view 모드에서 "어음 상세"로 표시하려면 직접 설정 필요
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
@@ -460,7 +529,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
mode={templateMode}
|
||||
initialData={{}}
|
||||
itemId={billId}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isSubmitting || isDeleting}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
|
||||
renderView={() => renderFormContent()}
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { billConfig } from './billConfig';
|
||||
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
|
||||
import {
|
||||
BILL_TYPE_OPTIONS,
|
||||
getBillStatusOptions,
|
||||
} from './types';
|
||||
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
|
||||
|
||||
// ===== 새 훅 import =====
|
||||
import { useDetailData, useCRUDHandlers } from '@/hooks';
|
||||
|
||||
// ===== Props =====
|
||||
interface BillDetailProps {
|
||||
billId: string;
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
}
|
||||
|
||||
// ===== 거래처 타입 =====
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
|
||||
interface BillFormData {
|
||||
billNumber: string;
|
||||
billType: BillType;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: BillStatus;
|
||||
note: string;
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM_DATA: BillFormData = {
|
||||
billNumber: '',
|
||||
billType: 'received',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
export function BillDetailV2({ billId, mode }: BillDetailProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
|
||||
// ===== 폼 상태 (통합된 단일 state) =====
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
|
||||
|
||||
// ===== 폼 필드 업데이트 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
useEffect(() => {
|
||||
async function loadClients() {
|
||||
const result = await getClients();
|
||||
if (result.success && result.data) {
|
||||
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
|
||||
}
|
||||
}
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// ===== 새 훅: useDetailData로 데이터 로딩 =====
|
||||
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
|
||||
const fetchBillWrapper = useCallback(
|
||||
(id: string | number) => getBill(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
const {
|
||||
data: billData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
} = useDetailData<BillRecord>(
|
||||
billId !== 'new' ? billId : null,
|
||||
fetchBillWrapper,
|
||||
{ skip: isNewMode }
|
||||
);
|
||||
|
||||
// ===== 데이터 로드 시 폼에 반영 =====
|
||||
useEffect(() => {
|
||||
if (billData) {
|
||||
setFormData({
|
||||
billNumber: billData.billNumber,
|
||||
billType: billData.billType,
|
||||
vendorId: billData.vendorId,
|
||||
amount: billData.amount,
|
||||
issueDate: billData.issueDate,
|
||||
maturityDate: billData.maturityDate,
|
||||
status: billData.status,
|
||||
note: billData.note,
|
||||
installments: billData.installments,
|
||||
});
|
||||
}
|
||||
}, [billData]);
|
||||
|
||||
// ===== 로드 에러 처리 =====
|
||||
useEffect(() => {
|
||||
if (loadError) {
|
||||
toast.error(loadError);
|
||||
router.push('/ko/accounting/bills');
|
||||
}
|
||||
}, [loadError, router]);
|
||||
|
||||
// ===== 유효성 검사 함수 =====
|
||||
const validateForm = useCallback((): { valid: boolean; error?: string } => {
|
||||
if (!formData.billNumber.trim()) {
|
||||
return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
}
|
||||
if (!formData.vendorId) {
|
||||
return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (formData.amount <= 0) {
|
||||
return { valid: false, error: '금액을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.issueDate) {
|
||||
return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.maturityDate) {
|
||||
return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 차수 유효성 검사
|
||||
for (let i = 0; i < formData.installments.length; i++) {
|
||||
const inst = formData.installments[i];
|
||||
if (!inst.date) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
|
||||
}
|
||||
if (inst.amount <= 0) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}, [formData]);
|
||||
|
||||
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
|
||||
const updateBillWrapper = useCallback(
|
||||
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
|
||||
[]
|
||||
);
|
||||
|
||||
const deleteBillWrapper = useCallback(
|
||||
(id: string | number) => deleteBill(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
|
||||
const {
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleDelete: crudDelete,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
|
||||
onCreate: createBill,
|
||||
onUpdate: updateBillWrapper,
|
||||
onDelete: deleteBillWrapper,
|
||||
successRedirect: '/ko/accounting/bills',
|
||||
successMessages: {
|
||||
create: '어음이 등록되었습니다.',
|
||||
update: '어음이 수정되었습니다.',
|
||||
delete: '어음이 삭제되었습니다.',
|
||||
},
|
||||
// 수정 성공 시 view 모드로 이동
|
||||
disableRedirect: !isNewMode,
|
||||
onSuccess: (action) => {
|
||||
if (action === 'update') {
|
||||
router.push(`/ko/accounting/bills/${billId}?mode=view`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 유효성 검사
|
||||
const validation = validateForm();
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error!);
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
const billData: Partial<BillRecord> = {
|
||||
...formData,
|
||||
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
|
||||
};
|
||||
|
||||
if (isNewMode) {
|
||||
return handleCreate(billData);
|
||||
} else {
|
||||
return handleUpdate(billId, billData);
|
||||
}
|
||||
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
return crudDelete(billId);
|
||||
}, [billId, crudDelete]);
|
||||
|
||||
// ===== 차수 관리 핸들러 =====
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
const newInstallment: InstallmentRecord = {
|
||||
id: `inst-${Date.now()}`,
|
||||
date: '',
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, newInstallment],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 상태 옵션 (구분에 따라 변경) =====
|
||||
const statusOptions = useMemo(
|
||||
() => getBillStatusOptions(formData.billType),
|
||||
[formData.billType]
|
||||
);
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 기본 정보 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billNumber">
|
||||
어음번호 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="billNumber"
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="어음번호를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billType">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.billType}
|
||||
onValueChange={(v) => updateField('billType', v as BillType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorId">
|
||||
거래처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.vendorId}
|
||||
onValueChange={(v) => updateField('vendorId', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">
|
||||
금액 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={formData.amount}
|
||||
onChange={(value) => updateField('amount', value ?? 0)}
|
||||
placeholder="금액을 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issueDate">
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.issueDate}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 만기일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maturityDate">
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.maturityDate}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as BillStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 차수 관리 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="text-red-500">*</span> 차수 관리
|
||||
</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddInstallment}
|
||||
className="text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead>일자</TableHead>
|
||||
<TableHead>금액</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker
|
||||
value={inst.date}
|
||||
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={inst.amount}
|
||||
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.note}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRemoveInstallment(inst.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
title: isViewMode ? '어음 상세' : '어음',
|
||||
actions: {
|
||||
...billConfig.actions,
|
||||
submitLabel: isNewMode ? '등록' : '저장',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={templateMode}
|
||||
initialData={{}}
|
||||
itemId={billId}
|
||||
isLoading={isLoading || isSubmitting || isDeleting}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user