Files
sam-react-prod/src/components/accounting/BillManagement/BillDetail.tsx
유병철 1d7b028693 feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금

Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입

신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들

총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00

551 lines
18 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
FileText,
Plus,
X,
Loader2,
List,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
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 [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = 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[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// ===== 거래처 목록 로드 =====
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 handleSave = useCallback(async () => {
// 유효성 검사
if (!billNumber.trim()) {
toast.error('어음번호를 입력해주세요.');
return;
}
if (!vendorId) {
toast.error('거래처를 선택해주세요.');
return;
}
if (amount <= 0) {
toast.error('금액을 입력해주세요.');
return;
}
// 차수 유효성 검사
for (let i = 0; i < installments.length; i++) {
const inst = installments[i];
if (!inst.date) {
toast.error(`차수 ${i + 1}번의 일자를 입력해주세요.`);
return;
}
if (inst.amount <= 0) {
toast.error(`차수 ${i + 1}번의 금액을 입력해주세요.`);
return;
}
}
setIsSaving(true);
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);
}
setIsSaving(false);
if (result.success) {
toast.success(isNewMode ? '어음이 등록되었습니다.' : '어음이 수정되었습니다.');
if (isNewMode) {
router.push('/ko/accounting/bills');
} else {
router.push(`/ko/accounting/bills/${billId}`);
}
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
}, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode, clients]);
// ===== 취소 핸들러 =====
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/bills');
} else {
router.push(`/ko/accounting/bills/${billId}`);
}
}, [router, billId, isNewMode]);
// ===== 목록으로 이동 =====
const handleBack = useCallback(() => {
router.push('/ko/accounting/bills');
}, [router]);
// ===== 수정 모드로 이동 =====
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/bills/${billId}?mode=edit`);
}, [router, billId]);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async () => {
setIsDeleting(true);
const result = await deleteBill(billId);
setIsDeleting(false);
setShowDeleteDialog(false);
if (result.success) {
toast.success('어음이 삭제되었습니다.');
router.push('/ko/accounting/bills');
} else {
toast.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);
// ===== 로딩 중 =====
if (isLoading) {
return (
<PageLayout>
<ContentLoadingSpinner text="어음 정보를 불러오는 중..." />
</PageLayout>
);
}
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title={isNewMode ? '어음 등록' : isViewMode ? '어음 상세' : '어음 수정'}
description="어음 및 수취어음 상세 현황을 관리합니다"
icon={FileText}
/>
{/* 헤더 액션 버튼 */}
<div className="flex items-center justify-end gap-2 mb-6">
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => setShowDeleteDialog(true)}
>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
) : (
<>
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-blue-500 hover:bg-blue-600"
>
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isNewMode ? '등록' : '저장'}
</Button>
</>
)}
</div>
{/* 기본 정보 섹션 */}
<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>
<Input
id="amount"
type="number"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
placeholder="금액을 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 발행일 */}
<div className="space-y-2">
<Label htmlFor="issueDate"></Label>
<Input
id="issueDate"
type="date"
value={issueDate}
onChange={(e) => setIssueDate(e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 만기일 */}
<div className="space-y-2">
<Label htmlFor="maturityDate"></Label>
<Input
id="maturityDate"
type="date"
value={maturityDate}
onChange={(e) => setMaturityDate(e.target.value)}
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>
<Input
type="date"
value={inst.date}
onChange={(e) => handleUpdateInstallment(inst.id, 'date', e.target.value)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
<TableCell>
<Input
type="number"
value={inst.amount}
onChange={(e) => handleUpdateInstallment(inst.id, 'amount', Number(e.target.value))}
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>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-500 hover:bg-red-600"
>
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}