- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선 - DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가 - useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱 - 전 도메인 날짜 필드 DatePicker 표준화 - 생산대시보드/작업지시/견적서/주문관리 모바일 호환성 강화 - 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등) - 달력 일정 관리 API 연동 및 대량 등록 다이얼로그 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
581 lines
21 KiB
TypeScript
581 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
import { format } from 'date-fns';
|
|
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 { Switch } from '@/components/ui/switch';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { getPresetStyle } from '@/lib/utils/status-config';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { FileText, Eye } from 'lucide-react';
|
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
|
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
|
import { purchaseConfig } from './purchaseConfig';
|
|
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
|
import type { ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
|
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
|
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
|
|
import { PURCHASE_TYPE_LABELS } from './types';
|
|
import {
|
|
getPurchaseById,
|
|
createPurchase,
|
|
updatePurchase,
|
|
deletePurchase,
|
|
} from './actions';
|
|
import { getClients } from '../VendorManagement/actions';
|
|
import { toast } from 'sonner';
|
|
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
|
|
|
interface PurchaseDetailProps {
|
|
purchaseId: string;
|
|
mode: 'view' | 'edit' | 'new';
|
|
}
|
|
|
|
// ===== 거래처 타입 =====
|
|
interface ClientOption {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
// ===== 초기 품목 데이터 =====
|
|
const createEmptyItem = (): PurchaseItem => ({
|
|
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
itemName: '',
|
|
quantity: 1,
|
|
unitPrice: 0,
|
|
supplyPrice: 0,
|
|
vat: 0,
|
|
note: '',
|
|
});
|
|
|
|
export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
|
const isViewMode = mode === 'view';
|
|
const isNewMode = mode === 'new';
|
|
|
|
// ===== 로딩 상태 =====
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// ===== 거래처 목록 =====
|
|
const [clients, setClients] = useState<ClientOption[]>([]);
|
|
|
|
// ===== 폼 상태 =====
|
|
const [purchaseNo, setPurchaseNo] = useState('');
|
|
const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
|
const [vendorId, setVendorId] = useState('');
|
|
const [vendorName, setVendorName] = useState('');
|
|
const [purchaseType, setPurchaseType] = useState<PurchaseType>('unset');
|
|
const [items, setItems] = useState<PurchaseItem[]>([createEmptyItem()]);
|
|
const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false);
|
|
|
|
// ===== 문서 관련 상태 (sourceDocument는 API에서 아직 미지원) =====
|
|
const [sourceDocument, setSourceDocument] = useState<PurchaseRecord['sourceDocument']>(undefined);
|
|
const [withdrawalAccount, setWithdrawalAccount] = useState<PurchaseRecord['withdrawalAccount']>(undefined);
|
|
const [createdAt, setCreatedAt] = useState('');
|
|
|
|
// ===== 문서 열람 모달 상태 =====
|
|
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
|
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | null>(null);
|
|
const [isModalLoading, setIsModalLoading] = useState(false);
|
|
const [approvalId, setApprovalId] = useState<string | undefined>(undefined);
|
|
|
|
// ===== 품목 관리 (공통 훅) =====
|
|
const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<PurchaseItem>({
|
|
items,
|
|
setItems,
|
|
createEmptyItem,
|
|
supplyKey: 'supplyPrice',
|
|
vatKey: 'vat',
|
|
minItems: 1,
|
|
});
|
|
|
|
// ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) =====
|
|
useEffect(() => {
|
|
async function loadInitialData() {
|
|
const isEditMode = purchaseId && mode !== 'new';
|
|
setIsLoading(true);
|
|
|
|
const [clientsResult, purchaseResult] = await Promise.all([
|
|
getClients({ size: 1000, only_active: true }),
|
|
isEditMode ? getPurchaseById(purchaseId) : Promise.resolve(null),
|
|
]);
|
|
|
|
// 거래처 목록
|
|
if (clientsResult.success) {
|
|
setClients(clientsResult.data.map(v => ({
|
|
id: v.id,
|
|
name: v.vendorName,
|
|
})));
|
|
}
|
|
|
|
// 매입 상세
|
|
if (purchaseResult) {
|
|
if (purchaseResult.success && purchaseResult.data) {
|
|
const data = purchaseResult.data;
|
|
setPurchaseNo(data.purchaseNo);
|
|
setPurchaseDate(data.purchaseDate);
|
|
setVendorId(data.vendorId);
|
|
setVendorName(data.vendorName);
|
|
setPurchaseType(data.purchaseType);
|
|
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
|
|
setTaxInvoiceReceived(data.taxInvoiceReceived);
|
|
setSourceDocument(data.sourceDocument);
|
|
setWithdrawalAccount(data.withdrawalAccount);
|
|
setCreatedAt(data.createdAt);
|
|
setApprovalId(data.approvalId);
|
|
}
|
|
} else if (isNewMode) {
|
|
setPurchaseNo('(자동생성)');
|
|
}
|
|
|
|
setIsLoading(false);
|
|
}
|
|
loadInitialData();
|
|
}, [purchaseId, mode, isNewMode]);
|
|
|
|
// ===== 문서 열람 핸들러 (API 연동) =====
|
|
const handleOpenDocument = useCallback(async () => {
|
|
if (!approvalId) {
|
|
toast.error('연결된 결재 문서가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
setIsModalLoading(true);
|
|
setDocumentModalOpen(true);
|
|
|
|
try {
|
|
const result = await getApprovalById(parseInt(approvalId));
|
|
if (result.success && result.data) {
|
|
const formData = result.data;
|
|
const docType = sourceDocument?.type === 'expense_report' ? 'expenseReport' : 'proposal';
|
|
|
|
// 기안자 정보
|
|
const drafter = {
|
|
id: 'drafter-1',
|
|
name: formData.basicInfo.drafter,
|
|
position: formData.basicInfo.drafterPosition || '',
|
|
department: formData.basicInfo.drafterDepartment || '',
|
|
status: 'approved' as const,
|
|
};
|
|
|
|
// 결재자 정보
|
|
const approvers = formData.approvalLine.map((person) => ({
|
|
id: person.id,
|
|
name: person.name,
|
|
position: person.position,
|
|
department: person.department,
|
|
status: 'approved' as const,
|
|
}));
|
|
|
|
if (docType === 'expenseReport') {
|
|
setModalData({
|
|
documentNo: formData.basicInfo.documentNo,
|
|
createdAt: formData.basicInfo.draftDate,
|
|
requestDate: formData.expenseReportData?.requestDate || '',
|
|
paymentDate: formData.expenseReportData?.paymentDate || '',
|
|
items: formData.expenseReportData?.items.map((item, index) => ({
|
|
id: item.id,
|
|
no: index + 1,
|
|
description: item.description,
|
|
amount: item.amount,
|
|
note: item.note,
|
|
})) || [],
|
|
cardInfo: formData.expenseReportData?.cardId || '-',
|
|
totalAmount: formData.expenseReportData?.totalAmount || 0,
|
|
attachments: formData.expenseReportData?.uploadedFiles?.map(f => f.name) || [],
|
|
approvers,
|
|
drafter,
|
|
} as ExpenseReportDocumentData);
|
|
} else {
|
|
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
|
|
`/api/proxy/files/${f.id}/download`
|
|
);
|
|
setModalData({
|
|
documentNo: formData.basicInfo.documentNo,
|
|
createdAt: formData.basicInfo.draftDate,
|
|
vendor: formData.proposalData?.vendor || '-',
|
|
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
|
|
title: formData.proposalData?.title || '',
|
|
description: formData.proposalData?.description || '-',
|
|
reason: formData.proposalData?.reason || '-',
|
|
estimatedCost: formData.proposalData?.estimatedCost || 0,
|
|
attachments: uploadedFileUrls,
|
|
approvers,
|
|
drafter,
|
|
} as ProposalDocumentData);
|
|
}
|
|
} else {
|
|
toast.error(result.error || '문서 조회에 실패했습니다.');
|
|
setDocumentModalOpen(false);
|
|
}
|
|
} catch {
|
|
toast.error('문서 조회 중 오류가 발생했습니다.');
|
|
setDocumentModalOpen(false);
|
|
} finally {
|
|
setIsModalLoading(false);
|
|
}
|
|
}, [approvalId, sourceDocument]);
|
|
|
|
// ===== 핸들러 =====
|
|
const handleVendorChange = useCallback((clientId: string) => {
|
|
const client = clients.find(c => c.id === clientId);
|
|
if (client) {
|
|
setVendorId(client.id);
|
|
setVendorName(client.name);
|
|
}
|
|
}, [clients]);
|
|
|
|
// ===== 저장 (IntegratedDetailTemplate 호환) =====
|
|
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
if (!vendorId) {
|
|
toast.warning('거래처를 선택해주세요.');
|
|
return { success: false, error: '거래처를 선택해주세요.' };
|
|
}
|
|
|
|
setIsSaving(true);
|
|
|
|
const purchaseData: Partial<PurchaseRecord> = {
|
|
purchaseDate,
|
|
vendorId,
|
|
supplyAmount: totals.supplyAmount,
|
|
vat: totals.vat,
|
|
totalAmount: totals.total,
|
|
purchaseType,
|
|
taxInvoiceReceived,
|
|
};
|
|
|
|
try {
|
|
let result;
|
|
if (isNewMode) {
|
|
result = await createPurchase(purchaseData);
|
|
} else if (purchaseId) {
|
|
result = await updatePurchase(purchaseId, purchaseData);
|
|
}
|
|
|
|
if (result?.success) {
|
|
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
|
|
return { success: true };
|
|
} else {
|
|
toast.error(result?.error || '저장에 실패했습니다.');
|
|
return { success: false, error: result?.error || '저장에 실패했습니다.' };
|
|
}
|
|
} catch {
|
|
toast.error('저장 중 오류가 발생했습니다.');
|
|
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId]);
|
|
|
|
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
|
|
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
if (!purchaseId) return { success: false, error: 'ID가 없습니다.' };
|
|
|
|
try {
|
|
const result = await deletePurchase(purchaseId);
|
|
|
|
if (result.success) {
|
|
toast.success('매입이 삭제되었습니다.');
|
|
return { success: true };
|
|
} else {
|
|
toast.error(result.error || '삭제에 실패했습니다.');
|
|
return { success: false, error: result.error || '삭제에 실패했습니다.' };
|
|
}
|
|
} catch {
|
|
toast.error('삭제 중 오류가 발생했습니다.');
|
|
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
|
|
}
|
|
}, [purchaseId]);
|
|
|
|
// ===== 폼 내용 렌더링 =====
|
|
const renderFormContent = () => (
|
|
<>
|
|
<div className="space-y-6">
|
|
{/* ===== 기본 정보 섹션 ===== */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg">기본 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* 품의서/지출결의서인 경우 전용 레이아웃 */}
|
|
{sourceDocument ? (
|
|
<>
|
|
{/* 문서 타입 및 열람 버튼 */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className={getPresetStyle('orange')}>
|
|
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
|
|
</Badge>
|
|
<span className="text-sm text-muted-foreground">연결된 문서가 있습니다</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="sm:ml-auto border-orange-300 text-orange-700 hover:bg-orange-100 w-full sm:w-auto"
|
|
onClick={handleOpenDocument}
|
|
>
|
|
<Eye className="h-4 w-4 mr-1" />
|
|
문서 열람
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 품의서/지출결의서용 필드 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* 품의서/지출결의서 제목 */}
|
|
<div className="space-y-2 md:col-span-2">
|
|
<Label>{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} 제목</Label>
|
|
<Input
|
|
value={sourceDocument.title}
|
|
readOnly
|
|
disabled
|
|
className="bg-gray-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* 예상비용 */}
|
|
<div className="space-y-2">
|
|
<Label>예상비용</Label>
|
|
<Input
|
|
value={`${formatAmount(sourceDocument.expectedCost)}원`}
|
|
readOnly
|
|
disabled
|
|
className="bg-gray-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* 매입번호 */}
|
|
<div className="space-y-2">
|
|
<Label>매입번호</Label>
|
|
<Input
|
|
value={purchaseNo}
|
|
readOnly
|
|
disabled
|
|
className="bg-gray-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* 거래처명 */}
|
|
<div className="space-y-2">
|
|
<Label>거래처명</Label>
|
|
<Select
|
|
value={vendorId}
|
|
onValueChange={handleVendorChange}
|
|
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>매입 유형</Label>
|
|
<Select
|
|
value={purchaseType}
|
|
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="매입 유형 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
/* 일반 매입 (품의서/지출결의서 없는 경우) */
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* 매입번호 */}
|
|
<div className="space-y-2">
|
|
<Label>매입번호</Label>
|
|
<Input
|
|
value={purchaseNo}
|
|
readOnly
|
|
disabled
|
|
className="bg-gray-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* 매입일 */}
|
|
<div className="space-y-2">
|
|
<Label>매입일</Label>
|
|
<DatePicker
|
|
value={purchaseDate}
|
|
onChange={setPurchaseDate}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
|
|
{/* 거래처명 */}
|
|
<div className="space-y-2">
|
|
<Label>거래처명</Label>
|
|
<Select
|
|
value={vendorId}
|
|
onValueChange={handleVendorChange}
|
|
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>매입 유형</Label>
|
|
<Select
|
|
value={purchaseType}
|
|
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
|
|
disabled={isViewMode}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="매입 유형 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ===== 품목 정보 섹션 ===== */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg">품목 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<LineItemsTable<PurchaseItem>
|
|
items={items}
|
|
getItemName={(item) => item.itemName}
|
|
getQuantity={(item) => item.quantity}
|
|
getUnitPrice={(item) => item.unitPrice}
|
|
getSupplyAmount={(item) => item.supplyPrice}
|
|
getVat={(item) => item.vat}
|
|
getNote={(item) => item.note ?? ''}
|
|
onItemChange={handleItemChange}
|
|
onAddItem={handleAddItem}
|
|
onRemoveItem={handleRemoveItem}
|
|
totals={totals}
|
|
isViewMode={isViewMode}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ===== 세금계산서 섹션 ===== */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg">세금계산서</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Label htmlFor="taxInvoice">세금계산서 수취</Label>
|
|
<Switch
|
|
id="taxInvoice"
|
|
checked={taxInvoiceReceived}
|
|
onCheckedChange={setTaxInvoiceReceived}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{taxInvoiceReceived ? (
|
|
<span className="text-sm text-green-600 flex items-center gap-1">
|
|
<FileText className="h-4 w-4" />
|
|
수취완료
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-gray-500 flex items-center gap-1">
|
|
<FileText className="h-4 w-4" />
|
|
미수취
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* ===== 품의서/지출결의서 문서 열람 모달 ===== */}
|
|
{modalData && (
|
|
<DocumentDetailModal
|
|
open={documentModalOpen}
|
|
onOpenChange={(open) => {
|
|
setDocumentModalOpen(open);
|
|
if (!open) setModalData(null);
|
|
}}
|
|
mode="reference"
|
|
documentType={sourceDocument?.type === 'expense_report' ? 'expenseReport' : 'proposal'}
|
|
data={modalData}
|
|
/>
|
|
)}
|
|
|
|
</>
|
|
);
|
|
|
|
// ===== 모드 변환 =====
|
|
const templateMode = isNewMode ? 'create' : mode;
|
|
|
|
// ===== 동적 config =====
|
|
const dynamicConfig = {
|
|
...purchaseConfig,
|
|
title: isNewMode ? '매입' : '매입 상세',
|
|
actions: {
|
|
...purchaseConfig.actions,
|
|
submitLabel: isNewMode ? '등록' : '저장',
|
|
},
|
|
};
|
|
|
|
return (
|
|
<IntegratedDetailTemplate
|
|
config={dynamicConfig}
|
|
mode={templateMode}
|
|
initialData={{}}
|
|
itemId={purchaseId}
|
|
isLoading={isLoading}
|
|
onSubmit={handleSubmit}
|
|
onDelete={purchaseId && !isNewMode ? handleDelete : undefined}
|
|
renderView={() => renderFormContent()}
|
|
renderForm={() => renderFormContent()}
|
|
/>
|
|
);
|
|
}
|