refactor(WEB): CEO 대시보드 대규모 개선 및 문서/권한/스토어 리팩토링
- CEO 대시보드: 섹션별 API 연동 강화 (매출/매입/생산 실데이터 표시) - DashboardSettingsDialog 드래그 정렬 및 설정 UX 개선 - dashboard transformers 모듈 분리 (파일 분할) - DocumentTable/DocumentWrapper 공통 문서 컴포넌트 추출 - LineItemsTable organisms 컴포넌트 추가 - PurchaseOrderDocument/InspectionRequestDocument 문서 컴포넌트 리팩토링 - PermissionContext → permissionStore(Zustand) 전환 - useUIStore, stores/utils/userStorage 추가 - favoritesStore/useTableColumnStore 사용자별 저장 지원 - DepositDetail/WithdrawalDetail 삭제 (통합) - PurchaseDetail/SalesDetail 간소화 - amount.ts/formatters.ts 유틸 확장 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,320 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
|
||||||
Banknote,
|
|
||||||
List,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { formatNumber } from '@/lib/utils/amount';
|
|
||||||
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 {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
||||||
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
|
||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import type { DepositRecord, DepositType } from './types';
|
|
||||||
import { DEPOSIT_TYPE_SELECTOR_OPTIONS } from './types';
|
|
||||||
import {
|
|
||||||
getDepositById,
|
|
||||||
createDeposit,
|
|
||||||
updateDeposit,
|
|
||||||
deleteDeposit,
|
|
||||||
getVendors,
|
|
||||||
} from './actions';
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
interface DepositDetailProps {
|
|
||||||
depositId: string;
|
|
||||||
mode: 'view' | 'edit' | 'new';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const isViewMode = mode === 'view';
|
|
||||||
const isNewMode = mode === 'new';
|
|
||||||
|
|
||||||
// ===== 폼 상태 =====
|
|
||||||
const [depositDate, setDepositDate] = useState('');
|
|
||||||
const [accountName, setAccountName] = useState('');
|
|
||||||
const [depositorName, setDepositorName] = useState('');
|
|
||||||
const [depositAmount, setDepositAmount] = useState(0);
|
|
||||||
const [note, setNote] = useState('');
|
|
||||||
const [vendorId, setVendorId] = useState('');
|
|
||||||
const [depositType, setDepositType] = useState<DepositType>('unset');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
|
|
||||||
// ===== 초기 데이터 로드 (거래처 + 입금 상세 병렬) =====
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInitialData = async () => {
|
|
||||||
const isEditMode = depositId && !isNewMode;
|
|
||||||
if (isEditMode) setIsLoading(true);
|
|
||||||
|
|
||||||
const [vendorsResult, depositResult] = await Promise.all([
|
|
||||||
getVendors(),
|
|
||||||
isEditMode ? getDepositById(depositId) : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 거래처 목록
|
|
||||||
if (vendorsResult.success) {
|
|
||||||
setVendors(vendorsResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 입금 상세
|
|
||||||
if (depositResult) {
|
|
||||||
if (depositResult.success && depositResult.data) {
|
|
||||||
setDepositDate(depositResult.data.depositDate);
|
|
||||||
setAccountName(depositResult.data.accountName);
|
|
||||||
setDepositorName(depositResult.data.depositorName);
|
|
||||||
setDepositAmount(depositResult.data.depositAmount);
|
|
||||||
setNote(depositResult.data.note);
|
|
||||||
setVendorId(depositResult.data.vendorId);
|
|
||||||
setDepositType(depositResult.data.depositType);
|
|
||||||
} else {
|
|
||||||
toast.error(depositResult.error || '입금 내역을 불러오는데 실패했습니다.');
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadInitialData();
|
|
||||||
}, [depositId, isNewMode]);
|
|
||||||
|
|
||||||
// ===== 저장 핸들러 =====
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!vendorId) {
|
|
||||||
toast.error('거래처를 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (depositType === 'unset') {
|
|
||||||
toast.error('입금 유형을 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
const formData: Partial<DepositRecord> = {
|
|
||||||
depositDate,
|
|
||||||
accountName,
|
|
||||||
depositorName,
|
|
||||||
depositAmount,
|
|
||||||
note,
|
|
||||||
vendorId,
|
|
||||||
vendorName: vendors.find(v => v.id === vendorId)?.name || '',
|
|
||||||
depositType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = isNewMode
|
|
||||||
? await createDeposit(formData)
|
|
||||||
: await updateDeposit(depositId, formData);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(isNewMode ? '입금 내역이 등록되었습니다.' : '입금 내역이 수정되었습니다.');
|
|
||||||
router.push('/ko/accounting/deposits');
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '저장에 실패했습니다.');
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [depositId, depositDate, accountName, depositorName, depositAmount, note, vendorId, vendors, depositType, router, isNewMode]);
|
|
||||||
|
|
||||||
// ===== 취소 핸들러 =====
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
if (isNewMode) {
|
|
||||||
router.push('/ko/accounting/deposits');
|
|
||||||
} else {
|
|
||||||
router.push(`/ko/accounting/deposits/${depositId}?mode=view`);
|
|
||||||
}
|
|
||||||
}, [router, depositId, isNewMode]);
|
|
||||||
|
|
||||||
// ===== 목록으로 이동 =====
|
|
||||||
const handleBack = useCallback(() => {
|
|
||||||
router.push('/ko/accounting/deposits');
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
// ===== 수정 모드로 이동 =====
|
|
||||||
const handleEdit = useCallback(() => {
|
|
||||||
router.push(`/ko/accounting/deposits/${depositId}?mode=edit`);
|
|
||||||
}, [router, depositId]);
|
|
||||||
|
|
||||||
// ===== 삭제 다이얼로그 =====
|
|
||||||
const deleteDialog = useDeleteDialog({
|
|
||||||
onDelete: async (id) => deleteDeposit(id),
|
|
||||||
onSuccess: () => router.push('/ko/accounting/deposits'),
|
|
||||||
entityName: '입금',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<PageHeader
|
|
||||||
title={isNewMode ? '입금 등록' : isViewMode ? '입금 상세' : '입금 수정'}
|
|
||||||
description="입금 상세 내역을 등록합니다"
|
|
||||||
icon={Banknote}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 헤더 액션 버튼 */}
|
|
||||||
<div className="flex items-center justify-end gap-2 mb-6">
|
|
||||||
{/* view 모드: [목록] [삭제] [수정] */}
|
|
||||||
{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={() => deleteDialog.single.open(depositId)}
|
|
||||||
disabled={deleteDialog.isPending}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
/* edit/new 모드: [취소] [저장/등록] */
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-blue-500 hover:bg-blue-600"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? '처리중...' : 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="depositDate">입금일</Label>
|
|
||||||
<DatePicker
|
|
||||||
value={depositDate}
|
|
||||||
disabled
|
|
||||||
className="bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입금계좌 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="accountName">입금계좌</Label>
|
|
||||||
<Input
|
|
||||||
id="accountName"
|
|
||||||
value={accountName}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
className="bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입금자명 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="depositorName">입금자명</Label>
|
|
||||||
<Input
|
|
||||||
id="depositorName"
|
|
||||||
value={depositorName}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
className="bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입금금액 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="depositAmount">입금금액</Label>
|
|
||||||
<Input
|
|
||||||
id="depositAmount"
|
|
||||||
value={formatNumber(depositAmount)}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
className="bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 적요 */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor="note">적요</Label>
|
|
||||||
<Input
|
|
||||||
id="note"
|
|
||||||
value={note}
|
|
||||||
onChange={(e) => setNote(e.target.value)}
|
|
||||||
placeholder="적요를 입력해주세요"
|
|
||||||
disabled={isViewMode}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
{vendors.map((vendor) => (
|
|
||||||
<SelectItem key={vendor.id} value={vendor.id}>
|
|
||||||
{vendor.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입금 유형 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="depositType">
|
|
||||||
입금 유형 <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Select value={depositType} onValueChange={(v) => setDepositType(v as DepositType)} disabled={isViewMode}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{DEPOSIT_TYPE_SELECTOR_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ===== 삭제 확인 다이얼로그 ===== */}
|
|
||||||
<DeleteConfirmDialog
|
|
||||||
open={deleteDialog.single.isOpen}
|
|
||||||
onOpenChange={deleteDialog.single.onOpenChange}
|
|
||||||
onConfirm={deleteDialog.single.confirm}
|
|
||||||
title="입금 삭제"
|
|
||||||
description="이 입금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
|
|
||||||
loading={deleteDialog.isPending}
|
|
||||||
/>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,6 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { getPresetStyle } from '@/lib/utils/status-config';
|
import { getPresetStyle } from '@/lib/utils/status-config';
|
||||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
|
||||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -18,17 +16,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { FileText, Plus, X, Eye } from 'lucide-react';
|
import { FileText, Eye } from 'lucide-react';
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||||
|
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
||||||
import { purchaseConfig } from './purchaseConfig';
|
import { purchaseConfig } from './purchaseConfig';
|
||||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||||
import type { ProposalDocumentData, ExpenseReportDocumentData } from '@/components/approval/DocumentDetail/types';
|
import type { ProposalDocumentData, ExpenseReportDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||||
@@ -95,6 +86,16 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
|||||||
// ===== 다이얼로그 상태 =====
|
// ===== 다이얼로그 상태 =====
|
||||||
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// ===== 품목 관리 (공통 훅) =====
|
||||||
|
const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<PurchaseItem>({
|
||||||
|
items,
|
||||||
|
setItems,
|
||||||
|
createEmptyItem,
|
||||||
|
supplyKey: 'supplyPrice',
|
||||||
|
vatKey: 'vat',
|
||||||
|
minItems: 1,
|
||||||
|
});
|
||||||
|
|
||||||
// ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) =====
|
// ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) =====
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadInitialData() {
|
async function loadInitialData() {
|
||||||
@@ -138,17 +139,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
|||||||
loadInitialData();
|
loadInitialData();
|
||||||
}, [purchaseId, mode, isNewMode]);
|
}, [purchaseId, mode, isNewMode]);
|
||||||
|
|
||||||
// ===== 합계 계산 =====
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const totalSupply = items.reduce((sum, item) => sum + item.supplyPrice, 0);
|
|
||||||
const totalVat = items.reduce((sum, item) => sum + item.vat, 0);
|
|
||||||
return {
|
|
||||||
supplyAmount: totalSupply,
|
|
||||||
vat: totalVat,
|
|
||||||
total: totalSupply + totalVat,
|
|
||||||
};
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
// ===== 핸들러 =====
|
// ===== 핸들러 =====
|
||||||
const handleVendorChange = useCallback((clientId: string) => {
|
const handleVendorChange = useCallback((clientId: string) => {
|
||||||
const client = clients.find(c => c.id === clientId);
|
const client = clients.find(c => c.id === clientId);
|
||||||
@@ -158,37 +148,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
|||||||
}
|
}
|
||||||
}, [clients]);
|
}, [clients]);
|
||||||
|
|
||||||
const handleItemChange = useCallback((index: number, field: keyof PurchaseItem, value: string | number) => {
|
|
||||||
setItems(prev => {
|
|
||||||
const newItems = [...prev];
|
|
||||||
const item = { ...newItems[index] };
|
|
||||||
|
|
||||||
if (field === 'quantity' || field === 'unitPrice') {
|
|
||||||
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
|
|
||||||
item[field] = numValue;
|
|
||||||
// 자동 계산: 공급가액 = 수량 * 단가
|
|
||||||
item.supplyPrice = item.quantity * item.unitPrice;
|
|
||||||
item.vat = Math.floor(item.supplyPrice * 0.1);
|
|
||||||
} else {
|
|
||||||
(item as any)[field] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
newItems[index] = item;
|
|
||||||
return newItems;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddItem = useCallback(() => {
|
|
||||||
setItems(prev => [...prev, createEmptyItem()]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRemoveItem = useCallback((index: number) => {
|
|
||||||
setItems(prev => {
|
|
||||||
if (prev.length <= 1) return prev;
|
|
||||||
return prev.filter((_, i) => i !== index);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ===== 저장 (IntegratedDetailTemplate 호환) =====
|
// ===== 저장 (IntegratedDetailTemplate 호환) =====
|
||||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||||
if (!vendorId) {
|
if (!vendorId) {
|
||||||
@@ -435,101 +394,20 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
|||||||
<CardTitle className="text-lg">품목 정보</CardTitle>
|
<CardTitle className="text-lg">품목 정보</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<LineItemsTable<PurchaseItem>
|
||||||
<Table>
|
items={items}
|
||||||
<TableHeader>
|
getItemName={(item) => item.itemName}
|
||||||
<TableRow>
|
getQuantity={(item) => item.quantity}
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
getUnitPrice={(item) => item.unitPrice}
|
||||||
<TableHead>품목명</TableHead>
|
getSupplyAmount={(item) => item.supplyPrice}
|
||||||
<TableHead className="w-[100px] text-right">수량</TableHead>
|
getVat={(item) => item.vat}
|
||||||
<TableHead className="w-[120px] text-right">단가</TableHead>
|
getNote={(item) => item.note ?? ''}
|
||||||
<TableHead className="w-[120px] text-right">공급가액</TableHead>
|
onItemChange={handleItemChange}
|
||||||
<TableHead className="w-[100px] text-right">부가세</TableHead>
|
onAddItem={handleAddItem}
|
||||||
<TableHead>적요</TableHead>
|
onRemoveItem={handleRemoveItem}
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
totals={totals}
|
||||||
</TableRow>
|
isViewMode={isViewMode}
|
||||||
</TableHeader>
|
/>
|
||||||
<TableBody>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="text-center">{index + 1}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
value={item.itemName}
|
|
||||||
onChange={(e) => handleItemChange(index, 'itemName', e.target.value)}
|
|
||||||
placeholder="품목명"
|
|
||||||
disabled={isViewMode}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<QuantityInput
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(value) => handleItemChange(index, 'quantity', value ?? 0)}
|
|
||||||
disabled={isViewMode}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<CurrencyInput
|
|
||||||
value={item.unitPrice}
|
|
||||||
onChange={(value) => handleItemChange(index, 'unitPrice', value ?? 0)}
|
|
||||||
disabled={isViewMode}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-medium">
|
|
||||||
{formatAmount(item.supplyPrice)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatAmount(item.vat)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
value={item.note || ''}
|
|
||||||
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
|
|
||||||
placeholder="적요"
|
|
||||||
disabled={isViewMode}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{!isViewMode && items.length > 1 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-red-600 hover:text-red-700"
|
|
||||||
onClick={() => handleRemoveItem(index)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{/* 합계 행 */}
|
|
||||||
<TableRow className="bg-gray-50 font-medium">
|
|
||||||
<TableCell colSpan={4} className="text-right">
|
|
||||||
합계
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatAmount(totals.supplyAmount)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatAmount(totals.vat)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell colSpan={2}></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 품목 추가 버튼 */}
|
|
||||||
{!isViewMode && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button variant="outline" onClick={handleAddItem}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -691,4 +569,4 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
|||||||
renderForm={() => renderFormContent()}
|
renderForm={() => renderFormContent()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
Plus,
|
|
||||||
X,
|
|
||||||
Send,
|
Send,
|
||||||
FileText,
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -14,8 +12,6 @@ import { DatePicker } from '@/components/ui/date-picker';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
|
||||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -23,14 +19,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -42,13 +30,13 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
// 삭제 다이얼로그는 IntegratedDetailTemplate이 처리함
|
// 삭제 다이얼로그는 IntegratedDetailTemplate이 처리함
|
||||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||||
|
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
||||||
import { salesConfig } from './salesConfig';
|
import { salesConfig } from './salesConfig';
|
||||||
import type { SalesRecord, SalesItem, SalesType } from './types';
|
import type { SalesRecord, SalesItem, SalesType } from './types';
|
||||||
import { SALES_TYPE_OPTIONS } from './types';
|
import { SALES_TYPE_OPTIONS } from './types';
|
||||||
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
|
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getClients } from '../VendorManagement/actions';
|
import { getClients } from '../VendorManagement/actions';
|
||||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== Props =====
|
||||||
interface SalesDetailProps {
|
interface SalesDetailProps {
|
||||||
@@ -100,6 +88,16 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
|||||||
const [showEmailAlert, setShowEmailAlert] = useState(false);
|
const [showEmailAlert, setShowEmailAlert] = useState(false);
|
||||||
const [emailAlertMessage, setEmailAlertMessage] = useState('');
|
const [emailAlertMessage, setEmailAlertMessage] = useState('');
|
||||||
|
|
||||||
|
// ===== 품목 관리 (공통 훅) =====
|
||||||
|
const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<SalesItem>({
|
||||||
|
items,
|
||||||
|
setItems,
|
||||||
|
createEmptyItem,
|
||||||
|
supplyKey: 'supplyAmount',
|
||||||
|
vatKey: 'vat',
|
||||||
|
minItems: 1,
|
||||||
|
});
|
||||||
|
|
||||||
// ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) =====
|
// ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) =====
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadInitialData() {
|
async function loadInitialData() {
|
||||||
@@ -148,51 +146,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
|||||||
return clients.find(v => v.id === vendorId);
|
return clients.find(v => v.id === vendorId);
|
||||||
}, [clients, vendorId]);
|
}, [clients, vendorId]);
|
||||||
|
|
||||||
// ===== 합계 계산 =====
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const totalSupply = items.reduce((sum, item) => sum + item.supplyAmount, 0);
|
|
||||||
const totalVat = items.reduce((sum, item) => sum + item.vat, 0);
|
|
||||||
return {
|
|
||||||
supplyAmount: totalSupply,
|
|
||||||
vat: totalVat,
|
|
||||||
total: totalSupply + totalVat,
|
|
||||||
};
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
// ===== 품목 수정 핸들러 =====
|
|
||||||
const handleItemChange = useCallback((index: number, field: keyof SalesItem, value: string | number) => {
|
|
||||||
setItems(prev => {
|
|
||||||
const newItems = [...prev];
|
|
||||||
const item = { ...newItems[index] };
|
|
||||||
|
|
||||||
if (field === 'quantity' || field === 'unitPrice') {
|
|
||||||
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
|
|
||||||
item[field] = numValue;
|
|
||||||
// 자동 계산
|
|
||||||
item.supplyAmount = item.quantity * item.unitPrice;
|
|
||||||
item.vat = Math.floor(item.supplyAmount * 0.1);
|
|
||||||
} else {
|
|
||||||
(item as any)[field] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
newItems[index] = item;
|
|
||||||
return newItems;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ===== 품목 추가 =====
|
|
||||||
const handleAddItem = useCallback(() => {
|
|
||||||
setItems(prev => [...prev, createEmptyItem()]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ===== 품목 삭제 =====
|
|
||||||
const handleRemoveItem = useCallback((index: number) => {
|
|
||||||
setItems(prev => {
|
|
||||||
if (prev.length <= 1) return prev; // 최소 1개 유지
|
|
||||||
return prev.filter((_, i) => i !== index);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ===== 저장 (IntegratedDetailTemplate 호환) =====
|
// ===== 저장 (IntegratedDetailTemplate 호환) =====
|
||||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||||
if (!vendorId) {
|
if (!vendorId) {
|
||||||
@@ -341,101 +294,20 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
|||||||
<CardTitle className="text-lg">품목 정보</CardTitle>
|
<CardTitle className="text-lg">품목 정보</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<LineItemsTable<SalesItem>
|
||||||
<Table>
|
items={items}
|
||||||
<TableHeader>
|
getItemName={(item) => item.itemName}
|
||||||
<TableRow>
|
getQuantity={(item) => item.quantity}
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
getUnitPrice={(item) => item.unitPrice}
|
||||||
<TableHead>품목명</TableHead>
|
getSupplyAmount={(item) => item.supplyAmount}
|
||||||
<TableHead className="w-[100px] text-right">수량</TableHead>
|
getVat={(item) => item.vat}
|
||||||
<TableHead className="w-[120px] text-right">단가</TableHead>
|
getNote={(item) => item.note}
|
||||||
<TableHead className="w-[120px] text-right">공급가액</TableHead>
|
onItemChange={handleItemChange}
|
||||||
<TableHead className="w-[100px] text-right">부가세</TableHead>
|
onAddItem={handleAddItem}
|
||||||
<TableHead>적요</TableHead>
|
onRemoveItem={handleRemoveItem}
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
totals={totals}
|
||||||
</TableRow>
|
isViewMode={isViewMode}
|
||||||
</TableHeader>
|
/>
|
||||||
<TableBody>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="text-center">{index + 1}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
value={item.itemName}
|
|
||||||
onChange={(e) => handleItemChange(index, 'itemName', e.target.value)}
|
|
||||||
placeholder="품목명"
|
|
||||||
disabled={isViewMode}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<QuantityInput
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(value) => handleItemChange(index, 'quantity', value ?? 0)}
|
|
||||||
disabled={isViewMode}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<CurrencyInput
|
|
||||||
value={item.unitPrice}
|
|
||||||
onChange={(value) => handleItemChange(index, 'unitPrice', value ?? 0)}
|
|
||||||
disabled={isViewMode}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-medium">
|
|
||||||
{formatAmount(item.supplyAmount)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatAmount(item.vat)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
value={item.note}
|
|
||||||
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
|
|
||||||
placeholder="적요"
|
|
||||||
disabled={isViewMode}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{!isViewMode && items.length > 1 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-red-600 hover:text-red-700"
|
|
||||||
onClick={() => handleRemoveItem(index)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{/* 합계 행 */}
|
|
||||||
<TableRow className="bg-gray-50 font-medium">
|
|
||||||
<TableCell colSpan={4} className="text-right">
|
|
||||||
합계
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatAmount(totals.supplyAmount)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatAmount(totals.vat)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell colSpan={2}></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 품목 추가 버튼 */}
|
|
||||||
{!isViewMode && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button variant="outline" onClick={handleAddItem}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -572,4 +444,4 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
|||||||
renderForm={() => renderFormContent()}
|
renderForm={() => renderFormContent()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
|
||||||
Banknote,
|
|
||||||
List,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { formatNumber } from '@/lib/utils/amount';
|
|
||||||
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 {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
||||||
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
|
||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import type { WithdrawalRecord, WithdrawalType } from './types';
|
|
||||||
import { WITHDRAWAL_TYPE_SELECTOR_OPTIONS } from './types';
|
|
||||||
import {
|
|
||||||
getWithdrawalById,
|
|
||||||
createWithdrawal,
|
|
||||||
updateWithdrawal,
|
|
||||||
deleteWithdrawal,
|
|
||||||
getVendors,
|
|
||||||
} from './actions';
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
interface WithdrawalDetailProps {
|
|
||||||
withdrawalId: string;
|
|
||||||
mode: 'view' | 'edit' | 'new';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const isViewMode = mode === 'view';
|
|
||||||
const isNewMode = mode === 'new';
|
|
||||||
|
|
||||||
// ===== 폼 상태 =====
|
|
||||||
const [withdrawalDate, setWithdrawalDate] = useState('');
|
|
||||||
const [accountName, setAccountName] = useState('');
|
|
||||||
const [recipientName, setRecipientName] = useState('');
|
|
||||||
const [withdrawalAmount, setWithdrawalAmount] = useState(0);
|
|
||||||
const [note, setNote] = useState('');
|
|
||||||
const [vendorId, setVendorId] = useState('');
|
|
||||||
const [withdrawalType, setWithdrawalType] = useState<WithdrawalType>('unset');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
|
|
||||||
// ===== 초기 데이터 로드 (거래처 + 출금 상세 병렬) =====
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInitialData = async () => {
|
|
||||||
const isEditMode = withdrawalId && !isNewMode;
|
|
||||||
if (isEditMode) setIsLoading(true);
|
|
||||||
|
|
||||||
const [vendorsResult, withdrawalResult] = await Promise.all([
|
|
||||||
getVendors(),
|
|
||||||
isEditMode ? getWithdrawalById(withdrawalId) : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 거래처 목록
|
|
||||||
if (vendorsResult.success) {
|
|
||||||
setVendors(vendorsResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 출금 상세
|
|
||||||
if (withdrawalResult) {
|
|
||||||
if (withdrawalResult.success && withdrawalResult.data) {
|
|
||||||
setWithdrawalDate(withdrawalResult.data.withdrawalDate);
|
|
||||||
setAccountName(withdrawalResult.data.accountName);
|
|
||||||
setRecipientName(withdrawalResult.data.recipientName);
|
|
||||||
setWithdrawalAmount(withdrawalResult.data.withdrawalAmount);
|
|
||||||
setNote(withdrawalResult.data.note);
|
|
||||||
setVendorId(withdrawalResult.data.vendorId);
|
|
||||||
setWithdrawalType(withdrawalResult.data.withdrawalType);
|
|
||||||
} else {
|
|
||||||
toast.error(withdrawalResult.error || '출금 내역을 불러오는데 실패했습니다.');
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadInitialData();
|
|
||||||
}, [withdrawalId, isNewMode]);
|
|
||||||
|
|
||||||
// ===== 저장 핸들러 =====
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!vendorId) {
|
|
||||||
toast.error('거래처를 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (withdrawalType === 'unset') {
|
|
||||||
toast.error('출금 유형을 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
const formData: Partial<WithdrawalRecord> = {
|
|
||||||
withdrawalDate,
|
|
||||||
accountName,
|
|
||||||
recipientName,
|
|
||||||
withdrawalAmount,
|
|
||||||
note,
|
|
||||||
vendorId,
|
|
||||||
vendorName: vendors.find(v => v.id === vendorId)?.name || '',
|
|
||||||
withdrawalType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = isNewMode
|
|
||||||
? await createWithdrawal(formData)
|
|
||||||
: await updateWithdrawal(withdrawalId, formData);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(isNewMode ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
|
|
||||||
router.push('/ko/accounting/withdrawals');
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || '저장에 실패했습니다.');
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [withdrawalId, withdrawalDate, accountName, recipientName, withdrawalAmount, note, vendorId, vendors, withdrawalType, router, isNewMode]);
|
|
||||||
|
|
||||||
// ===== 취소 핸들러 =====
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
if (isNewMode) {
|
|
||||||
router.push('/ko/accounting/withdrawals');
|
|
||||||
} else {
|
|
||||||
router.push(`/ko/accounting/withdrawals/${withdrawalId}?mode=view`);
|
|
||||||
}
|
|
||||||
}, [router, withdrawalId, isNewMode]);
|
|
||||||
|
|
||||||
// ===== 목록으로 이동 =====
|
|
||||||
const handleBack = useCallback(() => {
|
|
||||||
router.push('/ko/accounting/withdrawals');
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
// ===== 수정 모드로 이동 =====
|
|
||||||
const handleEdit = useCallback(() => {
|
|
||||||
router.push(`/ko/accounting/withdrawals/${withdrawalId}?mode=edit`);
|
|
||||||
}, [router, withdrawalId]);
|
|
||||||
|
|
||||||
// ===== 삭제 다이얼로그 =====
|
|
||||||
const deleteDialog = useDeleteDialog({
|
|
||||||
onDelete: async (id) => deleteWithdrawal(id),
|
|
||||||
onSuccess: () => router.push('/ko/accounting/withdrawals'),
|
|
||||||
entityName: '출금',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<PageHeader
|
|
||||||
title={isNewMode ? '출금 등록' : isViewMode ? '출금 상세' : '출금 수정'}
|
|
||||||
description="출금 상세 내역을 등록합니다"
|
|
||||||
icon={Banknote}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 헤더 액션 버튼 */}
|
|
||||||
<div className="flex items-center justify-end gap-2 mb-6">
|
|
||||||
{/* view 모드: [목록] [삭제] [수정] */}
|
|
||||||
{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={() => deleteDialog.single.open(withdrawalId)}
|
|
||||||
disabled={deleteDialog.isPending}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
/* edit/new 모드: [취소] [저장/등록] */
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-blue-500 hover:bg-blue-600"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? '처리중...' : 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="withdrawalDate">출금일</Label>
|
|
||||||
<DatePicker
|
|
||||||
value={withdrawalDate}
|
|
||||||
disabled
|
|
||||||
className="bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출금계좌 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="accountName">출금계좌</Label>
|
|
||||||
<Input
|
|
||||||
id="accountName"
|
|
||||||
value={accountName}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
className="bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 수취인명 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="recipientName">수취인명</Label>
|
|
||||||
<Input
|
|
||||||
id="recipientName"
|
|
||||||
value={recipientName}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
className="bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출금금액 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="withdrawalAmount">출금금액</Label>
|
|
||||||
<Input
|
|
||||||
id="withdrawalAmount"
|
|
||||||
value={formatNumber(withdrawalAmount)}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
className="bg-gray-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 적요 */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor="note">적요</Label>
|
|
||||||
<Input
|
|
||||||
id="note"
|
|
||||||
value={note}
|
|
||||||
onChange={(e) => setNote(e.target.value)}
|
|
||||||
placeholder="적요를 입력해주세요"
|
|
||||||
disabled={isViewMode}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
{vendors.map((vendor) => (
|
|
||||||
<SelectItem key={vendor.id} value={vendor.id}>
|
|
||||||
{vendor.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출금 유형 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="withdrawalType">
|
|
||||||
출금 유형 <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Select value={withdrawalType} onValueChange={(v) => setWithdrawalType(v as WithdrawalType)} disabled={isViewMode}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ===== 삭제 확인 다이얼로그 ===== */}
|
|
||||||
<DeleteConfirmDialog
|
|
||||||
open={deleteDialog.single.isOpen}
|
|
||||||
onOpenChange={deleteDialog.single.onOpenChange}
|
|
||||||
onConfirm={deleteDialog.single.confirm}
|
|
||||||
title="출금 삭제"
|
|
||||||
description="이 출금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
|
|
||||||
loading={deleteDialog.isPending}
|
|
||||||
/>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -23,12 +23,13 @@ import {
|
|||||||
SalesStatusSection,
|
SalesStatusSection,
|
||||||
PurchaseStatusSection,
|
PurchaseStatusSection,
|
||||||
DailyProductionSection,
|
DailyProductionSection,
|
||||||
|
ShipmentSection,
|
||||||
UnshippedSection,
|
UnshippedSection,
|
||||||
ConstructionSection,
|
ConstructionSection,
|
||||||
DailyAttendanceSection,
|
DailyAttendanceSection,
|
||||||
} from './sections';
|
} from './sections';
|
||||||
import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig } from './types';
|
import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig, SectionKey } from './types';
|
||||||
import { DEFAULT_DASHBOARD_SETTINGS } from './types';
|
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } from './types';
|
||||||
import { ScheduleDetailModal, DetailModal } from './modals';
|
import { ScheduleDetailModal, DetailModal } from './modals';
|
||||||
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
|
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
|
||||||
import { mockData } from './mockData';
|
import { mockData } from './mockData';
|
||||||
@@ -138,7 +139,34 @@ export function CEODashboard() {
|
|||||||
const saved = localStorage.getItem('ceo-dashboard-settings');
|
const saved = localStorage.getItem('ceo-dashboard-settings');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
setDashboardSettings(JSON.parse(saved));
|
const parsed = JSON.parse(saved);
|
||||||
|
// 구버전 설정 마이그레이션: object → boolean 변환
|
||||||
|
if (typeof parsed.receivable === 'object' && parsed.receivable !== null) {
|
||||||
|
parsed.receivable = parsed.receivable.enabled ?? true;
|
||||||
|
}
|
||||||
|
if (typeof parsed.salesStatus === 'object' && parsed.salesStatus !== null) {
|
||||||
|
parsed.salesStatus = parsed.salesStatus.enabled ?? true;
|
||||||
|
}
|
||||||
|
if (typeof parsed.purchaseStatus === 'object' && parsed.purchaseStatus !== null) {
|
||||||
|
parsed.purchaseStatus = parsed.purchaseStatus.enabled ?? true;
|
||||||
|
}
|
||||||
|
// sectionOrder 마이그레이션
|
||||||
|
if (!parsed.sectionOrder) {
|
||||||
|
parsed.sectionOrder = DEFAULT_SECTION_ORDER;
|
||||||
|
} else {
|
||||||
|
const currentOrder: SectionKey[] = parsed.sectionOrder;
|
||||||
|
// 새 섹션이 추가됐으면 끝에 추가
|
||||||
|
for (const key of DEFAULT_SECTION_ORDER) {
|
||||||
|
if (!currentOrder.includes(key)) {
|
||||||
|
currentOrder.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 삭제된 섹션 필터링
|
||||||
|
parsed.sectionOrder = currentOrder.filter((key: SectionKey) =>
|
||||||
|
DEFAULT_SECTION_ORDER.includes(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setDashboardSettings(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
// 파싱 실패 시 기본값 유지
|
// 파싱 실패 시 기본값 유지
|
||||||
}
|
}
|
||||||
@@ -276,6 +304,187 @@ export function CEODashboard() {
|
|||||||
setSelectedSchedule(null);
|
setSelectedSchedule(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 섹션 순서
|
||||||
|
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
||||||
|
|
||||||
|
// 섹션 렌더링 함수
|
||||||
|
const renderDashboardSection = (key: SectionKey): React.ReactNode => {
|
||||||
|
switch (key) {
|
||||||
|
case 'todayIssueList':
|
||||||
|
if (!dashboardSettings.todayIssueList) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<TodayIssueSection items={data.todayIssueList} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'dailyReport':
|
||||||
|
if (!dashboardSettings.dailyReport) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<EnhancedDailyReportSection
|
||||||
|
data={data.dailyReport}
|
||||||
|
onClick={handleDailyReportClick}
|
||||||
|
/>
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'statusBoard':
|
||||||
|
if (!(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled)) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<EnhancedStatusBoardSection
|
||||||
|
items={data.todayIssue}
|
||||||
|
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
|
||||||
|
/>
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'monthlyExpense':
|
||||||
|
if (!dashboardSettings.monthlyExpense) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<EnhancedMonthlyExpenseSection
|
||||||
|
data={data.monthlyExpense}
|
||||||
|
onCardClick={handleMonthlyExpenseCardClick}
|
||||||
|
/>
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'cardManagement':
|
||||||
|
if (!dashboardSettings.cardManagement) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<CardManagementSection
|
||||||
|
data={data.cardManagement}
|
||||||
|
onCardClick={handleCardManagementCardClick}
|
||||||
|
/>
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'entertainment':
|
||||||
|
if (!dashboardSettings.entertainment.enabled) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<EntertainmentSection
|
||||||
|
data={data.entertainment}
|
||||||
|
onCardClick={handleEntertainmentCardClick}
|
||||||
|
/>
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'welfare':
|
||||||
|
if (!dashboardSettings.welfare.enabled) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<WelfareSection
|
||||||
|
data={data.welfare}
|
||||||
|
onCardClick={handleWelfareCardClick}
|
||||||
|
/>
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'receivable':
|
||||||
|
if (!dashboardSettings.receivable) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<ReceivableSection data={data.receivable} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'debtCollection':
|
||||||
|
if (!dashboardSettings.debtCollection) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<DebtCollectionSection data={data.debtCollection} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'vat':
|
||||||
|
if (!dashboardSettings.vat) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<VatSection data={data.vat} onClick={handleVatClick} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'calendar':
|
||||||
|
if (!dashboardSettings.calendar) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key} minHeight={500}>
|
||||||
|
<CalendarSection
|
||||||
|
schedules={data.calendarSchedules}
|
||||||
|
issues={data.todayIssueList}
|
||||||
|
onScheduleClick={handleScheduleClick}
|
||||||
|
onScheduleEdit={handleScheduleEdit}
|
||||||
|
/>
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'salesStatus':
|
||||||
|
if (!(dashboardSettings.salesStatus ?? true) || !data.salesStatus) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key} minHeight={600}>
|
||||||
|
<SalesStatusSection data={data.salesStatus} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'purchaseStatus':
|
||||||
|
if (!(dashboardSettings.purchaseStatus ?? true) || !data.purchaseStatus) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key} minHeight={600}>
|
||||||
|
<PurchaseStatusSection data={data.purchaseStatus} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'production':
|
||||||
|
if (!(dashboardSettings.production ?? true) || !data.dailyProduction) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key} minHeight={400}>
|
||||||
|
<DailyProductionSection
|
||||||
|
data={data.dailyProduction}
|
||||||
|
showShipment={false}
|
||||||
|
/>
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'shipment':
|
||||||
|
if (!(dashboardSettings.shipment ?? true) || !data.dailyProduction) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<ShipmentSection data={data.dailyProduction} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'unshipped':
|
||||||
|
if (!(dashboardSettings.unshipped ?? true) || !data.unshipped) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<UnshippedSection data={data.unshipped} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'construction':
|
||||||
|
if (!(dashboardSettings.construction ?? true) || !data.constructionData) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<ConstructionSection data={data.constructionData} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'attendance':
|
||||||
|
if (!(dashboardSettings.attendance ?? true) || !data.dailyAttendance) return null;
|
||||||
|
return (
|
||||||
|
<LazySection key={key}>
|
||||||
|
<DailyAttendanceSection data={data.dailyAttendance} />
|
||||||
|
</LazySection>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
@@ -312,158 +521,7 @@ export function CEODashboard() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 오늘의 이슈 (새 리스트 형태) */}
|
{sectionOrder.map(renderDashboardSection)}
|
||||||
{dashboardSettings.todayIssueList && (
|
|
||||||
<LazySection>
|
|
||||||
<TodayIssueSection items={data.todayIssueList} />
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 일일 일보 (Enhanced) */}
|
|
||||||
{dashboardSettings.dailyReport && (
|
|
||||||
<LazySection>
|
|
||||||
<EnhancedDailyReportSection
|
|
||||||
data={data.dailyReport}
|
|
||||||
onClick={handleDailyReportClick}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 현황판 (Enhanced - 아이콘 + 컬러 테마) */}
|
|
||||||
{(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && (
|
|
||||||
<LazySection>
|
|
||||||
<EnhancedStatusBoardSection
|
|
||||||
items={data.todayIssue}
|
|
||||||
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 당월 예상 지출 내역 (Enhanced) */}
|
|
||||||
{dashboardSettings.monthlyExpense && (
|
|
||||||
<LazySection>
|
|
||||||
<EnhancedMonthlyExpenseSection
|
|
||||||
data={data.monthlyExpense}
|
|
||||||
onCardClick={handleMonthlyExpenseCardClick}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 카드/가지급금 관리 */}
|
|
||||||
{dashboardSettings.cardManagement && (
|
|
||||||
<LazySection>
|
|
||||||
<CardManagementSection
|
|
||||||
data={data.cardManagement}
|
|
||||||
onCardClick={handleCardManagementCardClick}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 접대비 현황 */}
|
|
||||||
{dashboardSettings.entertainment.enabled && (
|
|
||||||
<LazySection>
|
|
||||||
<EntertainmentSection
|
|
||||||
data={data.entertainment}
|
|
||||||
onCardClick={handleEntertainmentCardClick}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 복리후생비 현황 */}
|
|
||||||
{dashboardSettings.welfare.enabled && (
|
|
||||||
<LazySection>
|
|
||||||
<WelfareSection
|
|
||||||
data={data.welfare}
|
|
||||||
onCardClick={handleWelfareCardClick}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 미수금 현황 */}
|
|
||||||
{dashboardSettings.receivable.enabled && (
|
|
||||||
<LazySection>
|
|
||||||
<ReceivableSection data={data.receivable} />
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 채권추심 현황 */}
|
|
||||||
{dashboardSettings.debtCollection && (
|
|
||||||
<LazySection>
|
|
||||||
<DebtCollectionSection data={data.debtCollection} />
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 부가세 현황 */}
|
|
||||||
{dashboardSettings.vat && (
|
|
||||||
<LazySection>
|
|
||||||
<VatSection data={data.vat} onClick={handleVatClick} />
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 캘린더 */}
|
|
||||||
{dashboardSettings.calendar && (
|
|
||||||
<LazySection minHeight={500}>
|
|
||||||
<CalendarSection
|
|
||||||
schedules={data.calendarSchedules}
|
|
||||||
issues={data.todayIssueList}
|
|
||||||
onScheduleClick={handleScheduleClick}
|
|
||||||
onScheduleEdit={handleScheduleEdit}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ===== 신규 섹션 (캘린더 하단) ===== */}
|
|
||||||
|
|
||||||
{/* 매출 현황 */}
|
|
||||||
{(dashboardSettings.salesStatus?.enabled ?? true) && data.salesStatus && (
|
|
||||||
<LazySection minHeight={600}>
|
|
||||||
<SalesStatusSection
|
|
||||||
data={data.salesStatus}
|
|
||||||
showDailyDetail={dashboardSettings.salesStatus?.dailySalesDetail ?? true}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 매입 현황 */}
|
|
||||||
{(dashboardSettings.purchaseStatus?.enabled ?? true) && data.purchaseStatus && (
|
|
||||||
<LazySection minHeight={600}>
|
|
||||||
<PurchaseStatusSection
|
|
||||||
data={data.purchaseStatus}
|
|
||||||
showDailyDetail={dashboardSettings.purchaseStatus?.dailyPurchaseDetail ?? true}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 생산 현황 */}
|
|
||||||
{(dashboardSettings.production ?? true) && data.dailyProduction && (
|
|
||||||
<LazySection minHeight={400}>
|
|
||||||
<DailyProductionSection
|
|
||||||
data={data.dailyProduction}
|
|
||||||
showShipment={dashboardSettings.shipment ?? true}
|
|
||||||
/>
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 미출고 내역 */}
|
|
||||||
{(dashboardSettings.unshipped ?? true) && data.unshipped && (
|
|
||||||
<LazySection>
|
|
||||||
<UnshippedSection data={data.unshipped} />
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 시공 현황 */}
|
|
||||||
{(dashboardSettings.construction ?? true) && data.constructionData && (
|
|
||||||
<LazySection>
|
|
||||||
<ConstructionSection data={data.constructionData} />
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 근태 현황 */}
|
|
||||||
{(dashboardSettings.attendance ?? true) && data.dailyAttendance && (
|
|
||||||
<LazySection>
|
|
||||||
<DailyAttendanceSection data={data.dailyAttendance} />
|
|
||||||
</LazySection>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 일정 상세 모달 */}
|
{/* 일정 상세 모달 */}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||||
import { NumberInput } from '@/components/ui/number-input';
|
import { NumberInput } from '@/components/ui/number-input';
|
||||||
import {
|
import {
|
||||||
@@ -34,8 +32,9 @@ import type {
|
|||||||
CompanyType,
|
CompanyType,
|
||||||
WelfareLimitType,
|
WelfareLimitType,
|
||||||
WelfareCalculationType,
|
WelfareCalculationType,
|
||||||
|
SectionKey,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { DEFAULT_DASHBOARD_SETTINGS } from '../types';
|
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types';
|
||||||
|
|
||||||
// 현황판 항목 라벨 (구 오늘의 이슈)
|
// 현황판 항목 라벨 (구 오늘의 이슈)
|
||||||
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
|
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
|
||||||
@@ -68,10 +67,17 @@ export function DashboardSettingsDialog({
|
|||||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
entertainment: false,
|
entertainment: false,
|
||||||
welfare: false,
|
welfare: false,
|
||||||
receivable: false,
|
statusBoard: false,
|
||||||
companyTypeInfo: false,
|
companyTypeInfo: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DnD 상태
|
||||||
|
const [draggedSection, setDraggedSection] = useState<SectionKey | null>(null);
|
||||||
|
const [dragOverSection, setDragOverSection] = useState<SectionKey | null>(null);
|
||||||
|
|
||||||
|
// 섹션 순서
|
||||||
|
const sectionOrder = localSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
||||||
|
|
||||||
// settings가 변경될 때 로컬 상태 업데이트
|
// settings가 변경될 때 로컬 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalSettings(settings);
|
setLocalSettings(settings);
|
||||||
@@ -101,7 +107,6 @@ export function DashboardSettingsDialog({
|
|||||||
...prev,
|
...prev,
|
||||||
statusBoard: {
|
statusBoard: {
|
||||||
enabled,
|
enabled,
|
||||||
// 전체 OFF 시 개별 항목도 모두 OFF
|
|
||||||
items: enabled
|
items: enabled
|
||||||
? statusBoardItems
|
? statusBoardItems
|
||||||
: Object.keys(statusBoardItems).reduce(
|
: Object.keys(statusBoardItems).reduce(
|
||||||
@@ -109,7 +114,6 @@ export function DashboardSettingsDialog({
|
|||||||
{} as TodayIssueSettings
|
{} as TodayIssueSettings
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
// Legacy 호환성 유지
|
|
||||||
todayIssue: {
|
todayIssue: {
|
||||||
enabled,
|
enabled,
|
||||||
items: enabled
|
items: enabled
|
||||||
@@ -139,7 +143,6 @@ export function DashboardSettingsDialog({
|
|||||||
enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled,
|
enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled,
|
||||||
items: newItems,
|
items: newItems,
|
||||||
},
|
},
|
||||||
// Legacy 호환성 유지
|
|
||||||
todayIssue: {
|
todayIssue: {
|
||||||
...prev.todayIssue,
|
...prev.todayIssue,
|
||||||
items: newItems,
|
items: newItems,
|
||||||
@@ -175,29 +178,12 @@ export function DashboardSettingsDialog({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매출 현황 설정 변경
|
// 매출/매입/미수금 현황 토글 (단순 boolean)
|
||||||
const handleSalesStatusChange = useCallback(
|
const handleSimpleSectionToggle = useCallback(
|
||||||
(key: 'enabled' | 'dailySalesDetail', value: boolean) => {
|
(section: 'salesStatus' | 'purchaseStatus' | 'receivable', enabled: boolean) => {
|
||||||
setLocalSettings((prev) => ({
|
setLocalSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
salesStatus: {
|
[section]: enabled,
|
||||||
...prev.salesStatus,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 매입 현황 설정 변경
|
|
||||||
const handlePurchaseStatusChange = useCallback(
|
|
||||||
(key: 'enabled' | 'dailyPurchaseDetail', value: boolean) => {
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
purchaseStatus: {
|
|
||||||
...prev.purchaseStatus,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@@ -237,19 +223,18 @@ export function DashboardSettingsDialog({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 미수금 설정 변경
|
// 섹션 순서 변경 (DnD)
|
||||||
const handleReceivableChange = useCallback(
|
const handleReorder = useCallback((sourceKey: SectionKey, targetKey: SectionKey) => {
|
||||||
(key: 'enabled' | 'topCompanies', value: boolean) => {
|
setLocalSettings((prev) => {
|
||||||
setLocalSettings((prev) => ({
|
const order = [...(prev.sectionOrder ?? DEFAULT_SECTION_ORDER)];
|
||||||
...prev,
|
const sourceIdx = order.indexOf(sourceKey);
|
||||||
receivable: {
|
const targetIdx = order.indexOf(targetKey);
|
||||||
...prev.receivable,
|
if (sourceIdx === -1 || targetIdx === -1) return prev;
|
||||||
[key]: value,
|
order.splice(sourceIdx, 1);
|
||||||
},
|
order.splice(targetIdx, 0, sourceKey);
|
||||||
}));
|
return { ...prev, sectionOrder: order };
|
||||||
},
|
});
|
||||||
[]
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
// 저장
|
// 저장
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
@@ -259,7 +244,7 @@ export function DashboardSettingsDialog({
|
|||||||
|
|
||||||
// 취소
|
// 취소
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setLocalSettings(settings); // 원래 설정으로 복원
|
setLocalSettings(settings);
|
||||||
onClose();
|
onClose();
|
||||||
}, [settings, onClose]);
|
}, [settings, onClose]);
|
||||||
|
|
||||||
@@ -297,6 +282,7 @@ export function DashboardSettingsDialog({
|
|||||||
isExpanded,
|
isExpanded,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
children,
|
children,
|
||||||
|
showGrip,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
@@ -305,6 +291,7 @@ export function DashboardSettingsDialog({
|
|||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
onToggleExpand?: () => void;
|
onToggleExpand?: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
showGrip?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
|
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
|
||||||
<div
|
<div
|
||||||
@@ -314,6 +301,9 @@ export function DashboardSettingsDialog({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{showGrip && (
|
||||||
|
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab flex-shrink-0" />
|
||||||
|
)}
|
||||||
{hasExpand && (
|
{hasExpand && (
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button type="button" className="p-1 hover:bg-gray-300 rounded">
|
<button type="button" className="p-1 hover:bg-gray-300 rounded">
|
||||||
@@ -337,83 +327,93 @@ export function DashboardSettingsDialog({
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
// 섹션 렌더링 함수
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
const renderSection = (key: SectionKey): React.ReactNode => {
|
||||||
<DialogContent className="w-[95vw] max-w-[450px] sm:max-w-[450px] max-h-[85vh] overflow-y-auto bg-white border-gray-200 p-0">
|
switch (key) {
|
||||||
<DialogHeader className="p-4 border-b border-gray-200">
|
case 'todayIssueList':
|
||||||
<DialogTitle className="text-lg font-bold text-gray-900">항목 설정</DialogTitle>
|
return (
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-3 p-4">
|
|
||||||
{/* 오늘의 이슈 (리스트 형태) */}
|
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="오늘의 이슈"
|
label={SECTION_LABELS.todayIssueList}
|
||||||
checked={localSettings.todayIssueList}
|
checked={localSettings.todayIssueList}
|
||||||
onCheckedChange={handleTodayIssueListToggle}
|
onCheckedChange={handleTodayIssueListToggle}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 자금현황 */}
|
case 'dailyReport':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="자금현황"
|
label={SECTION_LABELS.dailyReport}
|
||||||
checked={localSettings.dailyReport}
|
checked={localSettings.dailyReport}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
|
case 'statusBoard':
|
||||||
<div className="space-y-0 rounded-lg overflow-hidden">
|
return (
|
||||||
<div className="flex items-center justify-between py-3 px-4 bg-gray-200">
|
|
||||||
<span className="text-sm font-medium text-gray-800">현황판</span>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled}
|
|
||||||
onCheckedChange={handleStatusBoardToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{(localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled) && (
|
|
||||||
<div className="bg-gray-50">
|
|
||||||
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
|
|
||||||
(key) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-center justify-between py-2.5 px-6 border-t border-gray-200"
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
{STATUS_BOARD_LABELS[key]}
|
|
||||||
</span>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[key]}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleStatusBoardItemToggle(key, checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 당월 예상 지출 내역 */}
|
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="당월 예상 지출 내역"
|
label={SECTION_LABELS.statusBoard}
|
||||||
|
checked={localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled}
|
||||||
|
onCheckedChange={handleStatusBoardToggle}
|
||||||
|
hasExpand
|
||||||
|
isExpanded={expandedSections.statusBoard}
|
||||||
|
onToggleExpand={() => toggleSection('statusBoard')}
|
||||||
|
showGrip
|
||||||
|
>
|
||||||
|
<div className="space-y-0">
|
||||||
|
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
|
||||||
|
(itemKey) => (
|
||||||
|
<div
|
||||||
|
key={itemKey}
|
||||||
|
className="flex items-center justify-between py-2.5 px-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{STATUS_BOARD_LABELS[itemKey]}
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[itemKey]}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleStatusBoardItemToggle(itemKey, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'monthlyExpense':
|
||||||
|
return (
|
||||||
|
<SectionRow
|
||||||
|
label={SECTION_LABELS.monthlyExpense}
|
||||||
checked={localSettings.monthlyExpense}
|
checked={localSettings.monthlyExpense}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('monthlyExpense', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('monthlyExpense', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 카드/가지급금 관리 */}
|
case 'cardManagement':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="카드/가지급금 관리"
|
label={SECTION_LABELS.cardManagement}
|
||||||
checked={localSettings.cardManagement}
|
checked={localSettings.cardManagement}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('cardManagement', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('cardManagement', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 접대비 현황 */}
|
case 'entertainment':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="접대비 현황"
|
label={SECTION_LABELS.entertainment}
|
||||||
checked={localSettings.entertainment.enabled}
|
checked={localSettings.entertainment.enabled}
|
||||||
onCheckedChange={(checked) => handleEntertainmentChange('enabled', checked)}
|
onCheckedChange={(checked) => handleEntertainmentChange('enabled', checked)}
|
||||||
hasExpand
|
hasExpand
|
||||||
isExpanded={expandedSections.entertainment}
|
isExpanded={expandedSections.entertainment}
|
||||||
onToggleExpand={() => toggleSection('entertainment')}
|
onToggleExpand={() => toggleSection('entertainment')}
|
||||||
|
showGrip
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -621,15 +621,18 @@ export function DashboardSettingsDialog({
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
</SectionRow>
|
</SectionRow>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 복리후생비 현황 */}
|
case 'welfare':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="복리후생비 현황"
|
label={SECTION_LABELS.welfare}
|
||||||
checked={localSettings.welfare.enabled}
|
checked={localSettings.welfare.enabled}
|
||||||
onCheckedChange={(checked) => handleWelfareChange('enabled', checked)}
|
onCheckedChange={(checked) => handleWelfareChange('enabled', checked)}
|
||||||
hasExpand
|
hasExpand
|
||||||
isExpanded={expandedSections.welfare}
|
isExpanded={expandedSections.welfare}
|
||||||
onToggleExpand={() => toggleSection('welfare')}
|
onToggleExpand={() => toggleSection('welfare')}
|
||||||
|
showGrip
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -713,124 +716,177 @@ export function DashboardSettingsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionRow>
|
</SectionRow>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 미수금 현황 */}
|
case 'receivable':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="미수금 현황"
|
label={SECTION_LABELS.receivable}
|
||||||
checked={localSettings.receivable.enabled}
|
checked={localSettings.receivable ?? true}
|
||||||
onCheckedChange={(checked) => handleReceivableChange('enabled', checked)}
|
onCheckedChange={(checked) => handleSimpleSectionToggle('receivable', checked)}
|
||||||
hasExpand
|
showGrip
|
||||||
isExpanded={expandedSections.receivable}
|
/>
|
||||||
onToggleExpand={() => toggleSection('receivable')}
|
);
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-600">미수금 상위 회사 현황</span>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={localSettings.receivable.topCompanies}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleReceivableChange('topCompanies', checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SectionRow>
|
|
||||||
|
|
||||||
{/* 채권추심 현황 */}
|
case 'debtCollection':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="채권추심 현황"
|
label={SECTION_LABELS.debtCollection}
|
||||||
checked={localSettings.debtCollection}
|
checked={localSettings.debtCollection}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('debtCollection', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('debtCollection', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 부가세 현황 */}
|
case 'vat':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="부가세 현황"
|
label={SECTION_LABELS.vat}
|
||||||
checked={localSettings.vat}
|
checked={localSettings.vat}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('vat', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('vat', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 캘린더 */}
|
case 'calendar':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="캘린더"
|
label={SECTION_LABELS.calendar}
|
||||||
checked={localSettings.calendar}
|
checked={localSettings.calendar}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('calendar', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('calendar', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* ===== 신규 섹션 ===== */}
|
case 'salesStatus':
|
||||||
|
return (
|
||||||
{/* 매출 현황 */}
|
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="매출 현황"
|
label={SECTION_LABELS.salesStatus}
|
||||||
checked={localSettings.salesStatus?.enabled ?? true}
|
checked={localSettings.salesStatus ?? true}
|
||||||
onCheckedChange={(checked) => handleSalesStatusChange('enabled', checked)}
|
onCheckedChange={(checked) => handleSimpleSectionToggle('salesStatus', checked)}
|
||||||
hasExpand
|
showGrip
|
||||||
isExpanded={expandedSections.salesStatus}
|
/>
|
||||||
onToggleExpand={() => toggleSection('salesStatus')}
|
);
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-600">일별 매출 내역</span>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={localSettings.salesStatus?.dailySalesDetail ?? true}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleSalesStatusChange('dailySalesDetail', checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SectionRow>
|
|
||||||
|
|
||||||
{/* 매입 현황 */}
|
case 'purchaseStatus':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="매입 현황"
|
label={SECTION_LABELS.purchaseStatus}
|
||||||
checked={localSettings.purchaseStatus?.enabled ?? true}
|
checked={localSettings.purchaseStatus ?? true}
|
||||||
onCheckedChange={(checked) => handlePurchaseStatusChange('enabled', checked)}
|
onCheckedChange={(checked) => handleSimpleSectionToggle('purchaseStatus', checked)}
|
||||||
hasExpand
|
showGrip
|
||||||
isExpanded={expandedSections.purchaseStatus}
|
/>
|
||||||
onToggleExpand={() => toggleSection('purchaseStatus')}
|
);
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-600">일별 매입 현황</span>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={localSettings.purchaseStatus?.dailyPurchaseDetail ?? true}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handlePurchaseStatusChange('dailyPurchaseDetail', checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SectionRow>
|
|
||||||
|
|
||||||
{/* 생산 현황 */}
|
case 'production':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="생산 현황"
|
label={SECTION_LABELS.production}
|
||||||
checked={localSettings.production ?? true}
|
checked={localSettings.production ?? true}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('production', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('production', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 출고 현황 */}
|
case 'shipment':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="출고 현황"
|
label={SECTION_LABELS.shipment}
|
||||||
checked={localSettings.shipment ?? true}
|
checked={localSettings.shipment ?? true}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('shipment', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('shipment', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 미출고 내역 */}
|
case 'unshipped':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="미출고 내역"
|
label={SECTION_LABELS.unshipped}
|
||||||
checked={localSettings.unshipped ?? true}
|
checked={localSettings.unshipped ?? true}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('unshipped', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('unshipped', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 시공 현황 */}
|
case 'construction':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="시공 현황"
|
label={SECTION_LABELS.construction}
|
||||||
checked={localSettings.construction ?? true}
|
checked={localSettings.construction ?? true}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('construction', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('construction', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 근태 현황 */}
|
case 'attendance':
|
||||||
|
return (
|
||||||
<SectionRow
|
<SectionRow
|
||||||
label="근태 현황"
|
label={SECTION_LABELS.attendance}
|
||||||
checked={localSettings.attendance ?? true}
|
checked={localSettings.attendance ?? true}
|
||||||
onCheckedChange={(checked) => handleSectionToggle('attendance', checked)}
|
onCheckedChange={(checked) => handleSectionToggle('attendance', checked)}
|
||||||
|
showGrip
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||||
|
<DialogContent className="w-[95vw] max-w-[450px] sm:max-w-[450px] max-h-[85vh] overflow-y-auto bg-white border-gray-200 p-0">
|
||||||
|
<DialogHeader className="p-4 border-b border-gray-200">
|
||||||
|
<DialogTitle className="text-lg font-bold text-gray-900">항목 설정</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{sectionOrder.map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
'application/json',
|
||||||
|
JSON.stringify({ type: 'dashboard-section', key })
|
||||||
|
);
|
||||||
|
setDraggedSection(key);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggedSection(null);
|
||||||
|
setDragOverSection(null);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (key !== draggedSection) {
|
||||||
|
setDragOverSection(key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOverSection(null)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||||
|
if (data.type !== 'dashboard-section') return;
|
||||||
|
const sourceKey = data.key as SectionKey;
|
||||||
|
if (sourceKey !== key) handleReorder(sourceKey, key);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setDraggedSection(null);
|
||||||
|
setDragOverSection(null);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'transition-all',
|
||||||
|
draggedSection === key && 'opacity-50',
|
||||||
|
dragOverSection === key && draggedSection !== key && 'border-t-2 border-blue-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderSection(key)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex gap-3 p-4 border-t border-gray-200 sm:justify-center">
|
<DialogFooter className="flex gap-3 p-4 border-t border-gray-200 sm:justify-center">
|
||||||
@@ -851,4 +907,4 @@ export function DashboardSettingsDialog({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function ConstructionSection({ data }: ConstructionSectionProps) {
|
|||||||
|
|
||||||
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||||
{/* 시공 요약 카드 */}
|
{/* 시공 요약 카드 */}
|
||||||
<div className="grid grid-cols-1 xs:grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||||
<div className="rounded-lg border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
|
<div className="rounded-lg border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<HardHat className="h-4 w-4 text-blue-500" />
|
<HardHat className="h-4 w-4 text-blue-500" />
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
|
|||||||
|
|
||||||
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||||
{/* 요약 카드 4개 */}
|
{/* 요약 카드 4개 */}
|
||||||
<div className="grid grid-cols-2 xs:grid-cols-4 gap-3 mb-6">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||||
<div className="rounded-lg border p-3 text-center" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
|
<div className="rounded-lg border p-3 text-center" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
|
||||||
<div className="flex items-center justify-center gap-1 mb-1">
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
<UserCheck className="h-3.5 w-3.5 text-green-500" />
|
<UserCheck className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
|||||||
@@ -19,6 +19,51 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
import { formatKoreanAmount } from '@/lib/utils/amount';
|
import { formatKoreanAmount } from '@/lib/utils/amount';
|
||||||
import type { DailyProductionData } from '../types';
|
import type { DailyProductionData } from '../types';
|
||||||
|
|
||||||
|
// 출고 현황 독립 섹션
|
||||||
|
interface ShipmentSectionProps {
|
||||||
|
data: DailyProductionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShipmentSection({ data }: ShipmentSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border overflow-hidden">
|
||||||
|
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
|
||||||
|
<Truck style={{ color: '#ffffff' }} className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold">출고 현황</h3>
|
||||||
|
<p style={{ color: '#cbd5e1' }} className="text-sm">예상 출고 정보</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="rounded-xl border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Package className="h-4 w-4 text-blue-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">예상 출고 (7일 이내)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
|
||||||
|
<p className="text-xs text-gray-500">{data.shipment.expectedCount}건</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Truck className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">예상 출고 (30일 이내)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
|
||||||
|
<p className="text-xs text-gray-500">{data.shipment.actualCount}건</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DailyProductionSectionProps {
|
interface DailyProductionSectionProps {
|
||||||
data: DailyProductionData;
|
data: DailyProductionData;
|
||||||
showShipment?: boolean;
|
showShipment?: boolean;
|
||||||
@@ -34,6 +79,7 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
|||||||
const [activeTab, setActiveTab] = useState(data.processes[0]?.processName ?? '');
|
const [activeTab, setActiveTab] = useState(data.processes[0]?.processName ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="rounded-xl border overflow-hidden">
|
<div className="rounded-xl border overflow-hidden">
|
||||||
{/* 다크 헤더 */}
|
{/* 다크 헤더 */}
|
||||||
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||||
@@ -216,33 +262,47 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
|||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* 출고 현황 */}
|
</div>
|
||||||
{showShipment && (
|
</div>
|
||||||
<div className="mt-6">
|
|
||||||
<div className="border rounded-lg p-4">
|
{/* 출고 현황 (별도 카드) */}
|
||||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">출고 현황</h4>
|
{showShipment && (
|
||||||
<div className="grid grid-cols-1 xs:grid-cols-2 gap-4">
|
<div className="rounded-xl border overflow-hidden">
|
||||||
<div className="rounded-lg border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
|
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center justify-between">
|
||||||
<Package className="h-4 w-4 text-blue-500" />
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium text-gray-700">예상 출고 (7일 이내)</span>
|
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
|
||||||
</div>
|
<Truck style={{ color: '#ffffff' }} className="h-5 w-5" />
|
||||||
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
|
</div>
|
||||||
<p className="text-xs text-gray-500">{data.shipment.expectedCount}건</p>
|
<div>
|
||||||
</div>
|
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold">출고 현황</h3>
|
||||||
<div className="rounded-lg border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
|
<p style={{ color: '#cbd5e1' }} className="text-sm">예상 출고 정보</p>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Truck className="h-4 w-4 text-green-500" />
|
|
||||||
<span className="text-sm font-medium text-gray-700">예상 출고 (30일 이내)</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
|
|
||||||
<p className="text-xs text-gray-500">{data.shipment.actualCount}건</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="rounded-xl border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Package className="h-4 w-4 text-blue-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">예상 출고 (7일 이내)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
|
||||||
|
<p className="text-xs text-gray-500">{data.shipment.expectedCount}건</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Truck className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">예상 출고 (30일 이내)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
|
||||||
|
<p className="text-xs text-gray-500">{data.shipment.actualCount}건</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import type { PurchaseStatusData } from '../types';
|
|||||||
|
|
||||||
interface PurchaseStatusSectionProps {
|
interface PurchaseStatusSectionProps {
|
||||||
data: PurchaseStatusData;
|
data: PurchaseStatusData;
|
||||||
showDailyDetail?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAmount = (value: number) => {
|
const formatAmount = (value: number) => {
|
||||||
@@ -45,7 +44,7 @@ const formatAmount = (value: number) => {
|
|||||||
return value.toLocaleString();
|
return value.toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PurchaseStatusSection({ data, showDailyDetail = true }: PurchaseStatusSectionProps) {
|
export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
|
||||||
const [supplierFilter, setSupplierFilter] = useState<string[]>([]);
|
const [supplierFilter, setSupplierFilter] = useState<string[]>([]);
|
||||||
const [sortOrder, setSortOrder] = useState('date-desc');
|
const [sortOrder, setSortOrder] = useState('date-desc');
|
||||||
|
|
||||||
@@ -61,6 +60,7 @@ export function PurchaseStatusSection({ data, showDailyDetail = true }: Purchase
|
|||||||
const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))];
|
const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="rounded-xl border overflow-hidden">
|
<div className="rounded-xl border overflow-hidden">
|
||||||
{/* 다크 헤더 */}
|
{/* 다크 헤더 */}
|
||||||
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||||
@@ -84,8 +84,8 @@ export function PurchaseStatusSection({ data, showDailyDetail = true }: Purchase
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||||
{/* 통계카드 3개 */}
|
{/* 통계카드 3개 - 가로 배치 */}
|
||||||
<div className="grid grid-cols-1 xs:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||||
{/* 누적 매입 */}
|
{/* 누적 매입 */}
|
||||||
<div
|
<div
|
||||||
style={{ backgroundColor: '#fff7ed', borderColor: '#fed7aa' }}
|
style={{ backgroundColor: '#fff7ed', borderColor: '#fed7aa' }}
|
||||||
@@ -174,94 +174,108 @@ export function PurchaseStatusSection({ data, showDailyDetail = true }: Purchase
|
|||||||
outerRadius={80}
|
outerRadius={80}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="name"
|
nameKey="name"
|
||||||
label={({ name, payload }) => `${name} ${(payload as { percentage?: number })?.percentage ?? 0}%`}
|
|
||||||
>
|
>
|
||||||
{data.materialRatio.map((entry, index) => (
|
{data.materialRatio.map((entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Legend />
|
<Tooltip formatter={(value: number | undefined) => [formatKoreanAmount(value ?? 0), '금액']} />
|
||||||
|
<Legend formatter={(value: string) => {
|
||||||
|
const item = data.materialRatio.find((r) => r.name === value);
|
||||||
|
return `${value} ${item?.percentage ?? 0}%`;
|
||||||
|
}} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 당월 매입 내역 테이블 */}
|
</div>
|
||||||
{showDailyDetail && (
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
|
{/* 당월 매입 내역 (별도 카드) */}
|
||||||
<h4 className="text-sm font-semibold text-gray-700">당월 매입 내역</h4>
|
<div className="rounded-xl border overflow-hidden">
|
||||||
<div className="flex gap-2">
|
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||||
<MultiSelectCombobox
|
<div className="flex items-center gap-3">
|
||||||
options={suppliers.map((s) => ({ value: s, label: s }))}
|
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
|
||||||
value={supplierFilter}
|
<ShoppingCart style={{ color: '#ffffff' }} className="h-5 w-5" />
|
||||||
onChange={setSupplierFilter}
|
|
||||||
placeholder="전체 공급처"
|
|
||||||
className="w-[160px] h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<Select value={sortOrder} onValueChange={setSortOrder}>
|
|
||||||
<SelectTrigger className="w-[120px] h-8 text-xs">
|
|
||||||
<SelectValue placeholder="정렬" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="date-desc">최신순</SelectItem>
|
|
||||||
<SelectItem value="date-asc">오래된순</SelectItem>
|
|
||||||
<SelectItem value="amount-desc">금액 높은순</SelectItem>
|
|
||||||
<SelectItem value="amount-asc">금액 낮은순</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<table className="w-full text-sm">
|
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold">당월 매입 내역</h3>
|
||||||
<thead>
|
<p style={{ color: '#cbd5e1' }} className="text-sm">당월 매입 거래 상세</p>
|
||||||
<tr className="bg-gray-50 border-b">
|
|
||||||
<th className="px-4 py-2 text-left text-gray-600 font-medium">날짜</th>
|
|
||||||
<th className="px-4 py-2 text-left text-gray-600 font-medium">공급처</th>
|
|
||||||
<th className="px-4 py-2 text-left text-gray-600 font-medium">품목</th>
|
|
||||||
<th className="px-4 py-2 text-right text-gray-600 font-medium">금액</th>
|
|
||||||
<th className="px-4 py-2 text-center text-gray-600 font-medium">상태</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredItems.map((item, idx) => (
|
|
||||||
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-2 text-gray-700">{item.date}</td>
|
|
||||||
<td className="px-4 py-2 text-gray-700">{item.supplier}</td>
|
|
||||||
<td className="px-4 py-2 text-gray-700">{item.item}</td>
|
|
||||||
<td className="px-4 py-2 text-right text-gray-900 font-medium">
|
|
||||||
{item.amount.toLocaleString()}원
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
item.status === '결제완료'
|
|
||||||
? 'text-green-600 border-green-200 bg-green-50'
|
|
||||||
: item.status === '미결제'
|
|
||||||
? 'text-red-600 border-red-200 bg-red-50'
|
|
||||||
: 'text-orange-600 border-orange-200 bg-orange-50'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="bg-gray-100 font-semibold">
|
|
||||||
<td className="px-4 py-2 text-gray-700" colSpan={3}>합계</td>
|
|
||||||
<td className="px-4 py-2 text-right text-gray-900">
|
|
||||||
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}원
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
|
||||||
|
<div className="text-sm text-gray-500">총 {filteredItems.length}건</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={suppliers.map((s) => ({ value: s, label: s }))}
|
||||||
|
value={supplierFilter}
|
||||||
|
onChange={setSupplierFilter}
|
||||||
|
placeholder="전체 공급처"
|
||||||
|
className="w-[160px] h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<Select value={sortOrder} onValueChange={setSortOrder}>
|
||||||
|
<SelectTrigger className="w-[120px] h-8 text-xs">
|
||||||
|
<SelectValue placeholder="정렬" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="date-desc">최신순</SelectItem>
|
||||||
|
<SelectItem value="date-asc">오래된순</SelectItem>
|
||||||
|
<SelectItem value="amount-desc">금액 높은순</SelectItem>
|
||||||
|
<SelectItem value="amount-asc">금액 낮은순</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 border-b">
|
||||||
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">날짜</th>
|
||||||
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">공급처</th>
|
||||||
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">품목</th>
|
||||||
|
<th className="px-4 py-2 text-right text-gray-600 font-medium">금액</th>
|
||||||
|
<th className="px-4 py-2 text-center text-gray-600 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredItems.map((item, idx) => (
|
||||||
|
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.date}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.supplier}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.item}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900 font-medium">
|
||||||
|
{item.amount.toLocaleString()}원
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
item.status === '결제완료'
|
||||||
|
? 'text-green-600 border-green-200 bg-green-50'
|
||||||
|
: item.status === '미결제'
|
||||||
|
? 'text-red-600 border-red-200 bg-red-50'
|
||||||
|
: 'text-orange-600 border-orange-200 bg-orange-50'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="bg-gray-100 font-semibold">
|
||||||
|
<td className="px-4 py-2 text-gray-700" colSpan={3}>합계</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900">
|
||||||
|
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}원
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import type { SalesStatusData } from '../types';
|
|||||||
|
|
||||||
interface SalesStatusSectionProps {
|
interface SalesStatusSectionProps {
|
||||||
data: SalesStatusData;
|
data: SalesStatusData;
|
||||||
showDailyDetail?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAmount = (value: number) => {
|
const formatAmount = (value: number) => {
|
||||||
@@ -43,7 +42,7 @@ const formatAmount = (value: number) => {
|
|||||||
return value.toLocaleString();
|
return value.toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatusSectionProps) {
|
export function SalesStatusSection({ data }: SalesStatusSectionProps) {
|
||||||
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
||||||
const [sortOrder, setSortOrder] = useState('date-desc');
|
const [sortOrder, setSortOrder] = useState('date-desc');
|
||||||
|
|
||||||
@@ -59,6 +58,7 @@ export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatus
|
|||||||
const clients = [...new Set(data.dailyItems.map((item) => item.client))];
|
const clients = [...new Set(data.dailyItems.map((item) => item.client))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="rounded-xl border overflow-hidden">
|
<div className="rounded-xl border overflow-hidden">
|
||||||
{/* 다크 헤더 */}
|
{/* 다크 헤더 */}
|
||||||
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||||
@@ -83,7 +83,7 @@ export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatus
|
|||||||
|
|
||||||
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||||
{/* 통계카드 4개 */}
|
{/* 통계카드 4개 */}
|
||||||
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
{/* 누적 매출 */}
|
{/* 누적 매출 */}
|
||||||
<div
|
<div
|
||||||
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
|
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
|
||||||
@@ -194,82 +194,93 @@ export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatus
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 당월 매출 내역 테이블 */}
|
</div>
|
||||||
{showDailyDetail && (
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
|
{/* 당월 매출 내역 (별도 카드) */}
|
||||||
<h4 className="text-sm font-semibold text-gray-700">당월 매출 내역</h4>
|
<div className="rounded-xl border overflow-hidden">
|
||||||
<div className="flex gap-2">
|
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||||
<MultiSelectCombobox
|
<div className="flex items-center gap-3">
|
||||||
options={clients.map((c) => ({ value: c, label: c }))}
|
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
|
||||||
value={clientFilter}
|
<BarChart3 style={{ color: '#ffffff' }} className="h-5 w-5" />
|
||||||
onChange={setClientFilter}
|
|
||||||
placeholder="전체 거래처"
|
|
||||||
className="w-[160px] h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<Select value={sortOrder} onValueChange={setSortOrder}>
|
|
||||||
<SelectTrigger className="w-[120px] h-8 text-xs">
|
|
||||||
<SelectValue placeholder="정렬" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="date-desc">최신순</SelectItem>
|
|
||||||
<SelectItem value="date-asc">오래된순</SelectItem>
|
|
||||||
<SelectItem value="amount-desc">금액 높은순</SelectItem>
|
|
||||||
<SelectItem value="amount-asc">금액 낮은순</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<table className="w-full text-sm">
|
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold">당월 매출 내역</h3>
|
||||||
<thead>
|
<p style={{ color: '#cbd5e1' }} className="text-sm">당월 매출 거래 상세</p>
|
||||||
<tr className="bg-gray-50 border-b">
|
|
||||||
<th className="px-4 py-2 text-left text-gray-600 font-medium">날짜</th>
|
|
||||||
<th className="px-4 py-2 text-left text-gray-600 font-medium">거래처</th>
|
|
||||||
<th className="px-4 py-2 text-left text-gray-600 font-medium">품목</th>
|
|
||||||
<th className="px-4 py-2 text-right text-gray-600 font-medium">금액</th>
|
|
||||||
<th className="px-4 py-2 text-center text-gray-600 font-medium">상태</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredItems.map((item, idx) => (
|
|
||||||
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-2 text-gray-700">{item.date}</td>
|
|
||||||
<td className="px-4 py-2 text-gray-700">{item.client}</td>
|
|
||||||
<td className="px-4 py-2 text-gray-700">{item.item}</td>
|
|
||||||
<td className="px-4 py-2 text-right text-gray-900 font-medium">
|
|
||||||
{item.amount.toLocaleString()}원
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
item.status === '입금완료'
|
|
||||||
? 'text-green-600 border-green-200 bg-green-50'
|
|
||||||
: item.status === '미입금'
|
|
||||||
? 'text-red-600 border-red-200 bg-red-50'
|
|
||||||
: 'text-orange-600 border-orange-200 bg-orange-50'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="bg-gray-100 font-semibold">
|
|
||||||
<td className="px-4 py-2 text-gray-700" colSpan={3}>합계</td>
|
|
||||||
<td className="px-4 py-2 text-right text-gray-900">
|
|
||||||
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}원
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
|
||||||
|
<div className="text-sm text-gray-500">총 {filteredItems.length}건</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={clients.map((c) => ({ value: c, label: c }))}
|
||||||
|
value={clientFilter}
|
||||||
|
onChange={setClientFilter}
|
||||||
|
placeholder="전체 거래처"
|
||||||
|
className="w-[160px] h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<Select value={sortOrder} onValueChange={setSortOrder}>
|
||||||
|
<SelectTrigger className="w-[120px] h-8 text-xs">
|
||||||
|
<SelectValue placeholder="정렬" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="date-desc">최신순</SelectItem>
|
||||||
|
<SelectItem value="date-asc">오래된순</SelectItem>
|
||||||
|
<SelectItem value="amount-desc">금액 높은순</SelectItem>
|
||||||
|
<SelectItem value="amount-asc">금액 낮은순</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 border-b">
|
||||||
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">날짜</th>
|
||||||
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">거래처</th>
|
||||||
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">품목</th>
|
||||||
|
<th className="px-4 py-2 text-right text-gray-600 font-medium">금액</th>
|
||||||
|
<th className="px-4 py-2 text-center text-gray-600 font-medium">상태</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredItems.map((item, idx) => (
|
||||||
|
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.date}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.client}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.item}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900 font-medium">
|
||||||
|
{item.amount.toLocaleString()}원
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
item.status === '입금완료'
|
||||||
|
? 'text-green-600 border-green-200 bg-green-50'
|
||||||
|
: item.status === '미입금'
|
||||||
|
? 'text-red-600 border-red-200 bg-red-50'
|
||||||
|
: 'text-orange-600 border-orange-200 bg-orange-50'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="bg-gray-100 font-semibold">
|
||||||
|
<td className="px-4 py-2 text-gray-700" colSpan={3}>합계</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-900">
|
||||||
|
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}원
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export { CalendarSection } from './CalendarSection';
|
|||||||
// 신규 섹션
|
// 신규 섹션
|
||||||
export { SalesStatusSection } from './SalesStatusSection';
|
export { SalesStatusSection } from './SalesStatusSection';
|
||||||
export { PurchaseStatusSection } from './PurchaseStatusSection';
|
export { PurchaseStatusSection } from './PurchaseStatusSection';
|
||||||
export { DailyProductionSection } from './DailyProductionSection';
|
export { DailyProductionSection, ShipmentSection } from './DailyProductionSection';
|
||||||
export { UnshippedSection } from './UnshippedSection';
|
export { UnshippedSection } from './UnshippedSection';
|
||||||
export { ConstructionSection } from './ConstructionSection';
|
export { ConstructionSection } from './ConstructionSection';
|
||||||
export { DailyAttendanceSection } from './DailyAttendanceSection';
|
export { DailyAttendanceSection } from './DailyAttendanceSection';
|
||||||
|
|||||||
@@ -347,6 +347,71 @@ export interface CEODashboardData {
|
|||||||
dailyAttendance?: DailyAttendanceData;
|
dailyAttendance?: DailyAttendanceData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 섹션 순서 관리 =====
|
||||||
|
|
||||||
|
// 대시보드 섹션 키
|
||||||
|
export type SectionKey =
|
||||||
|
| 'todayIssueList'
|
||||||
|
| 'dailyReport'
|
||||||
|
| 'statusBoard'
|
||||||
|
| 'monthlyExpense'
|
||||||
|
| 'cardManagement'
|
||||||
|
| 'entertainment'
|
||||||
|
| 'welfare'
|
||||||
|
| 'receivable'
|
||||||
|
| 'debtCollection'
|
||||||
|
| 'vat'
|
||||||
|
| 'calendar'
|
||||||
|
| 'salesStatus'
|
||||||
|
| 'purchaseStatus'
|
||||||
|
| 'production'
|
||||||
|
| 'shipment'
|
||||||
|
| 'unshipped'
|
||||||
|
| 'construction'
|
||||||
|
| 'attendance';
|
||||||
|
|
||||||
|
export const DEFAULT_SECTION_ORDER: SectionKey[] = [
|
||||||
|
'todayIssueList',
|
||||||
|
'dailyReport',
|
||||||
|
'statusBoard',
|
||||||
|
'monthlyExpense',
|
||||||
|
'cardManagement',
|
||||||
|
'entertainment',
|
||||||
|
'welfare',
|
||||||
|
'receivable',
|
||||||
|
'debtCollection',
|
||||||
|
'vat',
|
||||||
|
'calendar',
|
||||||
|
'salesStatus',
|
||||||
|
'purchaseStatus',
|
||||||
|
'production',
|
||||||
|
'shipment',
|
||||||
|
'unshipped',
|
||||||
|
'construction',
|
||||||
|
'attendance',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SECTION_LABELS: Record<SectionKey, string> = {
|
||||||
|
todayIssueList: '오늘의 이슈',
|
||||||
|
dailyReport: '자금현황',
|
||||||
|
statusBoard: '현황판',
|
||||||
|
monthlyExpense: '당월 예상 지출 내역',
|
||||||
|
cardManagement: '카드/가지급금 관리',
|
||||||
|
entertainment: '접대비 현황',
|
||||||
|
welfare: '복리후생비 현황',
|
||||||
|
receivable: '미수금 현황',
|
||||||
|
debtCollection: '채권추심 현황',
|
||||||
|
vat: '부가세 현황',
|
||||||
|
calendar: '캘린더',
|
||||||
|
salesStatus: '매출 현황',
|
||||||
|
purchaseStatus: '매입 현황',
|
||||||
|
production: '생산 현황',
|
||||||
|
shipment: '출고 현황',
|
||||||
|
unshipped: '미출고 내역',
|
||||||
|
construction: '시공 현황',
|
||||||
|
attendance: '근태 현황',
|
||||||
|
};
|
||||||
|
|
||||||
// ===== 대시보드 설정 타입 =====
|
// ===== 대시보드 설정 타입 =====
|
||||||
|
|
||||||
// 오늘의 이슈 개별 항목 설정
|
// 오늘의 이슈 개별 항목 설정
|
||||||
@@ -407,21 +472,20 @@ export interface DashboardSettings {
|
|||||||
cardManagement: boolean;
|
cardManagement: boolean;
|
||||||
entertainment: EntertainmentSettings;
|
entertainment: EntertainmentSettings;
|
||||||
welfare: WelfareSettings;
|
welfare: WelfareSettings;
|
||||||
receivable: {
|
receivable: boolean;
|
||||||
enabled: boolean;
|
|
||||||
topCompanies: boolean; // 미수금 상위 회사 현황
|
|
||||||
};
|
|
||||||
debtCollection: boolean;
|
debtCollection: boolean;
|
||||||
vat: boolean;
|
vat: boolean;
|
||||||
calendar: boolean;
|
calendar: boolean;
|
||||||
// 신규 섹션 설정
|
// 신규 섹션 설정
|
||||||
salesStatus: { enabled: boolean; dailySalesDetail: boolean };
|
salesStatus: boolean;
|
||||||
purchaseStatus: { enabled: boolean; dailyPurchaseDetail: boolean };
|
purchaseStatus: boolean;
|
||||||
production: boolean;
|
production: boolean;
|
||||||
shipment: boolean;
|
shipment: boolean;
|
||||||
unshipped: boolean;
|
unshipped: boolean;
|
||||||
construction: boolean;
|
construction: boolean;
|
||||||
attendance: boolean;
|
attendance: boolean;
|
||||||
|
// 섹션 순서
|
||||||
|
sectionOrder?: SectionKey[];
|
||||||
// Legacy: 기존 todayIssue 호환용 (deprecated, statusBoard로 대체)
|
// Legacy: 기존 todayIssue 호환용 (deprecated, statusBoard로 대체)
|
||||||
todayIssue: {
|
todayIssue: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -649,21 +713,20 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
|||||||
ratio: 20.5,
|
ratio: 20.5,
|
||||||
annualTotal: 20000000,
|
annualTotal: 20000000,
|
||||||
},
|
},
|
||||||
receivable: {
|
receivable: true,
|
||||||
enabled: true,
|
|
||||||
topCompanies: true,
|
|
||||||
},
|
|
||||||
debtCollection: true,
|
debtCollection: true,
|
||||||
vat: true,
|
vat: true,
|
||||||
calendar: true,
|
calendar: true,
|
||||||
// 신규 섹션
|
// 신규 섹션
|
||||||
salesStatus: { enabled: true, dailySalesDetail: true },
|
salesStatus: true,
|
||||||
purchaseStatus: { enabled: true, dailyPurchaseDetail: true },
|
purchaseStatus: true,
|
||||||
production: true,
|
production: true,
|
||||||
shipment: true,
|
shipment: true,
|
||||||
unshipped: true,
|
unshipped: true,
|
||||||
construction: true,
|
construction: true,
|
||||||
attendance: true,
|
attendance: true,
|
||||||
|
// 섹션 순서
|
||||||
|
sectionOrder: DEFAULT_SECTION_ORDER,
|
||||||
// Legacy: 기존 todayIssue 호환용 (statusBoard와 동일)
|
// Legacy: 기존 todayIssue 호환용 (statusBoard와 동일)
|
||||||
todayIssue: {
|
todayIssue: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
93
src/components/document-system/components/DocumentTable.tsx
Normal file
93
src/components/document-system/components/DocumentTable.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface DocumentTableProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** 테이블 위 섹션 헤더 */
|
||||||
|
header?: string;
|
||||||
|
/** 헤더 배경색 (기본: dark) */
|
||||||
|
headerVariant?: 'dark' | 'light' | 'primary';
|
||||||
|
/** 하단 마진 (기본: mb-6) */
|
||||||
|
spacing?: string;
|
||||||
|
/** 추가 className (table 요소에 적용) */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서용 테이블 래퍼
|
||||||
|
*
|
||||||
|
* 일관된 border, collapse, text-size를 적용합니다.
|
||||||
|
* 선택적으로 섹션 헤더를 포함할 수 있습니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 기본 사용
|
||||||
|
* <DocumentTable>
|
||||||
|
* <thead>
|
||||||
|
* <tr><th className={DOC_STYLES.th}>품목</th></tr>
|
||||||
|
* </thead>
|
||||||
|
* <tbody>
|
||||||
|
* <tr><td className={DOC_STYLES.td}>스크린</td></tr>
|
||||||
|
* </tbody>
|
||||||
|
* </DocumentTable>
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 섹션 헤더 포함
|
||||||
|
* <DocumentTable header="자재 내역">
|
||||||
|
* <tbody>...</tbody>
|
||||||
|
* </DocumentTable>
|
||||||
|
*/
|
||||||
|
export function DocumentTable({
|
||||||
|
children,
|
||||||
|
header,
|
||||||
|
headerVariant = 'dark',
|
||||||
|
spacing = 'mb-6',
|
||||||
|
className,
|
||||||
|
}: DocumentTableProps) {
|
||||||
|
const headerClasses = {
|
||||||
|
dark: 'bg-gray-800 text-white',
|
||||||
|
light: 'bg-gray-100 text-gray-900',
|
||||||
|
primary: 'bg-blue-600 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={spacing}>
|
||||||
|
{header && (
|
||||||
|
<div className={cn('text-center py-1 font-bold border-b border-gray-400', headerClasses[headerVariant])}>
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table className={cn('w-full border-collapse border border-gray-400', className)}>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 테이블 셀 스타일 상수
|
||||||
|
*
|
||||||
|
* 문서 내 <th>/<td>에 일관된 스타일을 적용하기 위한 유틸리티.
|
||||||
|
* DocumentTable 내부에서 직접 className으로 사용합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <tr>
|
||||||
|
* <th className={DOC_STYLES.th}>품목명</th>
|
||||||
|
* <td className={DOC_STYLES.td}>스크린</td>
|
||||||
|
* <td className={DOC_STYLES.tdCenter}>10</td>
|
||||||
|
* <td className={DOC_STYLES.tdRight}>1,000,000</td>
|
||||||
|
* </tr>
|
||||||
|
*/
|
||||||
|
export const DOC_STYLES = {
|
||||||
|
/** 헤더 셀 (회색 배경, 볼드) */
|
||||||
|
th: 'bg-gray-100 border border-gray-400 px-2 py-1 font-medium text-center',
|
||||||
|
/** 데이터 셀 (좌측 정렬) */
|
||||||
|
td: 'border border-gray-300 px-2 py-1',
|
||||||
|
/** 데이터 셀 (중앙 정렬) */
|
||||||
|
tdCenter: 'border border-gray-300 px-2 py-1 text-center',
|
||||||
|
/** 데이터 셀 (우측 정렬, 숫자용) */
|
||||||
|
tdRight: 'border border-gray-300 px-2 py-1 text-right',
|
||||||
|
/** 라벨 셀 (key-value 테이블의 라벨) */
|
||||||
|
label: 'bg-gray-100 border border-gray-300 px-2 py-1 font-medium w-24',
|
||||||
|
/** 값 셀 (key-value 테이블의 값) */
|
||||||
|
value: 'border border-gray-300 px-2 py-1',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ReactNode, forwardRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface DocumentWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** 텍스트 크기 (기본: text-xs) */
|
||||||
|
fontSize?: 'text-[10px]' | 'text-[11px]' | 'text-xs' | 'text-sm';
|
||||||
|
/** print-area 클래스 자동 추가 (기본: true) */
|
||||||
|
printArea?: boolean;
|
||||||
|
/** 추가 className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 A4 래퍼
|
||||||
|
*
|
||||||
|
* 모든 문서 컴포넌트의 최외곽 컨테이너.
|
||||||
|
* 일관된 배경색, 패딩, 최소 높이, 프린트 클래스를 제공합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <DocumentWrapper>
|
||||||
|
* <DocumentHeader title="발주서" />
|
||||||
|
* <DocumentTable>...</DocumentTable>
|
||||||
|
* </DocumentWrapper>
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 작은 폰트 + 커스텀 클래스
|
||||||
|
* <DocumentWrapper fontSize="text-[11px]" className="leading-tight">
|
||||||
|
* ...
|
||||||
|
* </DocumentWrapper>
|
||||||
|
*/
|
||||||
|
export const DocumentWrapper = forwardRef<HTMLDivElement, DocumentWrapperProps>(
|
||||||
|
function DocumentWrapper({ children, fontSize = 'text-xs', printArea = true, className }, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'bg-white p-8 min-h-full',
|
||||||
|
fontSize,
|
||||||
|
printArea && 'print-area',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// 문서 공통 컴포넌트
|
// 문서 공통 컴포넌트
|
||||||
export { ApprovalLine } from './ApprovalLine';
|
export { ApprovalLine } from './ApprovalLine';
|
||||||
export { DocumentHeader } from './DocumentHeader';
|
export { DocumentHeader } from './DocumentHeader';
|
||||||
|
export { DocumentWrapper } from './DocumentWrapper';
|
||||||
|
export { DocumentTable, DOC_STYLES } from './DocumentTable';
|
||||||
export { SectionHeader } from './SectionHeader';
|
export { SectionHeader } from './SectionHeader';
|
||||||
export { InfoTable } from './InfoTable';
|
export { InfoTable } from './InfoTable';
|
||||||
export { QualityApprovalTable } from './QualityApprovalTable';
|
export { QualityApprovalTable } from './QualityApprovalTable';
|
||||||
@@ -11,6 +13,8 @@ export { SignatureSection } from './SignatureSection';
|
|||||||
// Types
|
// Types
|
||||||
export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine';
|
export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine';
|
||||||
export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader';
|
export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader';
|
||||||
|
export type { DocumentWrapperProps } from './DocumentWrapper';
|
||||||
|
export type { DocumentTableProps } from './DocumentTable';
|
||||||
export type { SectionHeaderProps } from './SectionHeader';
|
export type { SectionHeaderProps } from './SectionHeader';
|
||||||
export type { InfoTableCell, InfoTableProps } from './InfoTable';
|
export type { InfoTableCell, InfoTableProps } from './InfoTable';
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
67
src/components/document-system/hooks/usePrintHandler.ts
Normal file
67
src/components/document-system/hooks/usePrintHandler.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { printElement, printArea } from '@/lib/print-utils';
|
||||||
|
|
||||||
|
interface UsePrintHandlerOptions {
|
||||||
|
/** 인쇄 제목 (브라우저 다이얼로그에 표시) */
|
||||||
|
title?: string;
|
||||||
|
/** 추가 CSS 스타일 */
|
||||||
|
styles?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePrintHandlerReturn {
|
||||||
|
/** ref를 할당한 요소를 인쇄 */
|
||||||
|
printRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** ref 기반 인쇄 실행 */
|
||||||
|
handlePrint: () => void;
|
||||||
|
/** .print-area 클래스 기반 인쇄 실행 */
|
||||||
|
handlePrintArea: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 인쇄 훅
|
||||||
|
*
|
||||||
|
* ref 기반 또는 .print-area 기반으로 인쇄를 실행합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // ref 기반 사용
|
||||||
|
* function MyDocument() {
|
||||||
|
* const { printRef, handlePrint } = usePrintHandler({ title: '발주서' });
|
||||||
|
* return (
|
||||||
|
* <>
|
||||||
|
* <DocumentWrapper ref={printRef}>
|
||||||
|
* <DocumentHeader title="발주서" />
|
||||||
|
* ...
|
||||||
|
* </DocumentWrapper>
|
||||||
|
* <Button onClick={handlePrint}>인쇄</Button>
|
||||||
|
* </>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // .print-area 기반 사용 (DocumentWrapper의 printArea prop 활용)
|
||||||
|
* function MyDocument() {
|
||||||
|
* const { handlePrintArea } = usePrintHandler({ title: '검사 성적서' });
|
||||||
|
* return (
|
||||||
|
* <>
|
||||||
|
* <DocumentWrapper>...</DocumentWrapper>
|
||||||
|
* <Button onClick={handlePrintArea}>인쇄</Button>
|
||||||
|
* </>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function usePrintHandler(options: UsePrintHandlerOptions = {}): UsePrintHandlerReturn {
|
||||||
|
const { title = '문서 인쇄', styles } = options;
|
||||||
|
const printRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const handlePrint = useCallback(() => {
|
||||||
|
if (printRef.current) {
|
||||||
|
printElement(printRef.current, { title, styles });
|
||||||
|
}
|
||||||
|
}, [title, styles]);
|
||||||
|
|
||||||
|
const handlePrintArea = useCallback(() => {
|
||||||
|
printArea({ title, styles });
|
||||||
|
}, [title, styles]);
|
||||||
|
|
||||||
|
return { printRef, handlePrint, handlePrintArea };
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ export { DocumentViewer } from './viewer';
|
|||||||
export {
|
export {
|
||||||
ApprovalLine,
|
ApprovalLine,
|
||||||
DocumentHeader,
|
DocumentHeader,
|
||||||
|
DocumentWrapper,
|
||||||
|
DocumentTable,
|
||||||
|
DOC_STYLES,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
InfoTable,
|
InfoTable,
|
||||||
QualityApprovalTable,
|
QualityApprovalTable,
|
||||||
@@ -15,6 +18,7 @@ export {
|
|||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
export { useZoom, useDrag } from './viewer/hooks';
|
export { useZoom, useDrag } from './viewer/hooks';
|
||||||
|
export { usePrintHandler } from './hooks/usePrintHandler';
|
||||||
|
|
||||||
// Presets
|
// Presets
|
||||||
export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets';
|
export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets';
|
||||||
@@ -26,6 +30,8 @@ export type {
|
|||||||
ApprovalLineProps,
|
ApprovalLineProps,
|
||||||
DocumentHeaderLogo,
|
DocumentHeaderLogo,
|
||||||
DocumentHeaderProps,
|
DocumentHeaderProps,
|
||||||
|
DocumentWrapperProps,
|
||||||
|
DocumentTableProps,
|
||||||
SectionHeaderProps,
|
SectionHeaderProps,
|
||||||
InfoTableCell,
|
InfoTableCell,
|
||||||
InfoTableProps,
|
InfoTableProps,
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 발주서 문서 컴포넌트
|
* 발주서 문서 컴포넌트
|
||||||
* - 스크린샷 형식 + 지출결의서 디자인 스타일
|
* - DocumentWrapper + DocumentTable building block 활용
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getTodayString } from "@/lib/utils/date";
|
import { getTodayString } from "@/lib/utils/date";
|
||||||
import { OrderItem } from "../actions";
|
import { OrderItem } from "../actions";
|
||||||
import { formatNumber } from '@/lib/utils/amount';
|
import { formatNumber } from '@/lib/utils/amount';
|
||||||
|
import { DocumentWrapper, DocumentTable, DOC_STYLES } from '@/components/document-system';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 수량 포맷 함수
|
* 수량 포맷 함수
|
||||||
@@ -61,7 +62,7 @@ export function PurchaseOrderDocument({
|
|||||||
remarks,
|
remarks,
|
||||||
}: PurchaseOrderDocumentProps) {
|
}: PurchaseOrderDocumentProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-8 min-h-full">
|
<DocumentWrapper fontSize="text-sm">
|
||||||
{/* 헤더: 제목 + 로트번호/결재란 */}
|
{/* 헤더: 제목 + 로트번호/결재란 */}
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<h1 className="text-2xl font-bold tracking-widest">발 주 서</h1>
|
<h1 className="text-2xl font-bold tracking-widest">발 주 서</h1>
|
||||||
@@ -98,101 +99,95 @@ export function PurchaseOrderDocument({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 신청업체 */}
|
{/* 신청업체 */}
|
||||||
<div className="border border-gray-300 mb-4">
|
<DocumentTable spacing="mb-4" className="text-sm">
|
||||||
<table className="w-full text-sm">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td rowSpan={2} className={`${DOC_STYLES.label} text-center w-20`}>
|
||||||
<td rowSpan={2} className="bg-gray-100 border-r border-b border-gray-300 p-2 text-center font-medium w-20">
|
신청업체
|
||||||
신청업체
|
</td>
|
||||||
</td>
|
<td className={`${DOC_STYLES.label} w-20`}>발주처</td>
|
||||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">발주처</td>
|
<td className={DOC_STYLES.value}>{client}</td>
|
||||||
<td className="border-r border-b border-gray-300 p-2">{client}</td>
|
<td className={`${DOC_STYLES.label} w-20`}>발주일</td>
|
||||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">발주일</td>
|
<td className={DOC_STYLES.value}>{orderDate}</td>
|
||||||
<td className="border-b border-gray-300 p-2">{orderDate}</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td className={`${DOC_STYLES.label} w-20`}>담당자</td>
|
||||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">담당자</td>
|
<td className={DOC_STYLES.value}>{manager}</td>
|
||||||
<td className="border-r border-b border-gray-300 p-2">{manager}</td>
|
<td className={`${DOC_STYLES.label} w-20`}>연락처</td>
|
||||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">연락처</td>
|
<td className={DOC_STYLES.value}>{managerContact}</td>
|
||||||
<td className="border-b border-gray-300 p-2">{managerContact}</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td className={`${DOC_STYLES.label} text-center w-20`}></td>
|
||||||
<td className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium"></td>
|
<td className={`${DOC_STYLES.label} w-20`}>FAX</td>
|
||||||
<td className="bg-gray-100 border-r border-gray-300 p-2">FAX</td>
|
<td className={DOC_STYLES.value}>-</td>
|
||||||
<td className="border-r border-gray-300 p-2">-</td>
|
<td className={`${DOC_STYLES.label} w-20`}>설치개소<br/>(동)</td>
|
||||||
<td className="bg-gray-100 border-r border-gray-300 p-2">설치개소<br/>(동)</td>
|
<td className={DOC_STYLES.value}>{installationCount}개소</td>
|
||||||
<td className="border-gray-300 p-2">{installationCount}개소</td>
|
</tr>
|
||||||
</tr>
|
</tbody>
|
||||||
</tbody>
|
</DocumentTable>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 신청내용 */}
|
{/* 신청내용 */}
|
||||||
<div className="border border-gray-300 mb-4">
|
<DocumentTable spacing="mb-4" className="text-sm">
|
||||||
<table className="w-full text-sm">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td rowSpan={3} className={`${DOC_STYLES.label} text-center w-20`}>
|
||||||
<td rowSpan={3} className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium w-20">
|
신청내용
|
||||||
신청내용
|
</td>
|
||||||
</td>
|
<td className={`${DOC_STYLES.label} w-20`}>현장명</td>
|
||||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">현장명</td>
|
<td colSpan={3} className={DOC_STYLES.value}>{siteName}</td>
|
||||||
<td colSpan={3} className="border-b border-gray-300 p-2">{siteName}</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td className={`${DOC_STYLES.label} w-20`}>납기요청<br/>일</td>
|
||||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">납기요청<br/>일</td>
|
<td className={DOC_STYLES.value}>{deliveryRequestDate}</td>
|
||||||
<td className="border-r border-b border-gray-300 p-2">{deliveryRequestDate}</td>
|
<td className={`${DOC_STYLES.label} w-20`}>배송방법</td>
|
||||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">배송방법</td>
|
<td className={DOC_STYLES.value}>{deliveryMethod}</td>
|
||||||
<td className="border-b border-gray-300 p-2">{deliveryMethod}</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td className={`${DOC_STYLES.label} w-20`}>출고일</td>
|
||||||
<td className="bg-gray-100 border-r border-gray-300 p-2">출고일</td>
|
<td className={DOC_STYLES.value}>{expectedShipDate}</td>
|
||||||
<td className="border-r border-gray-300 p-2">{expectedShipDate}</td>
|
<td className={`${DOC_STYLES.label} w-20`}>납품주소</td>
|
||||||
<td className="bg-gray-100 border-r border-gray-300 p-2">납품주소</td>
|
<td className={DOC_STYLES.value}>{address}</td>
|
||||||
<td className="border-gray-300 p-2">{address}</td>
|
</tr>
|
||||||
</tr>
|
</tbody>
|
||||||
</tbody>
|
</DocumentTable>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 부자재 */}
|
{/* 부자재 */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-sm font-medium mb-2">■ 부자재</p>
|
<p className="text-sm font-medium mb-2">■ 부자재</p>
|
||||||
<div className="border border-gray-300">
|
<DocumentTable spacing="">
|
||||||
<table className="w-full text-sm">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr className="bg-gray-100 border-b border-gray-300">
|
<th className={`${DOC_STYLES.th} w-16`}>구분</th>
|
||||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">구분</th>
|
<th className={`${DOC_STYLES.th} text-left`}>품명</th>
|
||||||
<th className="p-2 text-left font-medium border-r border-gray-300">품명</th>
|
<th className={`${DOC_STYLES.th} w-20`}>규격</th>
|
||||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-20">규격</th>
|
<th className={`${DOC_STYLES.th} w-24`}>길이(mm)</th>
|
||||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-24">길이(mm)</th>
|
<th className={`${DOC_STYLES.th} w-16`}>수량</th>
|
||||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">수량</th>
|
<th className={`${DOC_STYLES.th} w-24`}>비고</th>
|
||||||
<th className="p-2 text-center font-medium w-24">비고</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{items.length > 0 ? (
|
||||||
{items.length > 0 ? (
|
items.map((item, index) => (
|
||||||
items.map((item, index) => (
|
<tr key={item.id}>
|
||||||
<tr key={item.id} className="border-b border-gray-300">
|
<td className={DOC_STYLES.tdCenter}>{index + 1}</td>
|
||||||
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
|
<td className={DOC_STYLES.td}>{item.itemName}</td>
|
||||||
<td className="p-2 border-r border-gray-300">{item.itemName}</td>
|
<td className={DOC_STYLES.tdCenter}>{item.spec}</td>
|
||||||
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td>
|
<td className={DOC_STYLES.tdCenter}>
|
||||||
<td className="p-2 text-center border-r border-gray-300">
|
{item.width ? `${item.width}` : "-"}
|
||||||
{item.width ? `${item.width}` : "-"}
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-center border-r border-gray-300">{formatQuantity(item.quantity, item.unit)}</td>
|
|
||||||
<td className="p-2 text-center">{item.symbol || "-"}</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr className="border-b border-gray-300">
|
|
||||||
<td colSpan={6} className="p-4 text-center text-gray-400">
|
|
||||||
등록된 품목이 없습니다
|
|
||||||
</td>
|
</td>
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{formatQuantity(item.quantity, item.unit)}</td>
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{item.symbol || "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
))
|
||||||
</tbody>
|
) : (
|
||||||
</table>
|
<tr>
|
||||||
</div>
|
<td colSpan={6} className="p-4 text-center text-gray-400 border border-gray-300">
|
||||||
|
등록된 품목이 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</DocumentTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 특이사항 */}
|
{/* 특이사항 */}
|
||||||
@@ -219,6 +214,6 @@ export function PurchaseOrderDocument({
|
|||||||
<div className="text-center text-sm text-gray-600">
|
<div className="text-center text-sm text-gray-600">
|
||||||
문의: 홍길동 | 010-1234-5678
|
문의: 홍길동 | 010-1234-5678
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DocumentWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
189
src/components/organisms/LineItemsTable/LineItemsTable.tsx
Normal file
189
src/components/organisms/LineItemsTable/LineItemsTable.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통 품목 테이블 컴포넌트
|
||||||
|
* 매출/매입/세금계산서 등의 품목 입력 테이블에서 공유
|
||||||
|
*
|
||||||
|
* 기본 컬럼: #, 품목명, 수량, 단가, 공급가액, 부가세, 적요, 삭제
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Plus, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||||
|
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||||
|
import type { LineItemTotals } from './types';
|
||||||
|
|
||||||
|
export interface LineItemsTableProps<T extends { id: string }> {
|
||||||
|
items: T[];
|
||||||
|
/** 품목명 가져오기 */
|
||||||
|
getItemName: (item: T) => string;
|
||||||
|
/** 수량 가져오기 */
|
||||||
|
getQuantity: (item: T) => number;
|
||||||
|
/** 단가 가져오기 */
|
||||||
|
getUnitPrice: (item: T) => number;
|
||||||
|
/** 공급가액 가져오기 */
|
||||||
|
getSupplyAmount: (item: T) => number;
|
||||||
|
/** 부가세 가져오기 */
|
||||||
|
getVat: (item: T) => number;
|
||||||
|
/** 적요 가져오기 */
|
||||||
|
getNote?: (item: T) => string;
|
||||||
|
/** 필드 변경 핸들러 */
|
||||||
|
onItemChange: (index: number, field: string, value: string | number) => void;
|
||||||
|
/** 행 추가 */
|
||||||
|
onAddItem: () => void;
|
||||||
|
/** 행 삭제 */
|
||||||
|
onRemoveItem: (index: number) => void;
|
||||||
|
/** 합계 */
|
||||||
|
totals: LineItemTotals;
|
||||||
|
/** view 모드 여부 */
|
||||||
|
isViewMode?: boolean;
|
||||||
|
/** 최소 유지 행 수 (삭제 버튼 표시 기준, 기본 1) */
|
||||||
|
minItems?: number;
|
||||||
|
/** 추가 버튼 라벨 */
|
||||||
|
addButtonLabel?: string;
|
||||||
|
/** 적요 컬럼 표시 여부 (기본 true) */
|
||||||
|
showNote?: boolean;
|
||||||
|
/** 테이블 앞뒤에 추가 컬럼 렌더링 */
|
||||||
|
renderExtraHeaders?: () => React.ReactNode;
|
||||||
|
renderExtraCells?: (item: T, index: number) => React.ReactNode;
|
||||||
|
renderExtraTotalCells?: () => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineItemsTable<T extends { id: string }>({
|
||||||
|
items,
|
||||||
|
getItemName,
|
||||||
|
getQuantity,
|
||||||
|
getUnitPrice,
|
||||||
|
getSupplyAmount,
|
||||||
|
getVat,
|
||||||
|
getNote,
|
||||||
|
onItemChange,
|
||||||
|
onAddItem,
|
||||||
|
onRemoveItem,
|
||||||
|
totals,
|
||||||
|
isViewMode = false,
|
||||||
|
minItems = 1,
|
||||||
|
addButtonLabel = '추가',
|
||||||
|
showNote = true,
|
||||||
|
renderExtraHeaders,
|
||||||
|
renderExtraCells,
|
||||||
|
renderExtraTotalCells,
|
||||||
|
}: LineItemsTableProps<T>) {
|
||||||
|
const noteColSpan = showNote ? 2 : 1; // 적요+삭제 or 삭제만
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
|
<TableHead>품목명</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">수량</TableHead>
|
||||||
|
<TableHead className="w-[120px] text-right">단가</TableHead>
|
||||||
|
<TableHead className="w-[120px] text-right">공급가액</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">부가세</TableHead>
|
||||||
|
{renderExtraHeaders?.()}
|
||||||
|
{showNote && <TableHead>적요</TableHead>}
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-center">{index + 1}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={getItemName(item)}
|
||||||
|
onChange={(e) => onItemChange(index, 'itemName', e.target.value)}
|
||||||
|
placeholder="품목명"
|
||||||
|
disabled={isViewMode}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<QuantityInput
|
||||||
|
value={getQuantity(item)}
|
||||||
|
onChange={(value) => onItemChange(index, 'quantity', value ?? 0)}
|
||||||
|
disabled={isViewMode}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<CurrencyInput
|
||||||
|
value={getUnitPrice(item)}
|
||||||
|
onChange={(value) => onItemChange(index, 'unitPrice', value ?? 0)}
|
||||||
|
disabled={isViewMode}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatAmount(getSupplyAmount(item))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatAmount(getVat(item))}
|
||||||
|
</TableCell>
|
||||||
|
{renderExtraCells?.(item, index)}
|
||||||
|
{showNote && (
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={getNote?.(item) ?? ''}
|
||||||
|
onChange={(e) => onItemChange(index, 'note', e.target.value)}
|
||||||
|
placeholder="적요"
|
||||||
|
disabled={isViewMode}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>
|
||||||
|
{!isViewMode && items.length > minItems && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-600 hover:text-red-700"
|
||||||
|
onClick={() => onRemoveItem(index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{/* 합계 행 */}
|
||||||
|
<TableRow className="bg-gray-50 font-medium">
|
||||||
|
<TableCell colSpan={4} className="text-right">
|
||||||
|
합계
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatAmount(totals.supplyAmount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatAmount(totals.vat)}
|
||||||
|
</TableCell>
|
||||||
|
{renderExtraTotalCells?.()}
|
||||||
|
<TableCell colSpan={noteColSpan}></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 추가 버튼 */}
|
||||||
|
{!isViewMode && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button variant="outline" onClick={onAddItem}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{addButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/organisms/LineItemsTable/calculations.ts
Normal file
33
src/components/organisms/LineItemsTable/calculations.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 품목 테이블 금액 계산 유틸리티
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_TAX_RATE = 0.1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공급가액 계산
|
||||||
|
*/
|
||||||
|
export function calcSupplyAmount(quantity: number, unitPrice: number): number {
|
||||||
|
return quantity * unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부가세 계산 (공급가액의 10%, 소수점 내림)
|
||||||
|
*/
|
||||||
|
export function calcVat(supplyAmount: number, taxRate: number = DEFAULT_TAX_RATE): number {
|
||||||
|
return Math.floor(supplyAmount * taxRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수량 또는 단가 변경 시 공급가액 + 부가세 자동 재계산
|
||||||
|
* @returns { supplyAmount, vat } 계산된 값
|
||||||
|
*/
|
||||||
|
export function recalculate(
|
||||||
|
quantity: number,
|
||||||
|
unitPrice: number,
|
||||||
|
taxRate: number = DEFAULT_TAX_RATE,
|
||||||
|
): { supplyAmount: number; vat: number } {
|
||||||
|
const supplyAmount = calcSupplyAmount(quantity, unitPrice);
|
||||||
|
const vat = calcVat(supplyAmount, taxRate);
|
||||||
|
return { supplyAmount, vat };
|
||||||
|
}
|
||||||
5
src/components/organisms/LineItemsTable/index.ts
Normal file
5
src/components/organisms/LineItemsTable/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { LineItemsTable } from './LineItemsTable';
|
||||||
|
export type { LineItemsTableProps } from './LineItemsTable';
|
||||||
|
export { useLineItems } from './useLineItems';
|
||||||
|
export { calcSupplyAmount, calcVat, recalculate } from './calculations';
|
||||||
|
export type { BaseLineItem, CalculatedLineItem, LineItemTotals } from './types';
|
||||||
27
src/components/organisms/LineItemsTable/types.ts
Normal file
27
src/components/organisms/LineItemsTable/types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* LineItemsTable 공통 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 품목 테이블의 기본 행 타입 */
|
||||||
|
export interface BaseLineItem {
|
||||||
|
id: string;
|
||||||
|
itemName: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공급가액+부가세 계산 결과가 포함된 행 타입 */
|
||||||
|
export interface CalculatedLineItem extends BaseLineItem {
|
||||||
|
/** 공급가액 (quantity × unitPrice) */
|
||||||
|
supplyAmount: number;
|
||||||
|
/** 부가세 (supplyAmount × taxRate) */
|
||||||
|
vat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 합계 데이터 */
|
||||||
|
export interface LineItemTotals {
|
||||||
|
supplyAmount: number;
|
||||||
|
vat: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
88
src/components/organisms/LineItemsTable/useLineItems.ts
Normal file
88
src/components/organisms/LineItemsTable/useLineItems.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 품목 리스트 관리 훅
|
||||||
|
* add / remove / update + 자동 계산 로직을 공통화
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { recalculate } from './calculations';
|
||||||
|
import type { LineItemTotals } from './types';
|
||||||
|
|
||||||
|
interface UseLineItemsOptions<T> {
|
||||||
|
items: T[];
|
||||||
|
setItems: React.Dispatch<React.SetStateAction<T[]>>;
|
||||||
|
createEmptyItem: () => T;
|
||||||
|
/** 공급가액 필드 키 (기본 'supplyAmount') */
|
||||||
|
supplyKey?: keyof T;
|
||||||
|
/** 부가세 필드 키 (기본 'vat') */
|
||||||
|
vatKey?: keyof T;
|
||||||
|
/** 세율 (기본 0.1) */
|
||||||
|
taxRate?: number;
|
||||||
|
/** 최소 유지 행 수 (기본 1) */
|
||||||
|
minItems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLineItems<T extends { id: string; quantity: number; unitPrice: number }>({
|
||||||
|
items,
|
||||||
|
setItems,
|
||||||
|
createEmptyItem,
|
||||||
|
supplyKey = 'supplyAmount' as keyof T,
|
||||||
|
vatKey = 'vat' as keyof T,
|
||||||
|
taxRate = 0.1,
|
||||||
|
minItems = 1,
|
||||||
|
}: UseLineItemsOptions<T>) {
|
||||||
|
const handleItemChange = useCallback(
|
||||||
|
(index: number, field: string, value: string | number) => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const newItems = [...prev];
|
||||||
|
const item = { ...newItems[index] };
|
||||||
|
|
||||||
|
if (field === 'quantity' || field === 'unitPrice') {
|
||||||
|
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
|
||||||
|
(item as any)[field] = numValue;
|
||||||
|
const qty = field === 'quantity' ? numValue : item.quantity;
|
||||||
|
const price = field === 'unitPrice' ? numValue : item.unitPrice;
|
||||||
|
const calc = recalculate(qty, price, taxRate);
|
||||||
|
(item as any)[supplyKey] = calc.supplyAmount;
|
||||||
|
(item as any)[vatKey] = calc.vat;
|
||||||
|
} else {
|
||||||
|
(item as any)[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
newItems[index] = item;
|
||||||
|
return newItems;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setItems, supplyKey, vatKey, taxRate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddItem = useCallback(() => {
|
||||||
|
setItems((prev) => [...prev, createEmptyItem()]);
|
||||||
|
}, [setItems, createEmptyItem]);
|
||||||
|
|
||||||
|
const handleRemoveItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setItems((prev) => {
|
||||||
|
if (prev.length <= minItems) return prev;
|
||||||
|
return prev.filter((_, i) => i !== index);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setItems, minItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals: LineItemTotals = useMemo(() => {
|
||||||
|
const totalSupply = items.reduce((sum, item) => sum + ((item as any)[supplyKey] ?? 0), 0);
|
||||||
|
const totalVat = items.reduce((sum, item) => sum + ((item as any)[vatKey] ?? 0), 0);
|
||||||
|
return {
|
||||||
|
supplyAmount: totalSupply,
|
||||||
|
vat: totalVat,
|
||||||
|
total: totalSupply + totalVat,
|
||||||
|
};
|
||||||
|
}, [items, supplyKey, vatKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleItemChange,
|
||||||
|
handleAddItem,
|
||||||
|
handleRemoveItem,
|
||||||
|
totals,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,16 +10,28 @@
|
|||||||
* - 검사대상 사전 고지 정보 테이블
|
* - 검사대상 사전 고지 정보 테이블
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ConstructionApprovalTable } from '@/components/document-system';
|
import {
|
||||||
|
ConstructionApprovalTable,
|
||||||
|
DocumentWrapper,
|
||||||
|
DocumentTable,
|
||||||
|
DOC_STYLES,
|
||||||
|
} from '@/components/document-system';
|
||||||
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
|
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
|
||||||
|
|
||||||
interface InspectionRequestDocumentProps {
|
interface InspectionRequestDocumentProps {
|
||||||
data: InspectionRequestDocumentType;
|
data: InspectionRequestDocumentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 라벨 셀 */
|
||||||
|
const lbl = `${DOC_STYLES.label} w-28`;
|
||||||
|
/** 서브 라벨 셀 (bg-gray-50) */
|
||||||
|
const subLbl = 'bg-gray-50 px-2 py-1 font-medium border-r border-gray-300 w-28';
|
||||||
|
/** 값 셀 */
|
||||||
|
const val = DOC_STYLES.value;
|
||||||
|
|
||||||
export function InspectionRequestDocument({ data }: InspectionRequestDocumentProps) {
|
export function InspectionRequestDocument({ data }: InspectionRequestDocumentProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-8 min-h-full text-[11px]">
|
<DocumentWrapper fontSize="text-[11px]">
|
||||||
{/* 헤더: 제목 (좌측) + 결재란 (우측) */}
|
{/* 헤더: 제목 (좌측) + 결재란 (우측) */}
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -51,47 +63,44 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="border border-gray-400 mb-4">
|
<DocumentTable header="기본 정보" headerVariant="light" spacing="mb-4">
|
||||||
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">기본 정보</div>
|
<tbody>
|
||||||
<table className="w-full">
|
<tr className="border-b border-gray-300">
|
||||||
<tbody>
|
<td className={lbl}>수주처</td>
|
||||||
<tr className="border-b border-gray-300">
|
<td className={`${val} border-r border-gray-300`}>{data.client || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">수주처</td>
|
<td className={lbl}>업체명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.client || '-'}</td>
|
<td className={val}>{data.companyName || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">업체명</td>
|
</tr>
|
||||||
<td className="px-2 py-1">{data.companyName || '-'}</td>
|
<tr className="border-b border-gray-300">
|
||||||
</tr>
|
<td className={lbl}>담당자</td>
|
||||||
<tr className="border-b border-gray-300">
|
<td className={`${val} border-r border-gray-300`}>{data.manager || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">담당자</td>
|
<td className={lbl}>수주번호</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.manager || '-'}</td>
|
<td className={val}>{data.orderNumber || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">수주번호</td>
|
</tr>
|
||||||
<td className="px-2 py-1">{data.orderNumber || '-'}</td>
|
<tr className="border-b border-gray-300">
|
||||||
</tr>
|
<td className={lbl}>담당자 연락처</td>
|
||||||
<tr className="border-b border-gray-300">
|
<td className={`${val} border-r border-gray-300`}>{data.managerContact || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">담당자 연락처</td>
|
<td className={lbl}>현장명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.managerContact || '-'}</td>
|
<td className={val}>{data.siteName || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">현장명</td>
|
</tr>
|
||||||
<td className="px-2 py-1">{data.siteName || '-'}</td>
|
<tr className="border-b border-gray-300">
|
||||||
</tr>
|
<td className={lbl}>납품일</td>
|
||||||
<tr className="border-b border-gray-300">
|
<td className={`${val} border-r border-gray-300`}>{data.deliveryDate || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">납품일</td>
|
<td className={lbl}>현장 주소</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.deliveryDate || '-'}</td>
|
<td className={val}>{data.siteAddress || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">현장 주소</td>
|
</tr>
|
||||||
<td className="px-2 py-1">{data.siteAddress || '-'}</td>
|
<tr>
|
||||||
</tr>
|
<td className={lbl}>총 개소</td>
|
||||||
<tr>
|
<td className={`${val} border-r border-gray-300`}>{data.totalLocations || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">총 개소</td>
|
<td className={lbl}>접수일</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.totalLocations || '-'}</td>
|
<td className={val}>{data.receptionDate || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">접수일</td>
|
</tr>
|
||||||
<td className="px-2 py-1">{data.receptionDate || '-'}</td>
|
<tr className="border-t border-gray-300">
|
||||||
</tr>
|
<td className={lbl}>검사방문요청일</td>
|
||||||
<tr className="border-t border-gray-300">
|
<td className={val} colSpan={3}>{data.visitRequestDate || '-'}</td>
|
||||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">검사방문요청일</td>
|
</tr>
|
||||||
<td className="px-2 py-1" colSpan={3}>{data.visitRequestDate || '-'}</td>
|
</tbody>
|
||||||
</tr>
|
</DocumentTable>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입력사항: 4개 섹션 */}
|
{/* 입력사항: 4개 섹션 */}
|
||||||
<div className="border border-gray-400 mb-4">
|
<div className="border border-gray-400 mb-4">
|
||||||
@@ -103,12 +112,12 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">현장명</td>
|
<td className={subLbl}>현장명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.constructionSite.siteName || '-'}</td>
|
<td className={`${val} border-r border-gray-300`}>{data.constructionSite.siteName || '-'}</td>
|
||||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">대지위치</td>
|
<td className={subLbl}>대지위치</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.constructionSite.landLocation || '-'}</td>
|
<td className={`${val} border-r border-gray-300`}>{data.constructionSite.landLocation || '-'}</td>
|
||||||
<td className="bg-gray-50 px-2 py-1 w-20 font-medium border-r border-gray-300">지번</td>
|
<td className={`${subLbl} w-20`}>지번</td>
|
||||||
<td className="px-2 py-1">{data.constructionSite.lotNumber || '-'}</td>
|
<td className={val}>{data.constructionSite.lotNumber || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -120,16 +129,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr className="border-b border-gray-300">
|
<tr className="border-b border-gray-300">
|
||||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">회사명</td>
|
<td className={subLbl}>회사명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.materialDistributor.companyName || '-'}</td>
|
<td className={`${val} border-r border-gray-300`}>{data.materialDistributor.companyName || '-'}</td>
|
||||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">회사주소</td>
|
<td className={subLbl}>회사주소</td>
|
||||||
<td className="px-2 py-1">{data.materialDistributor.companyAddress || '-'}</td>
|
<td className={val}>{data.materialDistributor.companyAddress || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">대표자명</td>
|
<td className={subLbl}>대표자명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.materialDistributor.representativeName || '-'}</td>
|
<td className={`${val} border-r border-gray-300`}>{data.materialDistributor.representativeName || '-'}</td>
|
||||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">전화번호</td>
|
<td className={subLbl}>전화번호</td>
|
||||||
<td className="px-2 py-1">{data.materialDistributor.phone || '-'}</td>
|
<td className={val}>{data.materialDistributor.phone || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -141,16 +150,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr className="border-b border-gray-300">
|
<tr className="border-b border-gray-300">
|
||||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">회사명</td>
|
<td className={subLbl}>회사명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.constructorInfo.companyName || '-'}</td>
|
<td className={`${val} border-r border-gray-300`}>{data.constructorInfo.companyName || '-'}</td>
|
||||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">회사주소</td>
|
<td className={subLbl}>회사주소</td>
|
||||||
<td className="px-2 py-1">{data.constructorInfo.companyAddress || '-'}</td>
|
<td className={val}>{data.constructorInfo.companyAddress || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">성명</td>
|
<td className={subLbl}>성명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.constructorInfo.name || '-'}</td>
|
<td className={`${val} border-r border-gray-300`}>{data.constructorInfo.name || '-'}</td>
|
||||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">전화번호</td>
|
<td className={subLbl}>전화번호</td>
|
||||||
<td className="px-2 py-1">{data.constructorInfo.phone || '-'}</td>
|
<td className={val}>{data.constructorInfo.phone || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -162,16 +171,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr className="border-b border-gray-300">
|
<tr className="border-b border-gray-300">
|
||||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">사무소명</td>
|
<td className={subLbl}>사무소명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.supervisor.officeName || '-'}</td>
|
<td className={`${val} border-r border-gray-300`}>{data.supervisor.officeName || '-'}</td>
|
||||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">사무소주소</td>
|
<td className={subLbl}>사무소주소</td>
|
||||||
<td className="px-2 py-1">{data.supervisor.officeAddress || '-'}</td>
|
<td className={val}>{data.supervisor.officeAddress || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">성명</td>
|
<td className={subLbl}>성명</td>
|
||||||
<td className="px-2 py-1 border-r border-gray-300">{data.supervisor.name || '-'}</td>
|
<td className={`${val} border-r border-gray-300`}>{data.supervisor.name || '-'}</td>
|
||||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">전화번호</td>
|
<td className={subLbl}>전화번호</td>
|
||||||
<td className="px-2 py-1">{data.supervisor.phone || '-'}</td>
|
<td className={val}>{data.supervisor.phone || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -179,71 +188,71 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검사 요청 시 필독 */}
|
{/* 검사 요청 시 필독 */}
|
||||||
<div className="border border-gray-400 mb-4">
|
<DocumentTable header="검사 요청 시 필독" headerVariant="dark" spacing="mb-4">
|
||||||
<div className="bg-gray-800 text-white text-center py-1 font-bold border-b border-gray-400">검사 요청 시 필독</div>
|
<tbody>
|
||||||
<div className="px-4 py-3 text-[11px] leading-relaxed text-center">
|
<tr>
|
||||||
<p>
|
<td className="px-4 py-3 text-[11px] leading-relaxed text-center">
|
||||||
발주 사이즈와 시공 완료된 사이즈가 다를 시, 일정 범위를 벗어날 경우
|
<p>
|
||||||
<br />
|
발주 사이즈와 시공 완료된 사이즈가 다를 시, 일정 범위를 벗어날 경우
|
||||||
인정마크를 부착할 수 없습니다. 제품검사를 위한 방문 전 미리
|
<br />
|
||||||
<br />
|
인정마크를 부착할 수 없습니다. 제품검사를 위한 방문 전 미리
|
||||||
<span className="text-red-600 font-medium">변경사항을 고지해주셔야 인정마크를 부착할 수 있습니다.</span>
|
<br />
|
||||||
</p>
|
<span className="text-red-600 font-medium">변경사항을 고지해주셔야 인정마크를 부착할 수 있습니다.</span>
|
||||||
<p className="mt-2 text-gray-600">
|
</p>
|
||||||
(사전고지를 하지 않음으로 발생하는 문제의 귀책은 신청업체에 있습니다.)
|
<p className="mt-2 text-gray-600">
|
||||||
</p>
|
(사전고지를 하지 않음으로 발생하는 문제의 귀책은 신청업체에 있습니다.)
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</DocumentTable>
|
||||||
|
|
||||||
{/* 검사대상 사전 고지 정보 */}
|
{/* 검사대상 사전 고지 정보 */}
|
||||||
<div className="border border-gray-400 mb-4">
|
<DocumentTable header="검사대상 사전 고지 정보" headerVariant="dark" spacing="mb-4">
|
||||||
<div className="bg-gray-800 text-white text-center py-1 font-bold border-b border-gray-400">검사대상 사전 고지 정보</div>
|
<thead>
|
||||||
<table className="w-full">
|
{/* 1단: 오픈사이즈 병합 */}
|
||||||
<thead>
|
<tr className="bg-gray-100 border-b border-gray-300">
|
||||||
{/* 1단: 오픈사이즈 병합 */}
|
<th className={`${DOC_STYLES.th} w-12`} rowSpan={3}>No.</th>
|
||||||
<tr className="bg-gray-100 border-b border-gray-300">
|
<th className={`${DOC_STYLES.th} w-16`} rowSpan={3}>층수</th>
|
||||||
<th className="border-r border-gray-400 px-2 py-1 w-12 text-center" rowSpan={3}>No.</th>
|
<th className={`${DOC_STYLES.th} w-20`} rowSpan={3}>부호</th>
|
||||||
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center" rowSpan={3}>층수</th>
|
<th className={DOC_STYLES.th} colSpan={4}>오픈사이즈</th>
|
||||||
<th className="border-r border-gray-400 px-2 py-1 w-20 text-center" rowSpan={3}>부호</th>
|
<th className={`${DOC_STYLES.th} border-r-0`} rowSpan={3}>변경사유</th>
|
||||||
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={4}>오픈사이즈</th>
|
</tr>
|
||||||
<th className="px-2 py-1 text-center" rowSpan={3}>변경사유</th>
|
{/* 2단: 발주 규격, 시공후 규격 */}
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-300">
|
||||||
|
<th className={DOC_STYLES.th} colSpan={2}>발주 규격</th>
|
||||||
|
<th className={DOC_STYLES.th} colSpan={2}>시공후 규격</th>
|
||||||
|
</tr>
|
||||||
|
{/* 3단: 가로, 세로 */}
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-400">
|
||||||
|
<th className={`${DOC_STYLES.th} w-16`}>가로</th>
|
||||||
|
<th className={`${DOC_STYLES.th} w-16`}>세로</th>
|
||||||
|
<th className={`${DOC_STYLES.th} w-16`}>가로</th>
|
||||||
|
<th className={`${DOC_STYLES.th} w-16`}>세로</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.priorNoticeItems.map((item, index) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-300">
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{index + 1}</td>
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{item.floor}</td>
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{item.symbol}</td>
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{item.orderWidth}</td>
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{item.orderHeight}</td>
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{item.constructionWidth}</td>
|
||||||
|
<td className={DOC_STYLES.tdCenter}>{item.constructionHeight}</td>
|
||||||
|
<td className={DOC_STYLES.td}>{item.changeReason || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* 2단: 발주 규격, 시공후 규격 */}
|
))}
|
||||||
<tr className="bg-gray-100 border-b border-gray-300">
|
{data.priorNoticeItems.length === 0 && (
|
||||||
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={2}>발주 규격</th>
|
<tr>
|
||||||
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={2}>시공후 규격</th>
|
<td colSpan={8} className="px-2 py-4 text-center text-gray-400">
|
||||||
|
검사대상 사전 고지 정보가 없습니다.
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* 3단: 가로, 세로 */}
|
)}
|
||||||
<tr className="bg-gray-100 border-b border-gray-400">
|
</tbody>
|
||||||
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center">가로</th>
|
</DocumentTable>
|
||||||
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center">세로</th>
|
|
||||||
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center">가로</th>
|
|
||||||
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center">세로</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.priorNoticeItems.map((item, index) => (
|
|
||||||
<tr key={item.id} className="border-b border-gray-300">
|
|
||||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{index + 1}</td>
|
|
||||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.floor}</td>
|
|
||||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.symbol}</td>
|
|
||||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderWidth}</td>
|
|
||||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderHeight}</td>
|
|
||||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionWidth}</td>
|
|
||||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionHeight}</td>
|
|
||||||
<td className="px-2 py-1">{item.changeReason || '-'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{data.priorNoticeItems.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-2 py-4 text-center text-gray-400">
|
|
||||||
검사대상 사전 고지 정보가 없습니다.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 서명 영역 */}
|
{/* 서명 영역 */}
|
||||||
<div className="mt-8 text-center text-[10px]">
|
<div className="mt-8 text-center text-[10px]">
|
||||||
@@ -252,6 +261,6 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
|||||||
<p>{data.createdDate}</p>
|
<p>{data.createdDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DocumentWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions';
|
import { usePermissionStore } from '@/stores/permissionStore';
|
||||||
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils';
|
import { findMatchingUrl } from '@/lib/permissions/utils';
|
||||||
import { ALL_DENIED_PERMS } from '@/lib/permissions/types';
|
|
||||||
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
|
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
|
||||||
import { AccessDenied } from '@/components/common/AccessDenied';
|
import { AccessDenied } from '@/components/common/AccessDenied';
|
||||||
import { stripLocalePrefix } from '@/lib/utils/locale';
|
import { stripLocalePrefix } from '@/lib/utils/locale';
|
||||||
|
|
||||||
interface PermissionContextType {
|
/**
|
||||||
permissionMap: PermissionMap | null;
|
* PermissionProvider — Zustand store 초기화 래퍼
|
||||||
isLoading: boolean;
|
*
|
||||||
/** URL 지정 권한 체크 (특수 케이스용) */
|
* 기존 Context.Provider 역할을 대체합니다.
|
||||||
can: (url: string, action: PermissionAction) => boolean;
|
* 마운트 시 한 번 loadPermissions() 호출만 담당합니다.
|
||||||
/** 권한 데이터 다시 로드 (설정 변경 후 호출) */
|
*/
|
||||||
reloadPermissions: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PermissionContext = createContext<PermissionContextType>({
|
|
||||||
permissionMap: null,
|
|
||||||
isLoading: true,
|
|
||||||
can: () => true,
|
|
||||||
reloadPermissions: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function PermissionProvider({ children }: { children: React.ReactNode }) {
|
export function PermissionProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [permissionMap, setPermissionMap] = useState<PermissionMap | null>(null);
|
const loadPermissions = usePermissionStore((s) => s.loadPermissions);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadPermissions = useCallback(async () => {
|
|
||||||
const userData = getUserData();
|
|
||||||
if (!userData || userData.roleIds.length === 0) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { roleIds, menuIdToUrl } = userData;
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// 사이드바 메뉴에 없는 권한 메뉴의 URL 매핑 보완
|
|
||||||
// (기준정보 관리, 공정관리 등 사이드바 미등록 메뉴 대응)
|
|
||||||
const [permMenuUrlMap, ...results] = await Promise.all([
|
|
||||||
getPermissionMenuUrlMap(),
|
|
||||||
...roleIds.map(id => getRolePermissionMatrix(id)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 권한 메뉴 URL을 베이스로, 사이드바 메뉴 URL로 덮어쓰기 (사이드바 우선)
|
|
||||||
const mergedMenuIdToUrl = { ...permMenuUrlMap, ...menuIdToUrl };
|
|
||||||
|
|
||||||
const maps = results
|
|
||||||
.filter(r => r.success && r.data?.permissions)
|
|
||||||
.map(r => convertMatrixToPermissionMap(r.data.permissions, mergedMenuIdToUrl));
|
|
||||||
|
|
||||||
if (maps.length > 0) {
|
|
||||||
const merged = mergePermissionMaps(maps);
|
|
||||||
|
|
||||||
// 권한 메뉴에 등록되어 있지만 매트릭스 응답에 없는 메뉴 처리
|
|
||||||
// (모든 권한 OFF → API가 해당 menuId를 생략 → "all denied"로 보완)
|
|
||||||
for (const [, url] of Object.entries(permMenuUrlMap)) {
|
|
||||||
if (url && !merged[url]) {
|
|
||||||
merged[url] = { ...ALL_DENIED_PERMS };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPermissionMap(merged);
|
|
||||||
} else {
|
|
||||||
setPermissionMap(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Permission] 권한 로드 실패:', error);
|
|
||||||
setPermissionMap(null);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 마운트 시 1회 로드
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPermissions();
|
loadPermissions();
|
||||||
}, [loadPermissions]);
|
}, [loadPermissions]);
|
||||||
|
|
||||||
const can = useCallback((url: string, action: PermissionAction): boolean => {
|
return <>{children}</>;
|
||||||
if (!permissionMap) return true;
|
|
||||||
const matchedUrl = findMatchingUrl(url, permissionMap);
|
|
||||||
if (!matchedUrl) return true;
|
|
||||||
const perms = permissionMap[matchedUrl];
|
|
||||||
return perms?.[action] ?? true;
|
|
||||||
}, [permissionMap]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PermissionContext.Provider value={{ permissionMap, isLoading, can, reloadPermissions: loadPermissions }}>
|
|
||||||
{children}
|
|
||||||
</PermissionContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,7 +31,7 @@ const BYPASS_PATHS = ['/settings/permissions'];
|
|||||||
|
|
||||||
function isGateBypassed(pathname: string): boolean {
|
function isGateBypassed(pathname: string): boolean {
|
||||||
const pathWithoutLocale = stripLocalePrefix(pathname);
|
const pathWithoutLocale = stripLocalePrefix(pathname);
|
||||||
return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp));
|
return BYPASS_PATHS.some((bp) => pathWithoutLocale.startsWith(bp));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,7 +39,8 @@ function isGateBypassed(pathname: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function PermissionGate({ children }: { children: React.ReactNode }) {
|
export function PermissionGate({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { permissionMap, isLoading } = useContext(PermissionContext);
|
const permissionMap = usePermissionStore((s) => s.permissionMap);
|
||||||
|
const isLoading = usePermissionStore((s) => s.isLoading);
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
if (!permissionMap) {
|
if (!permissionMap) {
|
||||||
@@ -135,27 +65,21 @@ export function PermissionGate({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** localStorage 'user' 키에서 역할 ID 배열 + menuId→URL 매핑 추출 */
|
/**
|
||||||
function getUserData(): { roleIds: number[]; menuIdToUrl: Record<string, string> } | null {
|
* 하위호환 훅 — 기존 usePermissionContext() 소비자를 위한 어댑터
|
||||||
if (typeof window === 'undefined') return null;
|
*
|
||||||
try {
|
* Zustand store에서 읽되, 기존과 동일한 인터페이스를 반환합니다.
|
||||||
const raw = localStorage.getItem('user');
|
*/
|
||||||
if (!raw) return null;
|
export function usePermissionContext(): {
|
||||||
|
permissionMap: PermissionMap | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
can: (url: string, action: PermissionAction) => boolean;
|
||||||
|
reloadPermissions: () => void;
|
||||||
|
} {
|
||||||
|
const permissionMap = usePermissionStore((s) => s.permissionMap);
|
||||||
|
const isLoading = usePermissionStore((s) => s.isLoading);
|
||||||
|
const can = usePermissionStore((s) => s.can);
|
||||||
|
const loadPermissions = usePermissionStore((s) => s.loadPermissions);
|
||||||
|
|
||||||
const parsed = JSON.parse(raw);
|
return { permissionMap, isLoading, can, reloadPermissions: loadPermissions };
|
||||||
|
|
||||||
const roleIds = Array.isArray(parsed.roles)
|
|
||||||
? parsed.roles.map((r: { id: number }) => r.id).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const menuIdToUrl = Array.isArray(parsed.menu)
|
|
||||||
? buildMenuIdToUrlMap(parsed.menu)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return { roleIds, menuIdToUrl };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePermissionContext = () => useContext(PermissionContext);
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { usePermissionContext } from '@/contexts/PermissionContext';
|
import { usePermissionStore } from '@/stores/permissionStore';
|
||||||
import { findMatchingUrl } from '@/lib/permissions/utils';
|
import { findMatchingUrl } from '@/lib/permissions/utils';
|
||||||
import { ALL_ALLOWED } from '@/lib/permissions/types';
|
import { ALL_ALLOWED } from '@/lib/permissions/types';
|
||||||
import type { UsePermissionReturn } from '@/lib/permissions/types';
|
import type { UsePermissionReturn } from '@/lib/permissions/types';
|
||||||
@@ -20,7 +20,8 @@ import type { UsePermissionReturn } from '@/lib/permissions/types';
|
|||||||
*/
|
*/
|
||||||
export function usePermission(overrideUrl?: string): UsePermissionReturn {
|
export function usePermission(overrideUrl?: string): UsePermissionReturn {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { permissionMap, isLoading } = usePermissionContext();
|
const permissionMap = usePermissionStore((s) => s.permissionMap);
|
||||||
|
const isLoading = usePermissionStore((s) => s.isLoading);
|
||||||
|
|
||||||
const targetPath = overrideUrl || pathname;
|
const targetPath = overrideUrl || pathname;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
32
src/lib/api/dashboard/transformers/calendar.ts
Normal file
32
src/lib/api/dashboard/transformers/calendar.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 캘린더 (Calendar) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CalendarApiResponse } from '../types';
|
||||||
|
import type { CalendarScheduleItem } from '@/components/business/CEODashboard/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar API 응답 → Frontend 타입 변환
|
||||||
|
* API 응답 형식이 CalendarScheduleItem과 동일하므로 단순 매핑
|
||||||
|
*/
|
||||||
|
export function transformCalendarResponse(api: CalendarApiResponse): {
|
||||||
|
items: CalendarScheduleItem[];
|
||||||
|
totalCount: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
items: api.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
startDate: item.startDate,
|
||||||
|
endDate: item.endDate,
|
||||||
|
startTime: item.startTime ?? undefined,
|
||||||
|
endTime: item.endTime ?? undefined,
|
||||||
|
isAllDay: item.isAllDay,
|
||||||
|
type: item.type,
|
||||||
|
department: item.department ?? undefined,
|
||||||
|
personName: item.personName ?? undefined,
|
||||||
|
color: item.color ?? undefined,
|
||||||
|
})),
|
||||||
|
totalCount: api.total_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
97
src/lib/api/dashboard/transformers/common.ts
Normal file
97
src/lib/api/dashboard/transformers/common.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Transformer 공유 헬퍼 함수
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HighlightColor } from '@/components/business/CEODashboard/types';
|
||||||
|
import { formatNumber } from '@/lib/utils/amount';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네비게이션 경로 정규화
|
||||||
|
* - /ko prefix 추가 (없는 경우)
|
||||||
|
* - 상세 페이지에 ?mode=view 추가 (필요시)
|
||||||
|
* @example normalizePath('/accounting/deposits/73') → '/ko/accounting/deposits/73?mode=view'
|
||||||
|
*/
|
||||||
|
export function normalizePath(path: string | undefined, options?: { addViewMode?: boolean }): string {
|
||||||
|
if (!path) return '';
|
||||||
|
|
||||||
|
let normalizedPath = path;
|
||||||
|
|
||||||
|
// /ko prefix 추가 (없는 경우)
|
||||||
|
if (!normalizedPath.startsWith('/ko')) {
|
||||||
|
normalizedPath = `/ko${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 페이지에 ?mode=view 추가 (숫자 ID가 있고 mode 파라미터가 없는 경우)
|
||||||
|
if (options?.addViewMode) {
|
||||||
|
const hasIdPattern = /\/\d+($|\?)/.test(normalizedPath);
|
||||||
|
const hasMode = /[?&]mode=/.test(normalizedPath);
|
||||||
|
const hasModal = /[?&]openModal=/.test(normalizedPath);
|
||||||
|
|
||||||
|
// ID가 있고, mode 파라미터가 없고, openModal도 없는 경우에만 ?mode=view 추가
|
||||||
|
if (hasIdPattern && !hasMode && !hasModal) {
|
||||||
|
normalizedPath = normalizedPath.includes('?')
|
||||||
|
? `${normalizedPath}&mode=view`
|
||||||
|
: `${normalizedPath}?mode=view`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 금액 포맷팅
|
||||||
|
* @example formatAmount(3050000000) → "30.5억원"
|
||||||
|
*/
|
||||||
|
export function formatAmount(amount: number): string {
|
||||||
|
const absAmount = Math.abs(amount);
|
||||||
|
if (absAmount >= 100000000) {
|
||||||
|
return `${(amount / 100000000).toFixed(1)}억원`;
|
||||||
|
} else if (absAmount >= 10000) {
|
||||||
|
return `${formatNumber(Math.round(amount / 10000))}만원`;
|
||||||
|
}
|
||||||
|
return `${formatNumber(amount)}원`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 포맷팅 (API → 한국어 형식)
|
||||||
|
* @example formatDate("2026-01-20", "월요일") → "2026년 1월 20일 월요일"
|
||||||
|
*/
|
||||||
|
export function formatDate(dateStr: string, dayOfWeek: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일 ${dayOfWeek}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 퍼센트 변화율 계산
|
||||||
|
*/
|
||||||
|
export function calculateChangeRate(current: number, previous: number): number {
|
||||||
|
if (previous === 0) return current > 0 ? 100 : 0;
|
||||||
|
return ((current - previous) / previous) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 변동률 → changeRate/changeDirection 변환 헬퍼
|
||||||
|
*/
|
||||||
|
export function toChangeFields(rate?: number): { changeRate?: string; changeDirection?: 'up' | 'down' } {
|
||||||
|
if (rate === undefined || rate === null) return {};
|
||||||
|
const direction = rate >= 0 ? 'up' as const : 'down' as const;
|
||||||
|
const sign = rate >= 0 ? '+' : '';
|
||||||
|
return {
|
||||||
|
changeRate: `${sign}${rate.toFixed(1)}%`,
|
||||||
|
changeDirection: direction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 유효한 하이라이트 색상 목록 */
|
||||||
|
const VALID_HIGHLIGHT_COLORS: HighlightColor[] = ['red', 'green', 'blue'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 색상 문자열 → Frontend 하이라이트 색상 변환
|
||||||
|
* 유효하지 않은 색상은 'blue'로 폴백
|
||||||
|
*/
|
||||||
|
export function validateHighlightColor(color: string): HighlightColor {
|
||||||
|
if (VALID_HIGHLIGHT_COLORS.includes(color as HighlightColor)) {
|
||||||
|
return color as HighlightColor;
|
||||||
|
}
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
189
src/lib/api/dashboard/transformers/daily-report.ts
Normal file
189
src/lib/api/dashboard/transformers/daily-report.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 일일 일보 (DailyReport) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DailyReportApiResponse } from '../types';
|
||||||
|
import type {
|
||||||
|
DailyReportData,
|
||||||
|
CheckPoint,
|
||||||
|
CheckPointType,
|
||||||
|
} from '@/components/business/CEODashboard/types';
|
||||||
|
import { formatAmount, formatDate, toChangeFields } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영자금 안정성에 따른 색상 반환
|
||||||
|
* 참조: AI 리포트 색상 체계 가이드 - 섹션 2.3
|
||||||
|
*/
|
||||||
|
function getStabilityColor(stability: string): 'red' | 'green' | 'blue' {
|
||||||
|
switch (stability) {
|
||||||
|
case 'stable':
|
||||||
|
return 'blue'; // 6개월 이상 - 안정적
|
||||||
|
case 'caution':
|
||||||
|
return 'green'; // 3-6개월 - 주의 (주황 대신 green 사용, 기존 타입 호환)
|
||||||
|
case 'warning':
|
||||||
|
return 'red'; // 3개월 미만 - 경고
|
||||||
|
default:
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영자금 안정성 메시지 생성
|
||||||
|
* - 음수: 현금성 자산 적자 상태
|
||||||
|
* - 0~3개월: 자금 부족 우려
|
||||||
|
* - 3~6개월: 자금 관리 필요
|
||||||
|
* - 6개월 이상: 안정적
|
||||||
|
*/
|
||||||
|
function getStabilityMessage(months: number | null, stability: string, cashAsset: number): string {
|
||||||
|
if (months === null) {
|
||||||
|
return '월 운영비 데이터가 없어 안정성을 판단할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현금성 자산이 음수인 경우 (적자 상태)
|
||||||
|
if (cashAsset < 0 || months < 0) {
|
||||||
|
return '현금성 자산이 부족한 상태입니다. 긴급 자금 확보가 필요합니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 운영 가능 기간이 거의 없는 경우 (1개월 미만)
|
||||||
|
if (months < 1) {
|
||||||
|
return '운영 자금이 거의 소진된 상태입니다. 즉시 자금 확보가 필요합니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthsText = `${months}개월분`;
|
||||||
|
|
||||||
|
switch (stability) {
|
||||||
|
case 'stable':
|
||||||
|
return `월 운영비용 대비 ${monthsText}이 확보되어 안정적입니다.`;
|
||||||
|
case 'caution':
|
||||||
|
return `월 운영비용 대비 ${monthsText}이 확보되어 있습니다. 자금 관리가 필요합니다.`;
|
||||||
|
case 'warning':
|
||||||
|
return `월 운영비용 대비 ${monthsText}만 확보되어 자금 부족 우려가 있습니다.`;
|
||||||
|
default:
|
||||||
|
return `월 운영비용 대비 ${monthsText}이 확보되어 있습니다.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 일보 CheckPoints 생성
|
||||||
|
* 참조: AI 리포트 색상 체계 가이드 - 섹션 2
|
||||||
|
*/
|
||||||
|
function generateDailyReportCheckPoints(api: DailyReportApiResponse): CheckPoint[] {
|
||||||
|
const checkPoints: CheckPoint[] = [];
|
||||||
|
|
||||||
|
// 출금 정보
|
||||||
|
const withdrawal = api.krw_totals.expense;
|
||||||
|
if (withdrawal > 0) {
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'dr-withdrawal',
|
||||||
|
type: 'info' as CheckPointType,
|
||||||
|
message: `어제 ${formatAmount(withdrawal)} 출금했습니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: formatAmount(withdrawal), color: 'red' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 입금 정보
|
||||||
|
const deposit = api.krw_totals.income;
|
||||||
|
if (deposit > 0) {
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'dr-deposit',
|
||||||
|
type: 'success' as CheckPointType,
|
||||||
|
message: `어제 ${formatAmount(deposit)}이 입금되었습니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: formatAmount(deposit), color: 'green' as const },
|
||||||
|
{ text: '입금', color: 'green' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현금성 자산 + 운영자금 안정성 현황
|
||||||
|
const cashAsset = api.cash_asset_total;
|
||||||
|
const operatingMonths = api.operating_months;
|
||||||
|
const operatingStability = api.operating_stability;
|
||||||
|
const stabilityColor = getStabilityColor(operatingStability);
|
||||||
|
const stabilityMessage = getStabilityMessage(operatingMonths, operatingStability, cashAsset);
|
||||||
|
|
||||||
|
// 하이라이트 생성 (음수/적자 상태일 때는 "X개월분" 대신 다른 메시지)
|
||||||
|
const isDeficit = cashAsset < 0 || (operatingMonths !== null && operatingMonths < 0);
|
||||||
|
const isAlmostEmpty = operatingMonths !== null && operatingMonths >= 0 && operatingMonths < 1;
|
||||||
|
|
||||||
|
const highlights: Array<{ text: string; color: 'red' | 'green' | 'blue' }> = [];
|
||||||
|
|
||||||
|
if (isDeficit) {
|
||||||
|
highlights.push({ text: '긴급 자금 확보 필요', color: 'red' });
|
||||||
|
} else if (isAlmostEmpty) {
|
||||||
|
highlights.push({ text: '즉시 자금 확보 필요', color: 'red' });
|
||||||
|
} else if (operatingMonths !== null && operatingMonths >= 1) {
|
||||||
|
highlights.push({ text: `${operatingMonths}개월분`, color: stabilityColor });
|
||||||
|
if (operatingStability === 'stable') {
|
||||||
|
highlights.push({ text: '안정적', color: 'blue' });
|
||||||
|
} else if (operatingStability === 'warning') {
|
||||||
|
highlights.push({ text: '자금 부족 우려', color: 'red' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'dr-cash-asset',
|
||||||
|
type: isDeficit || isAlmostEmpty ? 'warning' as CheckPointType : 'info' as CheckPointType,
|
||||||
|
message: `총 현금성 자산이 ${formatAmount(cashAsset)}입니다. ${stabilityMessage}`,
|
||||||
|
highlights,
|
||||||
|
});
|
||||||
|
|
||||||
|
return checkPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DailyReport API 응답 → Frontend 타입 변환
|
||||||
|
*/
|
||||||
|
export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData {
|
||||||
|
const change = api.daily_change;
|
||||||
|
|
||||||
|
// TODO: 백엔드 daily_change 필드 제공 시 더미값 제거
|
||||||
|
const FALLBACK_CHANGES = {
|
||||||
|
cash_asset: { changeRate: '+5.2%', changeDirection: 'up' as const },
|
||||||
|
foreign_currency: { changeRate: '+2.1%', changeDirection: 'up' as const },
|
||||||
|
income: { changeRate: '+12.0%', changeDirection: 'up' as const },
|
||||||
|
expense: { changeRate: '-8.0%', changeDirection: 'down' as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: formatDate(api.date, api.day_of_week),
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'dr1',
|
||||||
|
label: '현금성 자산 합계',
|
||||||
|
amount: api.cash_asset_total,
|
||||||
|
...(change?.cash_asset_change_rate !== undefined
|
||||||
|
? toChangeFields(change.cash_asset_change_rate)
|
||||||
|
: FALLBACK_CHANGES.cash_asset),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dr2',
|
||||||
|
label: '외국환(USD) 합계',
|
||||||
|
amount: api.foreign_currency_total,
|
||||||
|
currency: 'USD',
|
||||||
|
...(change?.foreign_currency_change_rate !== undefined
|
||||||
|
? toChangeFields(change.foreign_currency_change_rate)
|
||||||
|
: FALLBACK_CHANGES.foreign_currency),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dr3',
|
||||||
|
label: '입금 합계',
|
||||||
|
amount: api.krw_totals.income,
|
||||||
|
...(change?.income_change_rate !== undefined
|
||||||
|
? toChangeFields(change.income_change_rate)
|
||||||
|
: FALLBACK_CHANGES.income),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dr4',
|
||||||
|
label: '출금 합계',
|
||||||
|
amount: api.krw_totals.expense,
|
||||||
|
...(change?.expense_change_rate !== undefined
|
||||||
|
? toChangeFields(change.expense_change_rate)
|
||||||
|
: FALLBACK_CHANGES.expense),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkPoints: generateDailyReportCheckPoints(api),
|
||||||
|
};
|
||||||
|
}
|
||||||
462
src/lib/api/dashboard/transformers/expense-detail.ts
Normal file
462
src/lib/api/dashboard/transformers/expense-detail.ts
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* 지출 상세 모달 변환 (Purchase, Card, Bill, ExpectedExpense Detail)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PurchaseDashboardDetailApiResponse,
|
||||||
|
CardDashboardDetailApiResponse,
|
||||||
|
BillDashboardDetailApiResponse,
|
||||||
|
ExpectedExpenseDashboardDetailApiResponse,
|
||||||
|
} from '../types';
|
||||||
|
import type { DetailModalConfig } from '@/components/business/CEODashboard/types';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Purchase Dashboard Detail 변환 (me1)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purchase Dashboard Detail API 응답 → DetailModalConfig 변환
|
||||||
|
* 매입 상세 모달 설정 생성 (me1)
|
||||||
|
*/
|
||||||
|
export function transformPurchaseDetailResponse(api: PurchaseDashboardDetailApiResponse): DetailModalConfig {
|
||||||
|
const { summary, monthly_trend, by_type, items } = api;
|
||||||
|
const changeRateText = summary.change_rate >= 0
|
||||||
|
? `+${summary.change_rate.toFixed(1)}%`
|
||||||
|
: `${summary.change_rate.toFixed(1)}%`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '매입 상세',
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '당월 매입액', value: summary.current_month_amount, unit: '원' },
|
||||||
|
{ label: '전월 대비', value: changeRateText },
|
||||||
|
],
|
||||||
|
barChart: {
|
||||||
|
title: '월별 매입 추이',
|
||||||
|
data: monthly_trend.map(item => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.amount,
|
||||||
|
})),
|
||||||
|
dataKey: 'value',
|
||||||
|
xAxisKey: 'name',
|
||||||
|
color: '#60A5FA',
|
||||||
|
},
|
||||||
|
pieChart: {
|
||||||
|
title: '유형별 매입 비율',
|
||||||
|
data: by_type.map(item => ({
|
||||||
|
name: item.type_label,
|
||||||
|
value: item.amount,
|
||||||
|
percentage: 0, // API에서 계산하거나 프론트에서 계산
|
||||||
|
color: item.color,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
title: '매입 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'date', label: '매입일자', align: 'center', format: 'date' },
|
||||||
|
{ key: 'vendor', label: '거래처명', align: 'left' },
|
||||||
|
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'type', label: '유형', align: 'center' },
|
||||||
|
],
|
||||||
|
data: items.map((item, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
date: item.purchase_date,
|
||||||
|
vendor: item.vendor_name,
|
||||||
|
amount: item.amount,
|
||||||
|
type: item.type_label,
|
||||||
|
})),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
...by_type.map(t => ({ value: t.type, label: t.type_label })),
|
||||||
|
],
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sortOrder',
|
||||||
|
options: [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'amountDesc', label: '금액 높은순' },
|
||||||
|
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||||
|
],
|
||||||
|
defaultValue: 'latest',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: items.reduce((sum, item) => sum + item.amount, 0),
|
||||||
|
totalColumnKey: 'amount',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Card Dashboard Detail 변환 (me2)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card Dashboard Detail API 응답 → DetailModalConfig 변환
|
||||||
|
* 카드 상세 모달 설정 생성 (me2)
|
||||||
|
*/
|
||||||
|
export function transformCardDetailResponse(api: CardDashboardDetailApiResponse): DetailModalConfig {
|
||||||
|
const { summary, monthly_trend, by_user, items } = api;
|
||||||
|
const changeRate = summary.previous_month_total > 0
|
||||||
|
? ((summary.current_month_total - summary.previous_month_total) / summary.previous_month_total * 100)
|
||||||
|
: 0;
|
||||||
|
const changeRateText = changeRate >= 0
|
||||||
|
? `+${changeRate.toFixed(1)}%`
|
||||||
|
: `${changeRate.toFixed(1)}%`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '카드 사용 상세',
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '당월 사용액', value: summary.current_month_total, unit: '원' },
|
||||||
|
{ label: '전월 대비', value: changeRateText },
|
||||||
|
{ label: '당월 건수', value: summary.current_month_count, unit: '건' },
|
||||||
|
],
|
||||||
|
barChart: {
|
||||||
|
title: '월별 카드 사용 추이',
|
||||||
|
data: monthly_trend.map(item => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.amount,
|
||||||
|
})),
|
||||||
|
dataKey: 'value',
|
||||||
|
xAxisKey: 'name',
|
||||||
|
color: '#34D399',
|
||||||
|
},
|
||||||
|
pieChart: {
|
||||||
|
title: '사용자별 사용 비율',
|
||||||
|
data: by_user.map(item => ({
|
||||||
|
name: item.user_name,
|
||||||
|
value: item.amount,
|
||||||
|
percentage: 0,
|
||||||
|
color: item.color,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
title: '카드 사용 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||||
|
{ key: 'user', label: '사용자', align: 'center' },
|
||||||
|
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
||||||
|
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||||
|
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'usageType', label: '사용항목', align: 'center' },
|
||||||
|
],
|
||||||
|
data: items.map((item, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
cardName: item.card_name,
|
||||||
|
user: item.user_name,
|
||||||
|
date: item.transaction_date,
|
||||||
|
store: item.merchant_name,
|
||||||
|
amount: item.amount,
|
||||||
|
usageType: item.usage_type,
|
||||||
|
})),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
...by_user.map(u => ({ value: u.user_name, label: u.user_name })),
|
||||||
|
],
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sortOrder',
|
||||||
|
options: [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'amountDesc', label: '금액 높은순' },
|
||||||
|
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||||
|
],
|
||||||
|
defaultValue: 'latest',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: items.reduce((sum, item) => sum + item.amount, 0),
|
||||||
|
totalColumnKey: 'amount',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Bill Dashboard Detail 변환 (me3)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bill Dashboard Detail API 응답 → DetailModalConfig 변환
|
||||||
|
* 발행어음 상세 모달 설정 생성 (me3)
|
||||||
|
*/
|
||||||
|
export function transformBillDetailResponse(api: BillDashboardDetailApiResponse): DetailModalConfig {
|
||||||
|
const { summary, monthly_trend, by_vendor, items } = api;
|
||||||
|
const changeRateText = summary.change_rate >= 0
|
||||||
|
? `+${summary.change_rate.toFixed(1)}%`
|
||||||
|
: `${summary.change_rate.toFixed(1)}%`;
|
||||||
|
|
||||||
|
// 거래처별 가로 막대 차트 데이터
|
||||||
|
const horizontalBarData = by_vendor.map(item => ({
|
||||||
|
name: item.vendor_name,
|
||||||
|
value: item.amount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '발행어음 상세',
|
||||||
|
summaryCards: [
|
||||||
|
{ label: '당월 발행어음', value: summary.current_month_amount, unit: '원' },
|
||||||
|
{ label: '전월 대비', value: changeRateText },
|
||||||
|
],
|
||||||
|
barChart: {
|
||||||
|
title: '월별 발행어음 추이',
|
||||||
|
data: monthly_trend.map(item => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.amount,
|
||||||
|
})),
|
||||||
|
dataKey: 'value',
|
||||||
|
xAxisKey: 'name',
|
||||||
|
color: '#F59E0B',
|
||||||
|
},
|
||||||
|
horizontalBarChart: {
|
||||||
|
title: '거래처별 발행어음',
|
||||||
|
data: horizontalBarData,
|
||||||
|
dataKey: 'value',
|
||||||
|
yAxisKey: 'name',
|
||||||
|
color: '#8B5CF6',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
title: '발행어음 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'vendor', label: '거래처명', align: 'left' },
|
||||||
|
{ key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
|
||||||
|
{ key: 'dueDate', label: '만기일', align: 'center', format: 'date' },
|
||||||
|
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'status', label: '상태', align: 'center' },
|
||||||
|
],
|
||||||
|
data: items.map((item, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
vendor: item.vendor_name,
|
||||||
|
issueDate: item.issue_date,
|
||||||
|
dueDate: item.due_date,
|
||||||
|
amount: item.amount,
|
||||||
|
status: item.status_label,
|
||||||
|
})),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'vendor',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
...by_vendor.map(v => ({ value: v.vendor_name, label: v.vendor_name })),
|
||||||
|
],
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: 'pending', label: '대기중' },
|
||||||
|
{ value: 'paid', label: '결제완료' },
|
||||||
|
{ value: 'overdue', label: '연체' },
|
||||||
|
],
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sortOrder',
|
||||||
|
options: [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'amountDesc', label: '금액 높은순' },
|
||||||
|
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||||
|
],
|
||||||
|
defaultValue: 'latest',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: items.reduce((sum, item) => sum + item.amount, 0),
|
||||||
|
totalColumnKey: 'amount',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Expected Expense Dashboard Detail 변환 (me1~me4 통합)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// cardId별 제목 및 차트 설정 매핑
|
||||||
|
const EXPENSE_CARD_CONFIG: Record<string, {
|
||||||
|
title: string;
|
||||||
|
tableTitle: string;
|
||||||
|
summaryLabel: string;
|
||||||
|
barChartTitle: string;
|
||||||
|
pieChartTitle?: string;
|
||||||
|
horizontalBarChartTitle?: string;
|
||||||
|
hasBarChart: boolean;
|
||||||
|
hasPieChart: boolean;
|
||||||
|
hasHorizontalBarChart: boolean;
|
||||||
|
}> = {
|
||||||
|
me1: {
|
||||||
|
title: '당월 매입 상세',
|
||||||
|
tableTitle: '일별 매입 내역',
|
||||||
|
summaryLabel: '당월 매입',
|
||||||
|
barChartTitle: '월별 매입 추이',
|
||||||
|
pieChartTitle: '거래처별 매입 비율',
|
||||||
|
hasBarChart: true,
|
||||||
|
hasPieChart: true,
|
||||||
|
hasHorizontalBarChart: false,
|
||||||
|
},
|
||||||
|
me2: {
|
||||||
|
title: '당월 카드 상세',
|
||||||
|
tableTitle: '일별 카드 사용 내역',
|
||||||
|
summaryLabel: '당월 카드 사용',
|
||||||
|
barChartTitle: '월별 카드 사용 추이',
|
||||||
|
pieChartTitle: '거래처별 카드 사용 비율',
|
||||||
|
hasBarChart: true,
|
||||||
|
hasPieChart: true,
|
||||||
|
hasHorizontalBarChart: false,
|
||||||
|
},
|
||||||
|
me3: {
|
||||||
|
title: '당월 발행어음 상세',
|
||||||
|
tableTitle: '일별 발행어음 내역',
|
||||||
|
summaryLabel: '당월 발행어음 사용',
|
||||||
|
barChartTitle: '월별 발행어음 추이',
|
||||||
|
horizontalBarChartTitle: '당월 거래처별 발행어음',
|
||||||
|
hasBarChart: true,
|
||||||
|
hasPieChart: false,
|
||||||
|
hasHorizontalBarChart: true,
|
||||||
|
},
|
||||||
|
me4: {
|
||||||
|
title: '당월 지출 예상 상세',
|
||||||
|
tableTitle: '당월 지출 승인 내역서',
|
||||||
|
summaryLabel: '당월 지출 예상',
|
||||||
|
barChartTitle: '',
|
||||||
|
hasBarChart: false,
|
||||||
|
hasPieChart: false,
|
||||||
|
hasHorizontalBarChart: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExpectedExpense Dashboard Detail API 응답 → DetailModalConfig 변환
|
||||||
|
* 카드별 지출 상세 모달 설정 생성 (me1: 매입, me2: 카드, me3: 발행어음, me4: 전체)
|
||||||
|
*
|
||||||
|
* @param api API 응답 데이터
|
||||||
|
* @param cardId 카드 ID (me1~me4), 기본값 me4
|
||||||
|
*/
|
||||||
|
export function transformExpectedExpenseDetailResponse(
|
||||||
|
api: ExpectedExpenseDashboardDetailApiResponse,
|
||||||
|
cardId: string = 'me4'
|
||||||
|
): DetailModalConfig {
|
||||||
|
const { summary, monthly_trend, vendor_distribution, items, footer_summary } = api;
|
||||||
|
const changeRateText = summary.change_rate >= 0
|
||||||
|
? `+${summary.change_rate.toFixed(1)}%`
|
||||||
|
: `${summary.change_rate.toFixed(1)}%`;
|
||||||
|
|
||||||
|
// cardId별 설정 가져오기 (기본값: me4)
|
||||||
|
const config = EXPENSE_CARD_CONFIG[cardId] || EXPENSE_CARD_CONFIG.me4;
|
||||||
|
|
||||||
|
// 거래처 필터 옵션 생성
|
||||||
|
const vendorOptions = [{ value: 'all', label: '전체' }];
|
||||||
|
const uniqueVendors = [...new Set(items.map(item => item.vendor_name).filter(Boolean))];
|
||||||
|
uniqueVendors.forEach(vendor => {
|
||||||
|
vendorOptions.push({ value: vendor, label: vendor });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 결과 객체 생성
|
||||||
|
const result: DetailModalConfig = {
|
||||||
|
title: config.title,
|
||||||
|
summaryCards: [
|
||||||
|
{ label: config.summaryLabel, value: summary.total_amount, unit: '원' },
|
||||||
|
{ label: '전월 대비', value: changeRateText },
|
||||||
|
{ label: '지출 후 예상 잔액', value: summary.remaining_balance, unit: '원' },
|
||||||
|
],
|
||||||
|
table: {
|
||||||
|
title: config.tableTitle,
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'paymentDate', label: '결제예정일', align: 'center', format: 'date' },
|
||||||
|
{ key: 'item', label: '항목', align: 'left' },
|
||||||
|
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||||
|
{ key: 'account', label: '계정과목', align: 'center' },
|
||||||
|
],
|
||||||
|
data: items.map((item, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
paymentDate: item.payment_date,
|
||||||
|
item: item.item_name,
|
||||||
|
amount: item.amount,
|
||||||
|
vendor: item.vendor_name,
|
||||||
|
account: item.account_title,
|
||||||
|
})),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'vendor',
|
||||||
|
options: vendorOptions,
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sortOrder',
|
||||||
|
options: [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'amountDesc', label: '금액 높은순' },
|
||||||
|
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||||
|
],
|
||||||
|
defaultValue: 'latest',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: footer_summary.total_amount,
|
||||||
|
totalColumnKey: 'amount',
|
||||||
|
footerSummary: [
|
||||||
|
{ label: `총 ${footer_summary.item_count}건`, value: footer_summary.total_amount },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// barChart 추가 (me1, me2, me3)
|
||||||
|
if (config.hasBarChart && monthly_trend && monthly_trend.length > 0) {
|
||||||
|
result.barChart = {
|
||||||
|
title: config.barChartTitle,
|
||||||
|
data: monthly_trend.map(item => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.amount,
|
||||||
|
})),
|
||||||
|
dataKey: 'value',
|
||||||
|
xAxisKey: 'name',
|
||||||
|
color: '#60A5FA',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// pieChart 추가 (me1, me2)
|
||||||
|
if (config.hasPieChart && vendor_distribution && vendor_distribution.length > 0) {
|
||||||
|
result.pieChart = {
|
||||||
|
title: config.pieChartTitle || '분포',
|
||||||
|
data: vendor_distribution.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
value: item.value,
|
||||||
|
percentage: item.percentage,
|
||||||
|
color: item.color,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// horizontalBarChart 추가 (me3)
|
||||||
|
if (config.hasHorizontalBarChart && vendor_distribution && vendor_distribution.length > 0) {
|
||||||
|
result.horizontalBarChart = {
|
||||||
|
title: config.horizontalBarChartTitle || '거래처별 분포',
|
||||||
|
data: vendor_distribution.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
value: item.value,
|
||||||
|
})),
|
||||||
|
color: '#60A5FA',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
177
src/lib/api/dashboard/transformers/expense.ts
Normal file
177
src/lib/api/dashboard/transformers/expense.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* 월 예상 지출 (MonthlyExpense) + 카드/가지급금 (CardManagement) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExpectedExpenseApiResponse,
|
||||||
|
CardTransactionApiResponse,
|
||||||
|
LoanDashboardApiResponse,
|
||||||
|
TaxSimulationApiResponse,
|
||||||
|
} from '../types';
|
||||||
|
import type {
|
||||||
|
MonthlyExpenseData,
|
||||||
|
CardManagementData,
|
||||||
|
CheckPoint,
|
||||||
|
CheckPointType,
|
||||||
|
} from '@/components/business/CEODashboard/types';
|
||||||
|
import { formatAmount, calculateChangeRate } from './common';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 월 예상 지출 (MonthlyExpense)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 당월 예상 지출 CheckPoints 생성
|
||||||
|
*/
|
||||||
|
function generateMonthlyExpenseCheckPoints(api: ExpectedExpenseApiResponse): CheckPoint[] {
|
||||||
|
const checkPoints: CheckPoint[] = [];
|
||||||
|
|
||||||
|
// 총 예상 지출
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'me-total',
|
||||||
|
type: 'info' as CheckPointType,
|
||||||
|
message: `이번 달 예상 지출은 ${formatAmount(api.total_amount)}입니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: formatAmount(api.total_amount), color: 'blue' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return checkPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExpectedExpense API 응답 → Frontend 타입 변환
|
||||||
|
* 주의: 실제 API는 상세 분류(매입/카드/어음 등)를 제공하지 않음
|
||||||
|
* by_transaction_type에서 추출하거나 기본값 사용
|
||||||
|
*/
|
||||||
|
export function transformMonthlyExpenseResponse(api: ExpectedExpenseApiResponse): MonthlyExpenseData {
|
||||||
|
// transaction_type별 금액 추출
|
||||||
|
const purchaseTotal = api.by_transaction_type['purchase']?.total ?? 0;
|
||||||
|
const cardTotal = api.by_transaction_type['card']?.total ?? 0;
|
||||||
|
const billTotal = api.by_transaction_type['bill']?.total ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'me1',
|
||||||
|
label: '매입',
|
||||||
|
amount: purchaseTotal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'me2',
|
||||||
|
label: '카드',
|
||||||
|
amount: cardTotal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'me3',
|
||||||
|
label: '발행어음',
|
||||||
|
amount: billTotal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'me4',
|
||||||
|
label: '총 예상 지출 합계',
|
||||||
|
amount: api.total_amount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkPoints: generateMonthlyExpenseCheckPoints(api),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 카드/가지급금 (CardManagement)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드/가지급금 CheckPoints 생성
|
||||||
|
*/
|
||||||
|
function generateCardManagementCheckPoints(api: CardTransactionApiResponse): CheckPoint[] {
|
||||||
|
const checkPoints: CheckPoint[] = [];
|
||||||
|
|
||||||
|
// 전월 대비 변화
|
||||||
|
const changeRate = calculateChangeRate(api.current_month_total, api.previous_month_total);
|
||||||
|
if (Math.abs(changeRate) > 10) {
|
||||||
|
const type: CheckPointType = changeRate > 0 ? 'warning' : 'info';
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'cm-change',
|
||||||
|
type,
|
||||||
|
message: `당월 카드 사용액이 전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}% 변동했습니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: `${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`, color: changeRate > 0 ? 'red' as const : 'green' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 당월 사용액
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'cm-current',
|
||||||
|
type: 'info' as CheckPointType,
|
||||||
|
message: `당월 카드 사용 총 ${formatAmount(api.current_month_total)}입니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: formatAmount(api.current_month_total), color: 'blue' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return checkPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CardTransaction API 응답 → Frontend 타입 변환
|
||||||
|
* 4개 카드 구조:
|
||||||
|
* - cm1: 카드 사용액 (CardTransaction API)
|
||||||
|
* - cm2: 가지급금 (LoanDashboard API)
|
||||||
|
* - cm3: 법인세 예상 가중 (TaxSimulation API - corporate_tax.difference)
|
||||||
|
* - cm4: 대표자 종합세 예상 가중 (TaxSimulation API - income_tax.difference)
|
||||||
|
*/
|
||||||
|
export function transformCardManagementResponse(
|
||||||
|
summaryApi: CardTransactionApiResponse,
|
||||||
|
loanApi?: LoanDashboardApiResponse | null,
|
||||||
|
taxApi?: TaxSimulationApiResponse | null,
|
||||||
|
fallbackData?: CardManagementData
|
||||||
|
): CardManagementData {
|
||||||
|
const changeRate = calculateChangeRate(summaryApi.current_month_total, summaryApi.previous_month_total);
|
||||||
|
|
||||||
|
// cm2: 가지급금 금액 (LoanDashboard API 또는 fallback)
|
||||||
|
const loanAmount = loanApi?.summary?.total_outstanding ?? fallbackData?.cards[1]?.amount ?? 0;
|
||||||
|
|
||||||
|
// cm3: 법인세 예상 가중 (TaxSimulation API 또는 fallback)
|
||||||
|
const corporateTaxDifference = taxApi?.corporate_tax?.difference ?? fallbackData?.cards[2]?.amount ?? 0;
|
||||||
|
|
||||||
|
// cm4: 대표자 종합세 예상 가중 (TaxSimulation API 또는 fallback)
|
||||||
|
const incomeTaxDifference = taxApi?.income_tax?.difference ?? fallbackData?.cards[3]?.amount ?? 0;
|
||||||
|
|
||||||
|
// 가지급금 경고 배너 표시 여부 결정 (가지급금 잔액 > 0이면 표시)
|
||||||
|
const hasLoanWarning = loanAmount > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 가지급금 관련 경고 배너 (가지급금 있을 때만 표시)
|
||||||
|
warningBanner: hasLoanWarning ? fallbackData?.warningBanner : undefined,
|
||||||
|
cards: [
|
||||||
|
// cm1: 카드 사용액 (CardTransaction API)
|
||||||
|
{
|
||||||
|
id: 'cm1',
|
||||||
|
label: '카드',
|
||||||
|
amount: summaryApi.current_month_total,
|
||||||
|
previousLabel: `전월 대비 ${changeRate > 0 ? '+' : ''}${changeRate.toFixed(1)}%`,
|
||||||
|
},
|
||||||
|
// cm2: 가지급금 (LoanDashboard API)
|
||||||
|
{
|
||||||
|
id: 'cm2',
|
||||||
|
label: '가지급금',
|
||||||
|
amount: loanAmount,
|
||||||
|
},
|
||||||
|
// cm3: 법인세 예상 가중 (TaxSimulation API)
|
||||||
|
{
|
||||||
|
id: 'cm3',
|
||||||
|
label: '법인세 예상 가중',
|
||||||
|
amount: corporateTaxDifference,
|
||||||
|
},
|
||||||
|
// cm4: 대표자 종합세 예상 가중 (TaxSimulation API)
|
||||||
|
{
|
||||||
|
id: 'cm4',
|
||||||
|
label: '대표자 종합세 예상 가중',
|
||||||
|
amount: incomeTaxDifference,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkPoints: generateCardManagementCheckPoints(summaryApi),
|
||||||
|
};
|
||||||
|
}
|
||||||
192
src/lib/api/dashboard/transformers/receivable.ts
Normal file
192
src/lib/api/dashboard/transformers/receivable.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* 미수금 (Receivable) + 채권추심 (DebtCollection) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReceivablesApiResponse, BadDebtApiResponse } from '../types';
|
||||||
|
import type {
|
||||||
|
ReceivableData,
|
||||||
|
DebtCollectionData,
|
||||||
|
CheckPoint,
|
||||||
|
CheckPointType,
|
||||||
|
} from '@/components/business/CEODashboard/types';
|
||||||
|
import { formatAmount, normalizePath } from './common';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 미수금 (Receivable)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미수금 현황 CheckPoints 생성
|
||||||
|
*/
|
||||||
|
function generateReceivableCheckPoints(api: ReceivablesApiResponse): CheckPoint[] {
|
||||||
|
const checkPoints: CheckPoint[] = [];
|
||||||
|
|
||||||
|
// 연체 거래처 경고
|
||||||
|
if (api.overdue_vendor_count > 0) {
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'rv-overdue',
|
||||||
|
type: 'warning' as CheckPointType,
|
||||||
|
message: `연체 거래처 ${api.overdue_vendor_count}곳. 회수 조치가 필요합니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: `연체 거래처 ${api.overdue_vendor_count}곳`, color: 'red' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미수금 현황
|
||||||
|
if (api.total_receivables > 0) {
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'rv-total',
|
||||||
|
type: 'info' as CheckPointType,
|
||||||
|
message: `총 미수금 ${formatAmount(api.total_receivables)}입니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: formatAmount(api.total_receivables), color: 'blue' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receivables API 응답 → Frontend 타입 변환
|
||||||
|
*/
|
||||||
|
export function transformReceivableResponse(api: ReceivablesApiResponse): ReceivableData {
|
||||||
|
// 누적 미수금 = 이월 + 매출 - 입금
|
||||||
|
const cumulativeReceivable = api.total_carry_forward + api.total_sales - api.total_deposits;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'rv1',
|
||||||
|
label: '누적 미수금',
|
||||||
|
amount: cumulativeReceivable,
|
||||||
|
subItems: [
|
||||||
|
{ label: '이월', value: api.total_carry_forward },
|
||||||
|
{ label: '매출', value: api.total_sales },
|
||||||
|
{ label: '입금', value: api.total_deposits },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rv2',
|
||||||
|
label: '당월 미수금',
|
||||||
|
amount: api.total_receivables,
|
||||||
|
subItems: [
|
||||||
|
{ label: '매출', value: api.total_sales },
|
||||||
|
{ label: '입금', value: api.total_deposits },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rv3',
|
||||||
|
label: '거래처 현황',
|
||||||
|
amount: api.vendor_count,
|
||||||
|
unit: '곳',
|
||||||
|
subLabel: `연체 ${api.overdue_vendor_count}곳`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkPoints: generateReceivableCheckPoints(api),
|
||||||
|
//detailButtonLabel: '미수금 상세',
|
||||||
|
detailButtonPath: normalizePath('/accounting/receivables-status'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 채권추심 (DebtCollection)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채권추심 CheckPoints 생성
|
||||||
|
*/
|
||||||
|
function generateDebtCollectionCheckPoints(api: BadDebtApiResponse): CheckPoint[] {
|
||||||
|
const checkPoints: CheckPoint[] = [];
|
||||||
|
|
||||||
|
// 법적조치 진행 중
|
||||||
|
if (api.legal_action_amount > 0) {
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'dc-legal',
|
||||||
|
type: 'warning' as CheckPointType,
|
||||||
|
message: `법적조치 진행 중 ${formatAmount(api.legal_action_amount)}입니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: formatAmount(api.legal_action_amount), color: 'red' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회수 완료
|
||||||
|
if (api.recovered_amount > 0) {
|
||||||
|
checkPoints.push({
|
||||||
|
id: 'dc-recovered',
|
||||||
|
type: 'success' as CheckPointType,
|
||||||
|
message: `총 ${formatAmount(api.recovered_amount)}을 회수 완료했습니다.`,
|
||||||
|
highlights: [
|
||||||
|
{ text: formatAmount(api.recovered_amount), color: 'green' as const },
|
||||||
|
{ text: '회수 완료', color: 'green' as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 백엔드 per-card sub_label/count 제공 시 더미값 제거
|
||||||
|
// 채권추심 카드별 더미 서브라벨 (회사명 + 건수)
|
||||||
|
const DEBT_COLLECTION_FALLBACK_SUB_LABELS: Record<string, { company: string; count: number }> = {
|
||||||
|
dc1: { company: '(주)부산화학 외', count: 5 },
|
||||||
|
dc2: { company: '(주)삼성테크 외', count: 3 },
|
||||||
|
dc3: { company: '(주)대한전자 외', count: 2 },
|
||||||
|
dc4: { company: '(주)한국정밀 외', count: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채권추심 subLabel 생성 헬퍼
|
||||||
|
* dc1(누적)은 API client_count 사용, 나머지는 더미값
|
||||||
|
*/
|
||||||
|
function buildDebtSubLabel(cardId: string, clientCount?: number): string | undefined {
|
||||||
|
const fallback = DEBT_COLLECTION_FALLBACK_SUB_LABELS[cardId];
|
||||||
|
if (!fallback) return undefined;
|
||||||
|
|
||||||
|
const count = cardId === 'dc1' && clientCount !== undefined ? clientCount : fallback.count;
|
||||||
|
if (count <= 0) return undefined;
|
||||||
|
|
||||||
|
const remaining = count - 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
return `${fallback.company} ${remaining}건`;
|
||||||
|
}
|
||||||
|
return fallback.company.replace(/ 외$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BadDebt API 응답 → Frontend 타입 변환
|
||||||
|
*/
|
||||||
|
export function transformDebtCollectionResponse(api: BadDebtApiResponse): DebtCollectionData {
|
||||||
|
return {
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'dc1',
|
||||||
|
label: '누적 악성채권',
|
||||||
|
amount: api.total_amount,
|
||||||
|
subLabel: buildDebtSubLabel('dc1', api.client_count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dc2',
|
||||||
|
label: '추심중',
|
||||||
|
amount: api.collecting_amount,
|
||||||
|
subLabel: buildDebtSubLabel('dc2'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dc3',
|
||||||
|
label: '법적조치',
|
||||||
|
amount: api.legal_action_amount,
|
||||||
|
subLabel: buildDebtSubLabel('dc3'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dc4',
|
||||||
|
label: '회수완료',
|
||||||
|
amount: api.recovered_amount,
|
||||||
|
subLabel: buildDebtSubLabel('dc4'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkPoints: generateDebtCollectionCheckPoints(api),
|
||||||
|
detailButtonPath: normalizePath('/accounting/bad-debt-collection'),
|
||||||
|
};
|
||||||
|
}
|
||||||
173
src/lib/api/dashboard/transformers/status-issue.ts
Normal file
173
src/lib/api/dashboard/transformers/status-issue.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 현황판 (StatusBoard) + 오늘의 이슈 (TodayIssue) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StatusBoardApiResponse, TodayIssueApiResponse } from '../types';
|
||||||
|
import type {
|
||||||
|
TodayIssueItem,
|
||||||
|
TodayIssueListItem,
|
||||||
|
TodayIssueListBadgeType,
|
||||||
|
TodayIssueNotificationType,
|
||||||
|
} from '@/components/business/CEODashboard/types';
|
||||||
|
import { normalizePath } from './common';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 현황판 (StatusBoard)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// TODO: 백엔드 sub_label 필드 제공 시 더미값 제거
|
||||||
|
// API id 기준: orders, bad_debts, safety_stock, tax_deadline, new_clients, leaves, purchases, approvals
|
||||||
|
const STATUS_BOARD_FALLBACK_SUB_LABELS: Record<string, string> = {
|
||||||
|
orders: '(주)삼성전자 외',
|
||||||
|
bad_debts: '주식회사 부산화학 외',
|
||||||
|
safety_stock: '',
|
||||||
|
tax_deadline: '',
|
||||||
|
new_clients: '대한철강 외',
|
||||||
|
leaves: '',
|
||||||
|
purchases: '(유)한국정밀 외',
|
||||||
|
approvals: '구매 결재 외',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현황판 subLabel 생성 헬퍼
|
||||||
|
* API sub_label이 있으면 사용, 없으면 더미값 + 건수로 조합
|
||||||
|
*/
|
||||||
|
function buildStatusSubLabel(item: { id: string; count: number | string; sub_label?: string }): string | undefined {
|
||||||
|
// API에서 sub_label 제공 시 우선 사용
|
||||||
|
if (item.sub_label) return item.sub_label;
|
||||||
|
|
||||||
|
// 건수가 0이거나 문자열이면 subLabel 불필요
|
||||||
|
const count = typeof item.count === 'number' ? item.count : parseInt(String(item.count), 10);
|
||||||
|
if (isNaN(count) || count <= 0) return undefined;
|
||||||
|
|
||||||
|
const fallback = STATUS_BOARD_FALLBACK_SUB_LABELS[item.id];
|
||||||
|
if (!fallback) return undefined;
|
||||||
|
|
||||||
|
// "대한철강 외" + 나머지 건수
|
||||||
|
const remaining = count - 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
return `${fallback} ${remaining}건`;
|
||||||
|
}
|
||||||
|
// 1건이면 "외" 제거하고 이름만
|
||||||
|
return fallback.replace(/ 외$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusBoard API 응답 → Frontend 타입 변환
|
||||||
|
* API 응답 형식이 TodayIssueItem과 거의 동일하므로 단순 매핑
|
||||||
|
*/
|
||||||
|
export function transformStatusBoardResponse(api: StatusBoardApiResponse): TodayIssueItem[] {
|
||||||
|
return api.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
count: item.count,
|
||||||
|
subLabel: buildStatusSubLabel(item),
|
||||||
|
path: normalizePath(item.path, { addViewMode: true }),
|
||||||
|
isHighlighted: item.isHighlighted,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 오늘의 이슈 (TodayIssue)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 유효한 notification_type 목록 (API TodayIssue 모델과 동기화) */
|
||||||
|
const VALID_NOTIFICATION_TYPES: TodayIssueNotificationType[] = [
|
||||||
|
'sales_order',
|
||||||
|
'bad_debt',
|
||||||
|
'safety_stock',
|
||||||
|
'expected_expense',
|
||||||
|
'vat_report',
|
||||||
|
'approval_request',
|
||||||
|
'new_vendor',
|
||||||
|
'deposit',
|
||||||
|
'withdrawal',
|
||||||
|
'other',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** notification_type → 한글 badge 변환 매핑
|
||||||
|
* 백엔드 TodayIssue.php BADGE 상수와 동기화!
|
||||||
|
*/
|
||||||
|
const NOTIFICATION_TYPE_TO_BADGE: Record<TodayIssueNotificationType, TodayIssueListBadgeType> = {
|
||||||
|
sales_order: '수주등록',
|
||||||
|
bad_debt: '추심이슈',
|
||||||
|
safety_stock: '안전재고',
|
||||||
|
expected_expense: '지출 승인대기',
|
||||||
|
vat_report: '세금 신고',
|
||||||
|
approval_request: '결재 요청',
|
||||||
|
new_vendor: '신규거래처',
|
||||||
|
deposit: '입금',
|
||||||
|
withdrawal: '출금',
|
||||||
|
other: '기타',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 한글 badge → notification_type 추론 매핑 (fallback용)
|
||||||
|
* 백엔드 TodayIssue.php BADGE 상수와 동기화 필수!
|
||||||
|
*/
|
||||||
|
const BADGE_TO_NOTIFICATION_TYPE: Record<string, TodayIssueNotificationType> = {
|
||||||
|
// === 백엔드 실제 값 (TodayIssue.php 상수) ===
|
||||||
|
'수주등록': 'sales_order',
|
||||||
|
'추심이슈': 'bad_debt',
|
||||||
|
'안전재고': 'safety_stock',
|
||||||
|
'지출 승인대기': 'expected_expense',
|
||||||
|
'세금 신고': 'vat_report',
|
||||||
|
'결재 요청': 'approval_request',
|
||||||
|
'신규거래처': 'new_vendor',
|
||||||
|
'신규업체': 'new_vendor', // 변형
|
||||||
|
'입금': 'deposit',
|
||||||
|
'출금': 'withdrawal',
|
||||||
|
// === 혹시 모를 변형 (안전장치) ===
|
||||||
|
'수주 등록': 'sales_order',
|
||||||
|
'추심 이슈': 'bad_debt',
|
||||||
|
'안전 재고': 'safety_stock',
|
||||||
|
'지출승인대기': 'expected_expense',
|
||||||
|
'세금신고': 'vat_report',
|
||||||
|
'결재요청': 'approval_request',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API notification_type → Frontend notificationType 변환
|
||||||
|
* notification_type이 없으면 badge에서 추론
|
||||||
|
*/
|
||||||
|
function validateNotificationType(notificationType: string | null, badge?: string): TodayIssueNotificationType {
|
||||||
|
// 1. notification_type이 유효하면 그대로 사용
|
||||||
|
if (notificationType && VALID_NOTIFICATION_TYPES.includes(notificationType as TodayIssueNotificationType)) {
|
||||||
|
return notificationType as TodayIssueNotificationType;
|
||||||
|
}
|
||||||
|
// 2. notification_type이 없으면 badge에서 추론
|
||||||
|
if (badge && BADGE_TO_NOTIFICATION_TYPE[badge]) {
|
||||||
|
return BADGE_TO_NOTIFICATION_TYPE[badge];
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TodayIssue API 응답 → Frontend 타입 변환
|
||||||
|
* 오늘의 이슈 리스트 데이터 변환
|
||||||
|
* notification_type 코드값 기반으로 색상 매핑 지원
|
||||||
|
*/
|
||||||
|
export function transformTodayIssueResponse(api: TodayIssueApiResponse): {
|
||||||
|
items: TodayIssueListItem[];
|
||||||
|
totalCount: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
items: api.items.map((item) => {
|
||||||
|
// notification_type이 없으면 badge에서 추론
|
||||||
|
const notificationType = validateNotificationType(item.notification_type, item.badge);
|
||||||
|
// badge는 API 응답 그대로 사용하되, 없으면 notification_type에서 변환
|
||||||
|
const badge = item.badge || NOTIFICATION_TYPE_TO_BADGE[notificationType];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
badge: badge as TodayIssueListBadgeType,
|
||||||
|
notificationType,
|
||||||
|
content: item.content,
|
||||||
|
time: item.time,
|
||||||
|
date: item.date,
|
||||||
|
needsApproval: item.needsApproval ?? false,
|
||||||
|
path: normalizePath(item.path, { addViewMode: true }),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
totalCount: api.total_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
290
src/lib/api/dashboard/transformers/tax-benefits.ts
Normal file
290
src/lib/api/dashboard/transformers/tax-benefits.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* 부가세 (Vat) + 접대비 (Entertainment) + 복리후생비 (Welfare) 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
VatApiResponse,
|
||||||
|
EntertainmentApiResponse,
|
||||||
|
WelfareApiResponse,
|
||||||
|
WelfareDetailApiResponse,
|
||||||
|
} from '../types';
|
||||||
|
import type {
|
||||||
|
VatData,
|
||||||
|
EntertainmentData,
|
||||||
|
WelfareData,
|
||||||
|
CheckPointType,
|
||||||
|
DetailModalConfig,
|
||||||
|
} from '@/components/business/CEODashboard/types';
|
||||||
|
import { validateHighlightColor } from './common';
|
||||||
|
import { formatNumber } from '@/lib/utils/amount';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 부가세 (Vat)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vat API 응답 → Frontend 타입 변환
|
||||||
|
* 부가세 현황 데이터 변환
|
||||||
|
*/
|
||||||
|
export function transformVatResponse(api: VatApiResponse): VatData {
|
||||||
|
return {
|
||||||
|
cards: api.cards.map((card) => ({
|
||||||
|
id: card.id,
|
||||||
|
label: card.label,
|
||||||
|
amount: card.amount,
|
||||||
|
subLabel: card.subLabel,
|
||||||
|
unit: card.unit,
|
||||||
|
})),
|
||||||
|
checkPoints: api.check_points.map((cp) => ({
|
||||||
|
id: cp.id,
|
||||||
|
type: cp.type as CheckPointType,
|
||||||
|
message: cp.message,
|
||||||
|
highlights: cp.highlights?.map((h) => ({
|
||||||
|
text: h.text,
|
||||||
|
color: validateHighlightColor(h.color),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 접대비 (Entertainment)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entertainment API 응답 → Frontend 타입 변환
|
||||||
|
* 접대비 현황 데이터 변환
|
||||||
|
*/
|
||||||
|
export function transformEntertainmentResponse(api: EntertainmentApiResponse): EntertainmentData {
|
||||||
|
// 사용금액(et_used)을 잔여한도(et_remaining) 앞으로 재배치
|
||||||
|
const reordered = [...api.cards];
|
||||||
|
const usedIdx = reordered.findIndex((c) => c.id === 'et_used');
|
||||||
|
const remainIdx = reordered.findIndex((c) => c.id === 'et_remaining');
|
||||||
|
if (usedIdx > remainIdx && remainIdx >= 0) {
|
||||||
|
const [used] = reordered.splice(usedIdx, 1);
|
||||||
|
reordered.splice(remainIdx, 0, used);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards: reordered.map((card) => ({
|
||||||
|
id: card.id,
|
||||||
|
label: card.label,
|
||||||
|
amount: card.amount,
|
||||||
|
subLabel: card.subLabel,
|
||||||
|
unit: card.unit,
|
||||||
|
})),
|
||||||
|
checkPoints: api.check_points.map((cp) => ({
|
||||||
|
id: cp.id,
|
||||||
|
type: cp.type as CheckPointType,
|
||||||
|
message: cp.message,
|
||||||
|
highlights: cp.highlights?.map((h) => ({
|
||||||
|
text: h.text,
|
||||||
|
color: validateHighlightColor(h.color),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 복리후생비 (Welfare)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welfare API 응답 → Frontend 타입 변환
|
||||||
|
* 복리후생비 현황 데이터 변환
|
||||||
|
*/
|
||||||
|
export function transformWelfareResponse(api: WelfareApiResponse): WelfareData {
|
||||||
|
// 사용금액(wf_used)을 잔여한도(wf_remaining) 앞으로 재배치
|
||||||
|
const reordered = [...api.cards];
|
||||||
|
const usedIdx = reordered.findIndex((c) => c.id === 'wf_used');
|
||||||
|
const remainIdx = reordered.findIndex((c) => c.id === 'wf_remaining');
|
||||||
|
if (usedIdx > remainIdx && remainIdx >= 0) {
|
||||||
|
const [used] = reordered.splice(usedIdx, 1);
|
||||||
|
reordered.splice(remainIdx, 0, used);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards: reordered.map((card) => ({
|
||||||
|
id: card.id,
|
||||||
|
label: card.label,
|
||||||
|
amount: card.amount,
|
||||||
|
subLabel: card.subLabel,
|
||||||
|
unit: card.unit,
|
||||||
|
})),
|
||||||
|
checkPoints: api.check_points.map((cp) => ({
|
||||||
|
id: cp.id,
|
||||||
|
type: cp.type as CheckPointType,
|
||||||
|
message: cp.message,
|
||||||
|
highlights: cp.highlights?.map((h) => ({
|
||||||
|
text: h.text,
|
||||||
|
color: validateHighlightColor(h.color),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 복리후생비 상세 (WelfareDetail)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WelfareDetail API 응답 → DetailModalConfig 변환
|
||||||
|
* 복리후생비 상세 모달 설정 생성
|
||||||
|
*/
|
||||||
|
export function transformWelfareDetailResponse(api: WelfareDetailApiResponse): DetailModalConfig {
|
||||||
|
const { summary, monthly_usage, category_distribution, transactions, calculation, quarterly } = api;
|
||||||
|
|
||||||
|
// 계산 방식에 따른 calculationCards 생성
|
||||||
|
const calculationCards = calculation.type === 'fixed'
|
||||||
|
? {
|
||||||
|
title: '복리후생비 계산',
|
||||||
|
subtitle: `직원당 정액 금액/월 ${formatNumber(calculation.fixed_amount_per_month ?? 200000)}원`,
|
||||||
|
cards: [
|
||||||
|
{ label: '직원 수', value: calculation.employee_count, unit: '명' },
|
||||||
|
{ label: '연간 직원당 월급 금액', value: calculation.annual_amount_per_employee ?? 0, unit: '원', operator: '×' as const },
|
||||||
|
{ label: '당해년도 복리후생비 총 한도', value: calculation.annual_limit, unit: '원', operator: '=' as const },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: '복리후생비 계산',
|
||||||
|
subtitle: `연봉 총액 기준 비율 ${((calculation.ratio ?? 0.05) * 100).toFixed(1)}%`,
|
||||||
|
cards: [
|
||||||
|
{ label: '연봉 총액', value: calculation.total_salary ?? 0, unit: '원' },
|
||||||
|
{ label: '비율', value: (calculation.ratio ?? 0.05) * 100, unit: '%', operator: '×' as const },
|
||||||
|
{ label: '당해년도 복리후생비 총 한도', value: calculation.annual_limit, unit: '원', operator: '=' as const },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 분기 라벨 가져오기 (현재 분기 기준)
|
||||||
|
const currentQuarter = quarterly.find(q => q.used !== null)?.quarter ?? 1;
|
||||||
|
const quarterLabel = `${currentQuarter}사분기`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: '복리후생비 상세',
|
||||||
|
summaryCards: [
|
||||||
|
// 1행: 당해년도 기준
|
||||||
|
{ label: '당해년도 복리후생비 계정', value: summary.annual_account, unit: '원' },
|
||||||
|
{ label: '당해년도 복리후생비 한도', value: summary.annual_limit, unit: '원' },
|
||||||
|
{ label: '당해년도 복리후생비 사용', value: summary.annual_used, unit: '원' },
|
||||||
|
{ label: '당해년도 잔여한도', value: summary.annual_remaining, unit: '원' },
|
||||||
|
// 2행: 분기 기준
|
||||||
|
{ label: `${quarterLabel} 복리후생비 총 한도`, value: summary.quarterly_limit, unit: '원' },
|
||||||
|
{ label: `${quarterLabel} 복리후생비 잔여한도`, value: summary.quarterly_remaining, unit: '원' },
|
||||||
|
{ label: `${quarterLabel} 복리후생비 사용금액`, value: summary.quarterly_used, unit: '원' },
|
||||||
|
{ label: `${quarterLabel} 복리후생비 초과 금액`, value: summary.quarterly_exceeded, unit: '원' },
|
||||||
|
],
|
||||||
|
barChart: {
|
||||||
|
title: '월별 복리후생비 사용 추이',
|
||||||
|
data: monthly_usage.map(item => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.amount,
|
||||||
|
})),
|
||||||
|
dataKey: 'value',
|
||||||
|
xAxisKey: 'name',
|
||||||
|
color: '#60A5FA',
|
||||||
|
},
|
||||||
|
pieChart: {
|
||||||
|
title: '항목별 사용 비율',
|
||||||
|
data: category_distribution.map(item => ({
|
||||||
|
name: item.category_label,
|
||||||
|
value: item.amount,
|
||||||
|
percentage: item.percentage,
|
||||||
|
color: item.color,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
title: '일별 복리후생비 사용 내역',
|
||||||
|
columns: [
|
||||||
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
|
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||||
|
{ key: 'user', label: '사용자', align: 'center' },
|
||||||
|
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
||||||
|
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||||
|
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||||
|
{ key: 'usageType', label: '사용항목', align: 'center' },
|
||||||
|
],
|
||||||
|
data: transactions.map((tx, idx) => ({
|
||||||
|
no: idx + 1,
|
||||||
|
cardName: tx.card_name,
|
||||||
|
user: tx.user_name,
|
||||||
|
date: tx.transaction_date,
|
||||||
|
store: tx.merchant_name,
|
||||||
|
amount: tx.amount,
|
||||||
|
usageType: tx.usage_type_label,
|
||||||
|
})),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'usageType',
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '전체' },
|
||||||
|
{ value: '식비', label: '식비' },
|
||||||
|
{ value: '건강검진', label: '건강검진' },
|
||||||
|
{ value: '경조사비', label: '경조사비' },
|
||||||
|
{ value: '기타', label: '기타' },
|
||||||
|
],
|
||||||
|
defaultValue: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sortOrder',
|
||||||
|
options: [
|
||||||
|
{ value: 'latest', label: '최신순' },
|
||||||
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'amountDesc', label: '금액 높은순' },
|
||||||
|
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||||
|
],
|
||||||
|
defaultValue: 'latest',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showTotal: true,
|
||||||
|
totalLabel: '합계',
|
||||||
|
totalValue: transactions.reduce((sum, tx) => sum + tx.amount, 0),
|
||||||
|
totalColumnKey: 'amount',
|
||||||
|
},
|
||||||
|
calculationCards,
|
||||||
|
quarterlyTable: {
|
||||||
|
title: '복리후생비 현황',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
label: '한도금액',
|
||||||
|
q1: quarterly[0]?.limit ?? 0,
|
||||||
|
q2: quarterly[1]?.limit ?? 0,
|
||||||
|
q3: quarterly[2]?.limit ?? 0,
|
||||||
|
q4: quarterly[3]?.limit ?? 0,
|
||||||
|
total: quarterly.reduce((sum, q) => sum + (q.limit ?? 0), 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '이월금액',
|
||||||
|
q1: quarterly[0]?.carryover ?? 0,
|
||||||
|
q2: quarterly[1]?.carryover ?? '',
|
||||||
|
q3: quarterly[2]?.carryover ?? '',
|
||||||
|
q4: quarterly[3]?.carryover ?? '',
|
||||||
|
total: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '사용금액',
|
||||||
|
q1: quarterly[0]?.used ?? '',
|
||||||
|
q2: quarterly[1]?.used ?? '',
|
||||||
|
q3: quarterly[2]?.used ?? '',
|
||||||
|
q4: quarterly[3]?.used ?? '',
|
||||||
|
total: quarterly.reduce((sum, q) => sum + (q.used ?? 0), 0) || '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '잔여한도',
|
||||||
|
q1: quarterly[0]?.remaining ?? '',
|
||||||
|
q2: quarterly[1]?.remaining ?? '',
|
||||||
|
q3: quarterly[2]?.remaining ?? '',
|
||||||
|
q4: quarterly[3]?.remaining ?? '',
|
||||||
|
total: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '초과금액',
|
||||||
|
q1: quarterly[0]?.exceeded ?? '',
|
||||||
|
q2: quarterly[1]?.exceeded ?? '',
|
||||||
|
q3: quarterly[2]?.exceeded ?? '',
|
||||||
|
q4: quarterly[3]?.exceeded ?? '',
|
||||||
|
total: quarterly.reduce((sum, q) => sum + (q.exceeded ?? 0), 0) || '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -152,35 +152,9 @@ export function parseAccountNumber(formatted: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 숫자 천단위 콤마 포맷팅
|
* 숫자 천단위 콤마 포맷팅 — @/lib/utils/amount 통합 버전으로 위임
|
||||||
*/
|
*/
|
||||||
export function formatNumber(value: number | string, options?: {
|
export { formatNumber, type FormatNumberOptions } from '@/lib/utils/amount';
|
||||||
useComma?: boolean;
|
|
||||||
decimalPlaces?: number;
|
|
||||||
suffix?: string;
|
|
||||||
}): string {
|
|
||||||
const { useComma = true, decimalPlaces, suffix = '' } = options || {};
|
|
||||||
|
|
||||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
||||||
|
|
||||||
if (isNaN(num)) return '';
|
|
||||||
|
|
||||||
let formatted: string;
|
|
||||||
|
|
||||||
if (decimalPlaces !== undefined) {
|
|
||||||
formatted = num.toFixed(decimalPlaces);
|
|
||||||
} else {
|
|
||||||
formatted = String(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useComma) {
|
|
||||||
const parts = formatted.split('.');
|
|
||||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
||||||
formatted = parts.join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return suffix ? `${formatted}${suffix}` : formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 포맷된 숫자 문자열에서 숫자 추출
|
* 포맷된 숫자 문자열에서 숫자 추출
|
||||||
|
|||||||
@@ -5,14 +5,56 @@
|
|||||||
* 1만원 이상: "1,000만원"
|
* 1만원 이상: "1,000만원"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatNumber 옵션 (formatters.ts 호환)
|
||||||
|
*/
|
||||||
|
export interface FormatNumberOptions {
|
||||||
|
useComma?: boolean;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단순 숫자 포맷 (천단위 콤마만, 단위 없음)
|
* 단순 숫자 포맷 (천단위 콤마만, 단위 없음)
|
||||||
|
*
|
||||||
|
* - 옵션 없이 호출: Intl.NumberFormat 사용, null/NaN → '-'
|
||||||
|
* - 옵션과 함께 호출: formatters.ts 호환, NaN → ''
|
||||||
|
*
|
||||||
* @example formatNumber(1234567) // "1,234,567"
|
* @example formatNumber(1234567) // "1,234,567"
|
||||||
* @example formatNumber(null) // "-"
|
* @example formatNumber(null) // "-"
|
||||||
|
* @example formatNumber(1234, { suffix: '원' }) // "1,234원"
|
||||||
|
* @example formatNumber('abc', { useComma: true }) // ""
|
||||||
*/
|
*/
|
||||||
export function formatNumber(value: number | null | undefined): string {
|
export function formatNumber(
|
||||||
if (value == null || isNaN(value)) return '-';
|
value: number | string | null | undefined,
|
||||||
return new Intl.NumberFormat('ko-KR').format(value);
|
options?: FormatNumberOptions,
|
||||||
|
): string {
|
||||||
|
if (options) {
|
||||||
|
const { useComma = true, decimalPlaces, suffix = '' } = options;
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : (value ?? NaN);
|
||||||
|
if (isNaN(num)) return '';
|
||||||
|
|
||||||
|
let formatted: string;
|
||||||
|
if (decimalPlaces !== undefined) {
|
||||||
|
formatted = num.toFixed(decimalPlaces);
|
||||||
|
} else {
|
||||||
|
formatted = String(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useComma) {
|
||||||
|
const parts = formatted.split('.');
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
formatted = parts.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return suffix ? `${formatted}${suffix}` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 없는 호출: 기존 amount.ts 동작 유지
|
||||||
|
if (value == null) return '-';
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||||
|
if (isNaN(num)) return '-';
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(num);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAmount(amount: number): string {
|
export function formatAmount(amount: number): string {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { safeJsonParse } from '@/lib/utils';
|
import { createUserStorage } from './utils/userStorage';
|
||||||
|
|
||||||
export interface FavoriteItem {
|
export interface FavoriteItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,18 +12,6 @@ export interface FavoriteItem {
|
|||||||
|
|
||||||
export const MAX_FAVORITES = 10;
|
export const MAX_FAVORITES = 10;
|
||||||
|
|
||||||
function getUserId(): string {
|
|
||||||
if (typeof window === 'undefined') return 'default';
|
|
||||||
const userStr = localStorage.getItem('user');
|
|
||||||
if (!userStr) return 'default';
|
|
||||||
const user = safeJsonParse<Record<string, unknown> | null>(userStr, null);
|
|
||||||
return user?.id ? String(user.id) : 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStorageKey(): string {
|
|
||||||
return `sam-favorites-${getUserId()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FavoritesState {
|
interface FavoritesState {
|
||||||
favorites: FavoriteItem[];
|
favorites: FavoriteItem[];
|
||||||
toggleFavorite: (item: FavoriteItem) => 'added' | 'removed' | 'max_reached';
|
toggleFavorite: (item: FavoriteItem) => 'added' | 'removed' | 'max_reached';
|
||||||
@@ -68,27 +56,7 @@ export const useFavoritesStore = create<FavoritesState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'sam-favorites',
|
name: 'sam-favorites',
|
||||||
// 사용자별 키를 위해 storage 커스텀
|
storage: createUserStorage('sam-favorites'),
|
||||||
storage: {
|
|
||||||
getItem: (name) => {
|
|
||||||
const key = getStorageKey();
|
|
||||||
const str = localStorage.getItem(key);
|
|
||||||
if (!str) {
|
|
||||||
// fallback: 기본 키에서도 확인
|
|
||||||
const fallback = localStorage.getItem(name);
|
|
||||||
return fallback ? JSON.parse(fallback) : null;
|
|
||||||
}
|
|
||||||
return JSON.parse(str);
|
|
||||||
},
|
|
||||||
setItem: (name, value) => {
|
|
||||||
const key = getStorageKey();
|
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
},
|
|
||||||
removeItem: (name) => {
|
|
||||||
const key = getStorageKey();
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
98
src/stores/permissionStore.ts
Normal file
98
src/stores/permissionStore.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions';
|
||||||
|
import {
|
||||||
|
buildMenuIdToUrlMap,
|
||||||
|
convertMatrixToPermissionMap,
|
||||||
|
findMatchingUrl,
|
||||||
|
mergePermissionMaps,
|
||||||
|
} from '@/lib/permissions/utils';
|
||||||
|
import { ALL_DENIED_PERMS } from '@/lib/permissions/types';
|
||||||
|
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
|
||||||
|
|
||||||
|
interface PermissionState {
|
||||||
|
permissionMap: PermissionMap | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
can: (url: string, action: PermissionAction) => boolean;
|
||||||
|
loadPermissions: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** localStorage 'user' 키에서 역할 ID 배열 + menuId→URL 매핑 추출 */
|
||||||
|
function getUserData(): { roleIds: number[]; menuIdToUrl: Record<string, string> } | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('user');
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
const roleIds = Array.isArray(parsed.roles)
|
||||||
|
? parsed.roles.map((r: { id: number }) => r.id).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const menuIdToUrl = Array.isArray(parsed.menu)
|
||||||
|
? buildMenuIdToUrlMap(parsed.menu)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return { roleIds, menuIdToUrl };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||||
|
permissionMap: null,
|
||||||
|
isLoading: true,
|
||||||
|
|
||||||
|
can: (url: string, action: PermissionAction): boolean => {
|
||||||
|
const { permissionMap } = get();
|
||||||
|
if (!permissionMap) return true;
|
||||||
|
const matchedUrl = findMatchingUrl(url, permissionMap);
|
||||||
|
if (!matchedUrl) return true;
|
||||||
|
const perms = permissionMap[matchedUrl];
|
||||||
|
return perms?.[action] ?? true;
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPermissions: async () => {
|
||||||
|
const userData = getUserData();
|
||||||
|
if (!userData || userData.roleIds.length === 0) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { roleIds, menuIdToUrl } = userData;
|
||||||
|
set({ isLoading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 사이드바 메뉴에 없는 권한 메뉴의 URL 매핑 보완
|
||||||
|
const [permMenuUrlMap, ...results] = await Promise.all([
|
||||||
|
getPermissionMenuUrlMap(),
|
||||||
|
...roleIds.map((id) => getRolePermissionMatrix(id)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 권한 메뉴 URL을 베이스로, 사이드바 메뉴 URL로 덮어쓰기 (사이드바 우선)
|
||||||
|
const mergedMenuIdToUrl = { ...permMenuUrlMap, ...menuIdToUrl };
|
||||||
|
|
||||||
|
const maps = results
|
||||||
|
.filter((r) => r.success && r.data?.permissions)
|
||||||
|
.map((r) => convertMatrixToPermissionMap(r.data.permissions, mergedMenuIdToUrl));
|
||||||
|
|
||||||
|
if (maps.length > 0) {
|
||||||
|
const merged = mergePermissionMaps(maps);
|
||||||
|
|
||||||
|
// 권한 메뉴에 등록되어 있지만 매트릭스 응답에 없는 메뉴 처리
|
||||||
|
for (const [, url] of Object.entries(permMenuUrlMap)) {
|
||||||
|
if (url && !merged[url]) {
|
||||||
|
merged[url] = { ...ALL_DENIED_PERMS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ permissionMap: merged, isLoading: false });
|
||||||
|
} else {
|
||||||
|
set({ permissionMap: null, isLoading: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Permission] 권한 로드 실패:', error);
|
||||||
|
set({ permissionMap: null, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { safeJsonParse } from '@/lib/utils';
|
import { createUserStorage } from './utils/userStorage';
|
||||||
|
|
||||||
interface PageColumnSettings {
|
interface PageColumnSettings {
|
||||||
columnWidths: Record<string, number>;
|
columnWidths: Record<string, number>;
|
||||||
@@ -15,18 +15,6 @@ interface TableColumnState {
|
|||||||
getPageSettings: (pageId: string) => PageColumnSettings;
|
getPageSettings: (pageId: string) => PageColumnSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserId(): string {
|
|
||||||
if (typeof window === 'undefined') return 'default';
|
|
||||||
const userStr = localStorage.getItem('user');
|
|
||||||
if (!userStr) return 'default';
|
|
||||||
const user = safeJsonParse<Record<string, unknown> | null>(userStr, null);
|
|
||||||
return user?.id ? String(user.id) : 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStorageKey(): string {
|
|
||||||
return `sam-table-columns-${getUserId()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SETTINGS: PageColumnSettings = {
|
const DEFAULT_PAGE_SETTINGS: PageColumnSettings = {
|
||||||
columnWidths: {},
|
columnWidths: {},
|
||||||
hiddenColumns: [],
|
hiddenColumns: [],
|
||||||
@@ -77,25 +65,7 @@ export const useTableColumnStore = create<TableColumnState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'sam-table-columns',
|
name: 'sam-table-columns',
|
||||||
storage: {
|
storage: createUserStorage('sam-table-columns'),
|
||||||
getItem: (name) => {
|
|
||||||
const key = getStorageKey();
|
|
||||||
const str = localStorage.getItem(key);
|
|
||||||
if (!str) {
|
|
||||||
const fallback = localStorage.getItem(name);
|
|
||||||
return fallback ? JSON.parse(fallback) : null;
|
|
||||||
}
|
|
||||||
return JSON.parse(str);
|
|
||||||
},
|
|
||||||
setItem: (name, value) => {
|
|
||||||
const key = getStorageKey();
|
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
},
|
|
||||||
removeItem: (name) => {
|
|
||||||
const key = getStorageKey();
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
30
src/stores/useUIStore.ts
Normal file
30
src/stores/useUIStore.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface ConfirmDialogState {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
/** 글로벌 로딩 오버레이 */
|
||||||
|
globalLoading: boolean;
|
||||||
|
setGlobalLoading: (loading: boolean) => void;
|
||||||
|
|
||||||
|
/** 전역 확인 다이얼로그 */
|
||||||
|
confirmDialog: ConfirmDialogState;
|
||||||
|
openConfirmDialog: (title: string, message: string, onConfirm: () => void) => void;
|
||||||
|
closeConfirmDialog: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
|
globalLoading: false,
|
||||||
|
setGlobalLoading: (loading) => set({ globalLoading: loading }),
|
||||||
|
|
||||||
|
confirmDialog: { open: false, title: '', message: '' },
|
||||||
|
openConfirmDialog: (title, message, onConfirm) =>
|
||||||
|
set({ confirmDialog: { open: true, title, message, onConfirm } }),
|
||||||
|
closeConfirmDialog: () =>
|
||||||
|
set({ confirmDialog: { open: false, title: '', message: '' } }),
|
||||||
|
}));
|
||||||
35
src/stores/utils/userStorage.ts
Normal file
35
src/stores/utils/userStorage.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { safeJsonParse } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function getUserId(): string {
|
||||||
|
if (typeof window === 'undefined') return 'default';
|
||||||
|
const userStr = localStorage.getItem('user');
|
||||||
|
if (!userStr) return 'default';
|
||||||
|
const user = safeJsonParse<Record<string, unknown> | null>(userStr, null);
|
||||||
|
return user?.id ? String(user.id) : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageKey(baseKey: string): string {
|
||||||
|
return `${baseKey}-${getUserId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserStorage(baseKey: string) {
|
||||||
|
return {
|
||||||
|
getItem: (name: string) => {
|
||||||
|
const key = getStorageKey(baseKey);
|
||||||
|
const str = localStorage.getItem(key);
|
||||||
|
if (!str) {
|
||||||
|
const fallback = localStorage.getItem(name);
|
||||||
|
return fallback ? JSON.parse(fallback) : null;
|
||||||
|
}
|
||||||
|
return JSON.parse(str);
|
||||||
|
},
|
||||||
|
setItem: (name: string, value: unknown) => {
|
||||||
|
const key = getStorageKey(baseKey);
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
},
|
||||||
|
removeItem: (name: string) => {
|
||||||
|
const key = getStorageKey(baseKey);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user