Files
sam-react-prod/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx
유병철 13d27553b9 feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선
- DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가
- useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱
- 전 도메인 날짜 필드 DatePicker 표준화
- 생산대시보드/작업지시/견적서/주문관리 모바일 호환성 강화
- 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등)
- 달력 일정 관리 API 연동 및 대량 등록 다이얼로그 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:28:23 +09:00

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