Files
sam-react-prod/src/components/accounting/SalesManagement/SalesDetail.tsx
유병철 1bccaffe27 feat: CEO 대시보드 리팩토링 및 회계 관리 개선
- CEO 대시보드: 컴포넌트 분리(DashboardSettingsSections, DetailModalSections), 모달/섹션 개선, useCEODashboard 최적화
- 회계: 부실채권/매출/매입/일일보고 UI 및 타입 개선
- 공통: Sidebar, useDashboardFetch 훅 추가, amount/status-config 유틸 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:17:40 +09:00

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