DatePicker 공통화: - date-picker.tsx 공통 컴포넌트 신규 추가 - 전체 폼 컴포넌트 DatePicker 통일 적용 (50+ 파일) - DateRangeSelector 개선 공정관리: - RuleModal 대폭 리팩토링 (-592줄 → 간소화) - ProcessForm, StepForm 개선 - ProcessDetail 수정, actions 확장 작업자화면: - WorkerScreen 기능 대폭 확장 (+543줄) - WorkItemCard 개선 - types 확장 회계/인사/영업/품질: - BadDebtDetail, BillDetail, DepositDetail, SalesDetail 등 DatePicker 적용 - EmployeeForm, VacationDialog 등 DatePicker 적용 - OrderRegistration, QuoteRegistration DatePicker 적용 - InspectionCreate, InspectionDetail DatePicker 적용 공사관리/CEO대시보드: - BiddingDetail, ContractDetail, HandoverReport 등 DatePicker 적용 - ScheduleDetailModal, TodayIssueSection 개선 기타: - WorkOrderCreate/Edit/Detail/List 개선 - ShipmentCreate/Edit, ReceivingDetail 개선 - calendar, calendarEvents 수정 - datepicker 마이그레이션 체크리스트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useEffect } 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';
|
|
|
|
// ===== Props =====
|
|
interface BillDetailProps {
|
|
billId: string;
|
|
mode: 'view' | 'edit' | 'new';
|
|
}
|
|
|
|
// ===== 거래처 타입 =====
|
|
interface ClientOption {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
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[]>([]);
|
|
|
|
// ===== 거래처 목록 로드 =====
|
|
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();
|
|
}, []);
|
|
|
|
// ===== 데이터 로드 =====
|
|
useEffect(() => {
|
|
async function loadBill() {
|
|
if (!billId || billId === 'new') return;
|
|
|
|
setIsLoading(true);
|
|
const result = await getBill(billId);
|
|
setIsLoading(false);
|
|
|
|
if (result.success && result.data) {
|
|
const data = result.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(result.error || '어음 정보를 불러올 수 없습니다.');
|
|
router.push('/ko/accounting/bills');
|
|
}
|
|
}
|
|
|
|
loadBill();
|
|
}, [billId, router]);
|
|
|
|
// ===== 저장 핸들러 =====
|
|
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
// 유효성 검사
|
|
if (!billNumber.trim()) {
|
|
toast.error('어음번호를 입력해주세요.');
|
|
return { success: false, error: '어음번호를 입력해주세요.' };
|
|
}
|
|
if (!vendorId) {
|
|
toast.error('거래처를 선택해주세요.');
|
|
return { success: false, error: '거래처를 선택해주세요.' };
|
|
}
|
|
if (amount <= 0) {
|
|
toast.error('금액을 입력해주세요.');
|
|
return { success: false, error: '금액을 입력해주세요.' };
|
|
}
|
|
if (!issueDate) {
|
|
toast.error('발행일을 입력해주세요.');
|
|
return { success: false, error: '발행일을 입력해주세요.' };
|
|
}
|
|
if (!maturityDate) {
|
|
toast.error('만기일을 입력해주세요.');
|
|
return { success: false, error: '만기일을 입력해주세요.' };
|
|
}
|
|
|
|
// 차수 유효성 검사
|
|
for (let i = 0; i < installments.length; i++) {
|
|
const inst = installments[i];
|
|
if (!inst.date) {
|
|
const errorMsg = `차수 ${i + 1}번의 일자를 입력해주세요.`;
|
|
toast.error(errorMsg);
|
|
return { success: false, error: errorMsg };
|
|
}
|
|
if (inst.amount <= 0) {
|
|
const errorMsg = `차수 ${i + 1}번의 금액을 입력해주세요.`;
|
|
toast.error(errorMsg);
|
|
return { success: false, error: errorMsg };
|
|
}
|
|
}
|
|
|
|
const billData: Partial<BillRecord> = {
|
|
billNumber,
|
|
billType,
|
|
vendorId,
|
|
vendorName: clients.find(c => c.id === vendorId)?.name || '',
|
|
amount,
|
|
issueDate,
|
|
maturityDate,
|
|
status,
|
|
note,
|
|
installments,
|
|
};
|
|
|
|
let result;
|
|
if (isNewMode) {
|
|
result = await createBill(billData);
|
|
} else {
|
|
result = await updateBill(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]);
|
|
|
|
// ===== 삭제 핸들러 =====
|
|
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
const result = await deleteBill(billId);
|
|
|
|
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()}`,
|
|
date: '',
|
|
amount: 0,
|
|
note: '',
|
|
};
|
|
setInstallments(prev => [...prev, newInstallment]);
|
|
}, []);
|
|
|
|
// ===== 차수 삭제 =====
|
|
const handleRemoveInstallment = useCallback((id: string) => {
|
|
setInstallments(prev => prev.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 statusOptions = getBillStatusOptions(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={billNumber}
|
|
onChange={(e) => setBillNumber(e.target.value)}
|
|
placeholder="어음번호를 입력해주세요"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
|
|
{/* 구분 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="billType">
|
|
구분 <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Select value={billType} onValueChange={(v) => setBillType(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={vendorId} onValueChange={setVendorId} 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={amount}
|
|
onChange={(value) => setAmount(value ?? 0)}
|
|
placeholder="금액을 입력해주세요"
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
|
|
{/* 발행일 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="issueDate">
|
|
발행일 <span className="text-red-500">*</span>
|
|
</Label>
|
|
<DatePicker
|
|
value={issueDate}
|
|
onChange={setIssueDate}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
|
|
{/* 만기일 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="maturityDate">
|
|
만기일 <span className="text-red-500">*</span>
|
|
</Label>
|
|
<DatePicker
|
|
value={maturityDate}
|
|
onChange={setMaturityDate}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
|
|
{/* 상태 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="status">
|
|
상태 <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Select value={status} onValueChange={(v) => setStatus(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={note}
|
|
onChange={(e) => setNote(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>
|
|
{installments.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
|
등록된 차수가 없습니다
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
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>
|
|
</>
|
|
);
|
|
|
|
// ===== 템플릿 모드 및 동적 설정 =====
|
|
// IntegratedDetailTemplate: create → "{title} 등록", view → "{title}", edit → "{title} 수정"
|
|
// view 모드에서 "어음 상세"로 표시하려면 직접 설정 필요
|
|
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}
|
|
onSubmit={handleSubmit}
|
|
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
|
|
renderView={() => renderFormContent()}
|
|
renderForm={() => renderFormContent()}
|
|
/>
|
|
);
|
|
}
|