- CEO 대시보드: 컴포넌트 분리(DashboardSettingsSections, DetailModalSections), 모달/섹션 개선, useCEODashboard 최적화 - 회계: 부실채권/매출/매입/일일보고 UI 및 타입 개선 - 공통: Sidebar, useDashboardFetch 훅 추가, amount/status-config 유틸 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
import { format } from 'date-fns';
|
|
import {
|
|
Send,
|
|
FileText,
|
|
} 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
// 삭제 다이얼로그는 IntegratedDetailTemplate이 처리함
|
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
|
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
|
import { salesConfig } from './salesConfig';
|
|
import type { SalesRecord, SalesItem } from './types';
|
|
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
|
|
import { toast } from 'sonner';
|
|
import { getClients } from '../VendorManagement/actions';
|
|
|
|
// ===== Props =====
|
|
interface SalesDetailProps {
|
|
mode: 'view' | 'edit' | 'new';
|
|
salesId?: string;
|
|
}
|
|
|
|
// ===== 거래처 타입 (간단) =====
|
|
interface ClientOption {
|
|
id: string;
|
|
name: string;
|
|
email?: string;
|
|
}
|
|
|
|
// ===== 초기 품목 데이터 =====
|
|
const createEmptyItem = (): SalesItem => ({
|
|
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
itemName: '',
|
|
quantity: 0,
|
|
unitPrice: 0,
|
|
supplyAmount: 0,
|
|
vat: 0,
|
|
note: '',
|
|
});
|
|
|
|
export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
|
const isViewMode = mode === 'view';
|
|
const isNewMode = mode === 'new';
|
|
|
|
// ===== 로딩 상태 =====
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// ===== 거래처 목록 =====
|
|
const [clients, setClients] = useState<ClientOption[]>([]);
|
|
|
|
// ===== 폼 상태 =====
|
|
const [salesNo, setSalesNo] = useState('');
|
|
const [salesDate, setSalesDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
|
const [vendorId, setVendorId] = useState('');
|
|
const [vendorName, setVendorName] = useState('');
|
|
const [items, setItems] = useState<SalesItem[]>([createEmptyItem()]);
|
|
const [taxInvoiceIssued, setTaxInvoiceIssued] = useState(false);
|
|
const [transactionStatementIssued, setTransactionStatementIssued] = useState(false);
|
|
const [note, setNote] = useState('');
|
|
|
|
// ===== 알림 다이얼로그 상태 (이메일 발송용) =====
|
|
const [showEmailAlert, setShowEmailAlert] = useState(false);
|
|
const [emailAlertMessage, setEmailAlertMessage] = useState('');
|
|
|
|
// ===== 품목 관리 (공통 훅) =====
|
|
const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<SalesItem>({
|
|
items,
|
|
setItems,
|
|
createEmptyItem,
|
|
supplyKey: 'supplyAmount',
|
|
vatKey: 'vat',
|
|
minItems: 1,
|
|
});
|
|
|
|
// ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) =====
|
|
useEffect(() => {
|
|
async function loadInitialData() {
|
|
const isEditMode = salesId && mode !== 'new';
|
|
setIsLoading(true);
|
|
|
|
const [clientsResult, saleResult] = await Promise.all([
|
|
getClients({ size: 1000, only_active: true }),
|
|
isEditMode ? getSaleById(salesId) : Promise.resolve(null),
|
|
]);
|
|
|
|
// 거래처 목록
|
|
if (clientsResult.success) {
|
|
setClients(clientsResult.data.map(v => ({
|
|
id: v.id,
|
|
name: v.vendorName,
|
|
email: v.email,
|
|
})));
|
|
}
|
|
|
|
// 매출 상세
|
|
if (saleResult) {
|
|
if (saleResult.success && saleResult.data) {
|
|
const data = saleResult.data;
|
|
setSalesNo(data.salesNo);
|
|
setSalesDate(data.salesDate);
|
|
setVendorId(data.vendorId);
|
|
setVendorName(data.vendorName);
|
|
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
|
|
setTaxInvoiceIssued(data.taxInvoiceIssued);
|
|
setTransactionStatementIssued(data.transactionStatementIssued);
|
|
setNote(data.note || '');
|
|
}
|
|
} else if (isNewMode) {
|
|
setSalesNo('(자동생성)');
|
|
}
|
|
|
|
setIsLoading(false);
|
|
}
|
|
loadInitialData();
|
|
}, [salesId, mode, isNewMode]);
|
|
|
|
// ===== 선택된 거래처 정보 =====
|
|
const selectedVendor = useMemo(() => {
|
|
return clients.find(v => v.id === vendorId);
|
|
}, [clients, vendorId]);
|
|
|
|
// ===== 저장 (IntegratedDetailTemplate 호환) =====
|
|
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
if (!vendorId) {
|
|
toast.warning('거래처를 선택해주세요.');
|
|
return { success: false, error: '거래처를 선택해주세요.' };
|
|
}
|
|
|
|
setIsSaving(true);
|
|
|
|
const saleData: Partial<SalesRecord> = {
|
|
salesDate,
|
|
vendorId,
|
|
items,
|
|
totalSupplyAmount: totals.supplyAmount,
|
|
totalVat: totals.vat,
|
|
totalAmount: totals.total,
|
|
taxInvoiceIssued,
|
|
transactionStatementIssued,
|
|
note,
|
|
};
|
|
|
|
try {
|
|
let result;
|
|
if (isNewMode) {
|
|
result = await createSale(saleData);
|
|
} else if (salesId) {
|
|
result = await updateSale(salesId, saleData);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}, [salesDate, vendorId, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
|
|
|
|
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
|
|
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
if (!salesId) return { success: false, error: 'ID가 없습니다.' };
|
|
|
|
try {
|
|
const result = await deleteSale(salesId);
|
|
|
|
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: '삭제 중 오류가 발생했습니다.' };
|
|
}
|
|
}, [salesId]);
|
|
|
|
// ===== 거래명세서 발행 =====
|
|
const handleSendTransactionStatement = useCallback(() => {
|
|
if (selectedVendor?.email) {
|
|
setEmailAlertMessage(`거래명세서가 '${selectedVendor.email}'으로 발송되었습니다.`);
|
|
setTransactionStatementIssued(true);
|
|
setShowEmailAlert(true);
|
|
}
|
|
}, [selectedVendor]);
|
|
|
|
// ===== 폼 내용 렌더링 =====
|
|
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="salesNo">매출번호</Label>
|
|
<Input
|
|
id="salesNo"
|
|
value={salesNo}
|
|
readOnly
|
|
disabled
|
|
className="bg-gray-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* 매출일 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="salesDate">매출일</Label>
|
|
<DatePicker
|
|
value={salesDate}
|
|
onChange={setSalesDate}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
|
|
{/* 거래처명 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="vendorId">거래처명</Label>
|
|
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="거래처명 ▼" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{clients.filter(c => c.id !== '').map((client) => (
|
|
<SelectItem key={client.id} value={client.id}>
|
|
{client.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 품목 정보 섹션 */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">품목 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<LineItemsTable<SalesItem>
|
|
items={items}
|
|
getItemName={(item) => item.itemName}
|
|
getQuantity={(item) => item.quantity}
|
|
getUnitPrice={(item) => item.unitPrice}
|
|
getSupplyAmount={(item) => item.supplyAmount}
|
|
getVat={(item) => item.vat}
|
|
getNote={(item) => item.note}
|
|
onItemChange={handleItemChange}
|
|
onAddItem={handleAddItem}
|
|
onRemoveItem={handleRemoveItem}
|
|
totals={totals}
|
|
isViewMode={isViewMode}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 세금계산서 섹션 */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">세금계산서</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Label htmlFor="taxInvoice">세금계산서 발행</Label>
|
|
<Switch
|
|
id="taxInvoice"
|
|
checked={taxInvoiceIssued}
|
|
onCheckedChange={setTaxInvoiceIssued}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{taxInvoiceIssued ? (
|
|
<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>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="default"
|
|
className="bg-gray-900 hover:bg-gray-800 text-white"
|
|
onClick={() => {
|
|
toast.info('세금계산서 발행 기능 준비 중입니다.');
|
|
}}
|
|
>
|
|
<FileText className="h-4 w-4 mr-2" />
|
|
세금계산서 발행하기
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 거래명세서 섹션 */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">거래명세서</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Label htmlFor="transactionStatement">거래명세서 발행</Label>
|
|
<Switch
|
|
id="transactionStatement"
|
|
checked={transactionStatementIssued}
|
|
onCheckedChange={setTransactionStatementIssued}
|
|
disabled={isViewMode}
|
|
/>
|
|
</div>
|
|
{transactionStatementIssued ? (
|
|
<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 className="flex flex-col sm:flex-row gap-2">
|
|
<Button
|
|
variant="default"
|
|
className="bg-gray-900 hover:bg-gray-800 text-white"
|
|
onClick={() => {
|
|
toast.info('거래명세서 조회 기능 준비 중입니다.');
|
|
}}
|
|
>
|
|
<FileText className="h-4 w-4 mr-2" />
|
|
거래명세서 조회
|
|
</Button>
|
|
{vendorId && (
|
|
<Button
|
|
variant="default"
|
|
className="bg-gray-900 hover:bg-gray-800 text-white"
|
|
onClick={handleSendTransactionStatement}
|
|
disabled={!selectedVendor?.email}
|
|
>
|
|
<Send className="h-4 w-4 mr-2" />
|
|
거래명세서 발행하기
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 이메일 발송 알림 다이얼로그 */}
|
|
<AlertDialog open={showEmailAlert} onOpenChange={setShowEmailAlert}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>발송 완료</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{emailAlertMessage}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogAction>확인</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
|
|
// ===== 모드 변환 =====
|
|
const templateMode = isNewMode ? 'create' : mode;
|
|
|
|
// ===== 동적 config =====
|
|
// IntegratedDetailTemplate: create → "{title} 등록", view → "{title}", edit → "{title} 수정"
|
|
// view 모드에서 "매출 상세"로 표시하려면 직접 설정 필요
|
|
const dynamicConfig = {
|
|
...salesConfig,
|
|
title: isViewMode ? '매출 상세' : '매출',
|
|
actions: {
|
|
...salesConfig.actions,
|
|
submitLabel: isNewMode ? '등록' : '저장',
|
|
},
|
|
};
|
|
|
|
return (
|
|
<IntegratedDetailTemplate
|
|
config={dynamicConfig}
|
|
mode={templateMode}
|
|
initialData={{}}
|
|
itemId={salesId}
|
|
isLoading={isLoading}
|
|
onSubmit={handleSubmit}
|
|
onDelete={salesId ? handleDelete : undefined}
|
|
renderView={() => renderFormContent()}
|
|
renderForm={() => renderFormContent()}
|
|
/>
|
|
);
|
|
}
|