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:
유병철
2026-02-11 15:09:51 +09:00
parent e14335b635
commit a38996b751
96 changed files with 4930 additions and 6550 deletions

View File

@@ -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()}

View File

@@ -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()}
/>
);
}