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 { Badge } from '@/components/ui/badge';
|
||||
import { getPresetStyle } from '@/lib/utils/status-config';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -18,17 +16,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { FileText, Plus, X, Eye } from 'lucide-react';
|
||||
import { FileText, Eye } from 'lucide-react';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
||||
import { purchaseConfig } from './purchaseConfig';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
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 { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<PurchaseItem>({
|
||||
items,
|
||||
setItems,
|
||||
createEmptyItem,
|
||||
supplyKey: 'supplyPrice',
|
||||
vatKey: 'vat',
|
||||
minItems: 1,
|
||||
});
|
||||
|
||||
// ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) =====
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
@@ -138,17 +139,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
loadInitialData();
|
||||
}, [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 client = clients.find(c => c.id === clientId);
|
||||
@@ -158,37 +148,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
}
|
||||
}, [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 호환) =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!vendorId) {
|
||||
@@ -435,101 +394,20 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
<CardTitle className="text-lg">품목 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<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={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>
|
||||
)}
|
||||
<LineItemsTable<PurchaseItem>
|
||||
items={items}
|
||||
getItemName={(item) => item.itemName}
|
||||
getQuantity={(item) => item.quantity}
|
||||
getUnitPrice={(item) => item.unitPrice}
|
||||
getSupplyAmount={(item) => item.supplyPrice}
|
||||
getVat={(item) => item.vat}
|
||||
getNote={(item) => item.note ?? ''}
|
||||
onItemChange={handleItemChange}
|
||||
onAddItem={handleAddItem}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
totals={totals}
|
||||
isViewMode={isViewMode}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -691,4 +569,4 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Send,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
@@ -14,8 +12,6 @@ import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,14 +19,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -42,13 +30,13 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
// 삭제 다이얼로그는 IntegratedDetailTemplate이 처리함
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
||||
import { salesConfig } from './salesConfig';
|
||||
import type { SalesRecord, SalesItem, SalesType } from './types';
|
||||
import { SALES_TYPE_OPTIONS } from './types';
|
||||
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { getClients } from '../VendorManagement/actions';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
|
||||
// ===== Props =====
|
||||
interface SalesDetailProps {
|
||||
@@ -100,6 +88,16 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
const [showEmailAlert, setShowEmailAlert] = useState(false);
|
||||
const [emailAlertMessage, setEmailAlertMessage] = useState('');
|
||||
|
||||
// ===== 품목 관리 (공통 훅) =====
|
||||
const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<SalesItem>({
|
||||
items,
|
||||
setItems,
|
||||
createEmptyItem,
|
||||
supplyKey: 'supplyAmount',
|
||||
vatKey: 'vat',
|
||||
minItems: 1,
|
||||
});
|
||||
|
||||
// ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) =====
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
@@ -148,51 +146,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
return clients.find(v => v.id === 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 호환) =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!vendorId) {
|
||||
@@ -341,101 +294,20 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
<CardTitle className="text-lg">품목 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<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={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>
|
||||
)}
|
||||
<LineItemsTable<SalesItem>
|
||||
items={items}
|
||||
getItemName={(item) => item.itemName}
|
||||
getQuantity={(item) => item.quantity}
|
||||
getUnitPrice={(item) => item.unitPrice}
|
||||
getSupplyAmount={(item) => item.supplyAmount}
|
||||
getVat={(item) => item.vat}
|
||||
getNote={(item) => item.note}
|
||||
onItemChange={handleItemChange}
|
||||
onAddItem={handleAddItem}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
totals={totals}
|
||||
isViewMode={isViewMode}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -572,4 +444,4 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
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,
|
||||
PurchaseStatusSection,
|
||||
DailyProductionSection,
|
||||
ShipmentSection,
|
||||
UnshippedSection,
|
||||
ConstructionSection,
|
||||
DailyAttendanceSection,
|
||||
} from './sections';
|
||||
import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig } from './types';
|
||||
import { DEFAULT_DASHBOARD_SETTINGS } from './types';
|
||||
import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig, SectionKey } from './types';
|
||||
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } from './types';
|
||||
import { ScheduleDetailModal, DetailModal } from './modals';
|
||||
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
|
||||
import { mockData } from './mockData';
|
||||
@@ -138,7 +139,34 @@ export function CEODashboard() {
|
||||
const saved = localStorage.getItem('ceo-dashboard-settings');
|
||||
if (saved) {
|
||||
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 {
|
||||
// 파싱 실패 시 기본값 유지
|
||||
}
|
||||
@@ -276,6 +304,187 @@ export function CEODashboard() {
|
||||
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) {
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -312,158 +521,7 @@ export function CEODashboard() {
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 오늘의 이슈 (새 리스트 형태) */}
|
||||
{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>
|
||||
)}
|
||||
{sectionOrder.map(renderDashboardSection)}
|
||||
</div>
|
||||
|
||||
{/* 일정 상세 모달 */}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
|
||||
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 { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import {
|
||||
@@ -34,8 +32,9 @@ import type {
|
||||
CompanyType,
|
||||
WelfareLimitType,
|
||||
WelfareCalculationType,
|
||||
SectionKey,
|
||||
} 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> = {
|
||||
@@ -68,10 +67,17 @@ export function DashboardSettingsDialog({
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
entertainment: false,
|
||||
welfare: false,
|
||||
receivable: false,
|
||||
statusBoard: 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가 변경될 때 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings);
|
||||
@@ -101,7 +107,6 @@ export function DashboardSettingsDialog({
|
||||
...prev,
|
||||
statusBoard: {
|
||||
enabled,
|
||||
// 전체 OFF 시 개별 항목도 모두 OFF
|
||||
items: enabled
|
||||
? statusBoardItems
|
||||
: Object.keys(statusBoardItems).reduce(
|
||||
@@ -109,7 +114,6 @@ export function DashboardSettingsDialog({
|
||||
{} as TodayIssueSettings
|
||||
),
|
||||
},
|
||||
// Legacy 호환성 유지
|
||||
todayIssue: {
|
||||
enabled,
|
||||
items: enabled
|
||||
@@ -139,7 +143,6 @@ export function DashboardSettingsDialog({
|
||||
enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled,
|
||||
items: newItems,
|
||||
},
|
||||
// Legacy 호환성 유지
|
||||
todayIssue: {
|
||||
...prev.todayIssue,
|
||||
items: newItems,
|
||||
@@ -175,29 +178,12 @@ export function DashboardSettingsDialog({
|
||||
[]
|
||||
);
|
||||
|
||||
// 매출 현황 설정 변경
|
||||
const handleSalesStatusChange = useCallback(
|
||||
(key: 'enabled' | 'dailySalesDetail', value: boolean) => {
|
||||
// 매출/매입/미수금 현황 토글 (단순 boolean)
|
||||
const handleSimpleSectionToggle = useCallback(
|
||||
(section: 'salesStatus' | 'purchaseStatus' | 'receivable', enabled: boolean) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
salesStatus: {
|
||||
...prev.salesStatus,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 매입 현황 설정 변경
|
||||
const handlePurchaseStatusChange = useCallback(
|
||||
(key: 'enabled' | 'dailyPurchaseDetail', value: boolean) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
purchaseStatus: {
|
||||
...prev.purchaseStatus,
|
||||
[key]: value,
|
||||
},
|
||||
[section]: enabled,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
@@ -237,19 +223,18 @@ export function DashboardSettingsDialog({
|
||||
[]
|
||||
);
|
||||
|
||||
// 미수금 설정 변경
|
||||
const handleReceivableChange = useCallback(
|
||||
(key: 'enabled' | 'topCompanies', value: boolean) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
receivable: {
|
||||
...prev.receivable,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
// 섹션 순서 변경 (DnD)
|
||||
const handleReorder = useCallback((sourceKey: SectionKey, targetKey: SectionKey) => {
|
||||
setLocalSettings((prev) => {
|
||||
const order = [...(prev.sectionOrder ?? DEFAULT_SECTION_ORDER)];
|
||||
const sourceIdx = order.indexOf(sourceKey);
|
||||
const targetIdx = order.indexOf(targetKey);
|
||||
if (sourceIdx === -1 || targetIdx === -1) return prev;
|
||||
order.splice(sourceIdx, 1);
|
||||
order.splice(targetIdx, 0, sourceKey);
|
||||
return { ...prev, sectionOrder: order };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -259,7 +244,7 @@ export function DashboardSettingsDialog({
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
setLocalSettings(settings); // 원래 설정으로 복원
|
||||
setLocalSettings(settings);
|
||||
onClose();
|
||||
}, [settings, onClose]);
|
||||
|
||||
@@ -297,6 +282,7 @@ export function DashboardSettingsDialog({
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
children,
|
||||
showGrip,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
@@ -305,6 +291,7 @@ export function DashboardSettingsDialog({
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
children?: React.ReactNode;
|
||||
showGrip?: boolean;
|
||||
}) => (
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
|
||||
<div
|
||||
@@ -314,6 +301,9 @@ export function DashboardSettingsDialog({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showGrip && (
|
||||
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab flex-shrink-0" />
|
||||
)}
|
||||
{hasExpand && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="p-1 hover:bg-gray-300 rounded">
|
||||
@@ -337,83 +327,93 @@ export function DashboardSettingsDialog({
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
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">
|
||||
{/* 오늘의 이슈 (리스트 형태) */}
|
||||
// 섹션 렌더링 함수
|
||||
const renderSection = (key: SectionKey): React.ReactNode => {
|
||||
switch (key) {
|
||||
case 'todayIssueList':
|
||||
return (
|
||||
<SectionRow
|
||||
label="오늘의 이슈"
|
||||
label={SECTION_LABELS.todayIssueList}
|
||||
checked={localSettings.todayIssueList}
|
||||
onCheckedChange={handleTodayIssueListToggle}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 자금현황 */}
|
||||
case 'dailyReport':
|
||||
return (
|
||||
<SectionRow
|
||||
label="자금현황"
|
||||
label={SECTION_LABELS.dailyReport}
|
||||
checked={localSettings.dailyReport}
|
||||
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
|
||||
<div className="space-y-0 rounded-lg overflow-hidden">
|
||||
<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>
|
||||
|
||||
{/* 당월 예상 지출 내역 */}
|
||||
case 'statusBoard':
|
||||
return (
|
||||
<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}
|
||||
onCheckedChange={(checked) => handleSectionToggle('monthlyExpense', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 카드/가지급금 관리 */}
|
||||
case 'cardManagement':
|
||||
return (
|
||||
<SectionRow
|
||||
label="카드/가지급금 관리"
|
||||
label={SECTION_LABELS.cardManagement}
|
||||
checked={localSettings.cardManagement}
|
||||
onCheckedChange={(checked) => handleSectionToggle('cardManagement', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 접대비 현황 */}
|
||||
case 'entertainment':
|
||||
return (
|
||||
<SectionRow
|
||||
label="접대비 현황"
|
||||
label={SECTION_LABELS.entertainment}
|
||||
checked={localSettings.entertainment.enabled}
|
||||
onCheckedChange={(checked) => handleEntertainmentChange('enabled', checked)}
|
||||
hasExpand
|
||||
isExpanded={expandedSections.entertainment}
|
||||
onToggleExpand={() => toggleSection('entertainment')}
|
||||
showGrip
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -621,15 +621,18 @@ export function DashboardSettingsDialog({
|
||||
</Collapsible>
|
||||
</div>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
{/* 복리후생비 현황 */}
|
||||
case 'welfare':
|
||||
return (
|
||||
<SectionRow
|
||||
label="복리후생비 현황"
|
||||
label={SECTION_LABELS.welfare}
|
||||
checked={localSettings.welfare.enabled}
|
||||
onCheckedChange={(checked) => handleWelfareChange('enabled', checked)}
|
||||
hasExpand
|
||||
isExpanded={expandedSections.welfare}
|
||||
onToggleExpand={() => toggleSection('welfare')}
|
||||
showGrip
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -713,124 +716,177 @@ export function DashboardSettingsDialog({
|
||||
</div>
|
||||
</div>
|
||||
</SectionRow>
|
||||
);
|
||||
|
||||
{/* 미수금 현황 */}
|
||||
case 'receivable':
|
||||
return (
|
||||
<SectionRow
|
||||
label="미수금 현황"
|
||||
checked={localSettings.receivable.enabled}
|
||||
onCheckedChange={(checked) => handleReceivableChange('enabled', checked)}
|
||||
hasExpand
|
||||
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>
|
||||
label={SECTION_LABELS.receivable}
|
||||
checked={localSettings.receivable ?? true}
|
||||
onCheckedChange={(checked) => handleSimpleSectionToggle('receivable', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 채권추심 현황 */}
|
||||
case 'debtCollection':
|
||||
return (
|
||||
<SectionRow
|
||||
label="채권추심 현황"
|
||||
label={SECTION_LABELS.debtCollection}
|
||||
checked={localSettings.debtCollection}
|
||||
onCheckedChange={(checked) => handleSectionToggle('debtCollection', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 부가세 현황 */}
|
||||
case 'vat':
|
||||
return (
|
||||
<SectionRow
|
||||
label="부가세 현황"
|
||||
label={SECTION_LABELS.vat}
|
||||
checked={localSettings.vat}
|
||||
onCheckedChange={(checked) => handleSectionToggle('vat', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 캘린더 */}
|
||||
case 'calendar':
|
||||
return (
|
||||
<SectionRow
|
||||
label="캘린더"
|
||||
label={SECTION_LABELS.calendar}
|
||||
checked={localSettings.calendar}
|
||||
onCheckedChange={(checked) => handleSectionToggle('calendar', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* ===== 신규 섹션 ===== */}
|
||||
|
||||
{/* 매출 현황 */}
|
||||
case 'salesStatus':
|
||||
return (
|
||||
<SectionRow
|
||||
label="매출 현황"
|
||||
checked={localSettings.salesStatus?.enabled ?? true}
|
||||
onCheckedChange={(checked) => handleSalesStatusChange('enabled', checked)}
|
||||
hasExpand
|
||||
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>
|
||||
label={SECTION_LABELS.salesStatus}
|
||||
checked={localSettings.salesStatus ?? true}
|
||||
onCheckedChange={(checked) => handleSimpleSectionToggle('salesStatus', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 매입 현황 */}
|
||||
case 'purchaseStatus':
|
||||
return (
|
||||
<SectionRow
|
||||
label="매입 현황"
|
||||
checked={localSettings.purchaseStatus?.enabled ?? true}
|
||||
onCheckedChange={(checked) => handlePurchaseStatusChange('enabled', checked)}
|
||||
hasExpand
|
||||
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>
|
||||
label={SECTION_LABELS.purchaseStatus}
|
||||
checked={localSettings.purchaseStatus ?? true}
|
||||
onCheckedChange={(checked) => handleSimpleSectionToggle('purchaseStatus', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 생산 현황 */}
|
||||
case 'production':
|
||||
return (
|
||||
<SectionRow
|
||||
label="생산 현황"
|
||||
label={SECTION_LABELS.production}
|
||||
checked={localSettings.production ?? true}
|
||||
onCheckedChange={(checked) => handleSectionToggle('production', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 출고 현황 */}
|
||||
case 'shipment':
|
||||
return (
|
||||
<SectionRow
|
||||
label="출고 현황"
|
||||
label={SECTION_LABELS.shipment}
|
||||
checked={localSettings.shipment ?? true}
|
||||
onCheckedChange={(checked) => handleSectionToggle('shipment', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 미출고 내역 */}
|
||||
case 'unshipped':
|
||||
return (
|
||||
<SectionRow
|
||||
label="미출고 내역"
|
||||
label={SECTION_LABELS.unshipped}
|
||||
checked={localSettings.unshipped ?? true}
|
||||
onCheckedChange={(checked) => handleSectionToggle('unshipped', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 시공 현황 */}
|
||||
case 'construction':
|
||||
return (
|
||||
<SectionRow
|
||||
label="시공 현황"
|
||||
label={SECTION_LABELS.construction}
|
||||
checked={localSettings.construction ?? true}
|
||||
onCheckedChange={(checked) => handleSectionToggle('construction', checked)}
|
||||
showGrip
|
||||
/>
|
||||
);
|
||||
|
||||
{/* 근태 현황 */}
|
||||
case 'attendance':
|
||||
return (
|
||||
<SectionRow
|
||||
label="근태 현황"
|
||||
label={SECTION_LABELS.attendance}
|
||||
checked={localSettings.attendance ?? true}
|
||||
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>
|
||||
|
||||
<DialogFooter className="flex gap-3 p-4 border-t border-gray-200 sm:justify-center">
|
||||
@@ -851,4 +907,4 @@ export function DashboardSettingsDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ConstructionSection({ data }: ConstructionSectionProps) {
|
||||
|
||||
<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="flex items-center gap-2 mb-2">
|
||||
<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">
|
||||
{/* 요약 카드 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="flex items-center justify-center gap-1 mb-1">
|
||||
<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 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 {
|
||||
data: DailyProductionData;
|
||||
showShipment?: boolean;
|
||||
@@ -34,6 +79,7 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
||||
const [activeTab, setActiveTab] = useState(data.processes[0]?.processName ?? '');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
{/* 다크 헤더 */}
|
||||
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||
@@ -216,33 +262,47 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* 출고 현황 */}
|
||||
{showShipment && (
|
||||
<div className="mt-6">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">출고 현황</h4>
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg 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-lg 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>
|
||||
|
||||
{/* 출고 현황 (별도 카드) */}
|
||||
{showShipment && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import type { PurchaseStatusData } from '../types';
|
||||
|
||||
interface PurchaseStatusSectionProps {
|
||||
data: PurchaseStatusData;
|
||||
showDailyDetail?: boolean;
|
||||
}
|
||||
|
||||
const formatAmount = (value: number) => {
|
||||
@@ -45,7 +44,7 @@ const formatAmount = (value: number) => {
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
export function PurchaseStatusSection({ data, showDailyDetail = true }: PurchaseStatusSectionProps) {
|
||||
export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
|
||||
const [supplierFilter, setSupplierFilter] = useState<string[]>([]);
|
||||
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))];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
{/* 다크 헤더 */}
|
||||
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||
@@ -84,8 +84,8 @@ export function PurchaseStatusSection({ data, showDailyDetail = true }: Purchase
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||||
{/* 통계카드 3개 */}
|
||||
<div className="grid grid-cols-1 xs:grid-cols-3 gap-4 mb-6">
|
||||
{/* 통계카드 3개 - 가로 배치 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
{/* 누적 매입 */}
|
||||
<div
|
||||
style={{ backgroundColor: '#fff7ed', borderColor: '#fed7aa' }}
|
||||
@@ -174,94 +174,108 @@ export function PurchaseStatusSection({ data, showDailyDetail = true }: Purchase
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
label={({ name, payload }) => `${name} ${(payload as { percentage?: number })?.percentage ?? 0}%`}
|
||||
>
|
||||
{data.materialRatio.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</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>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 당월 매입 내역 테이블 */}
|
||||
{showDailyDetail && (
|
||||
<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="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>
|
||||
|
||||
{/* 당월 매입 내역 (별도 카드) */}
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
|
||||
<ShoppingCart style={{ color: '#ffffff' }} className="h-5 w-5" />
|
||||
</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>
|
||||
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold">당월 매입 내역</h3>
|
||||
<p style={{ color: '#cbd5e1' }} className="text-sm">당월 매입 거래 상세</p>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,6 @@ import type { SalesStatusData } from '../types';
|
||||
|
||||
interface SalesStatusSectionProps {
|
||||
data: SalesStatusData;
|
||||
showDailyDetail?: boolean;
|
||||
}
|
||||
|
||||
const formatAmount = (value: number) => {
|
||||
@@ -43,7 +42,7 @@ const formatAmount = (value: number) => {
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatusSectionProps) {
|
||||
export function SalesStatusSection({ data }: SalesStatusSectionProps) {
|
||||
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
||||
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))];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
{/* 다크 헤더 */}
|
||||
<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">
|
||||
{/* 통계카드 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
|
||||
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
|
||||
@@ -194,82 +194,93 @@ export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatus
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 당월 매출 내역 테이블 */}
|
||||
{showDailyDetail && (
|
||||
<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="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>
|
||||
|
||||
{/* 당월 매출 내역 (별도 카드) */}
|
||||
<div className="rounded-xl border overflow-hidden">
|
||||
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
|
||||
<BarChart3 style={{ color: '#ffffff' }} className="h-5 w-5" />
|
||||
</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>
|
||||
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold">당월 매출 내역</h3>
|
||||
<p style={{ color: '#cbd5e1' }} className="text-sm">당월 매출 거래 상세</p>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export { CalendarSection } from './CalendarSection';
|
||||
// 신규 섹션
|
||||
export { SalesStatusSection } from './SalesStatusSection';
|
||||
export { PurchaseStatusSection } from './PurchaseStatusSection';
|
||||
export { DailyProductionSection } from './DailyProductionSection';
|
||||
export { DailyProductionSection, ShipmentSection } from './DailyProductionSection';
|
||||
export { UnshippedSection } from './UnshippedSection';
|
||||
export { ConstructionSection } from './ConstructionSection';
|
||||
export { DailyAttendanceSection } from './DailyAttendanceSection';
|
||||
|
||||
@@ -347,6 +347,71 @@ export interface CEODashboardData {
|
||||
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;
|
||||
entertainment: EntertainmentSettings;
|
||||
welfare: WelfareSettings;
|
||||
receivable: {
|
||||
enabled: boolean;
|
||||
topCompanies: boolean; // 미수금 상위 회사 현황
|
||||
};
|
||||
receivable: boolean;
|
||||
debtCollection: boolean;
|
||||
vat: boolean;
|
||||
calendar: boolean;
|
||||
// 신규 섹션 설정
|
||||
salesStatus: { enabled: boolean; dailySalesDetail: boolean };
|
||||
purchaseStatus: { enabled: boolean; dailyPurchaseDetail: boolean };
|
||||
salesStatus: boolean;
|
||||
purchaseStatus: boolean;
|
||||
production: boolean;
|
||||
shipment: boolean;
|
||||
unshipped: boolean;
|
||||
construction: boolean;
|
||||
attendance: boolean;
|
||||
// 섹션 순서
|
||||
sectionOrder?: SectionKey[];
|
||||
// Legacy: 기존 todayIssue 호환용 (deprecated, statusBoard로 대체)
|
||||
todayIssue: {
|
||||
enabled: boolean;
|
||||
@@ -649,21 +713,20 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
ratio: 20.5,
|
||||
annualTotal: 20000000,
|
||||
},
|
||||
receivable: {
|
||||
enabled: true,
|
||||
topCompanies: true,
|
||||
},
|
||||
receivable: true,
|
||||
debtCollection: true,
|
||||
vat: true,
|
||||
calendar: true,
|
||||
// 신규 섹션
|
||||
salesStatus: { enabled: true, dailySalesDetail: true },
|
||||
purchaseStatus: { enabled: true, dailyPurchaseDetail: true },
|
||||
salesStatus: true,
|
||||
purchaseStatus: true,
|
||||
production: true,
|
||||
shipment: true,
|
||||
unshipped: true,
|
||||
construction: true,
|
||||
attendance: true,
|
||||
// 섹션 순서
|
||||
sectionOrder: DEFAULT_SECTION_ORDER,
|
||||
// Legacy: 기존 todayIssue 호환용 (statusBoard와 동일)
|
||||
todayIssue: {
|
||||
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 { DocumentHeader } from './DocumentHeader';
|
||||
export { DocumentWrapper } from './DocumentWrapper';
|
||||
export { DocumentTable, DOC_STYLES } from './DocumentTable';
|
||||
export { SectionHeader } from './SectionHeader';
|
||||
export { InfoTable } from './InfoTable';
|
||||
export { QualityApprovalTable } from './QualityApprovalTable';
|
||||
@@ -11,6 +13,8 @@ export { SignatureSection } from './SignatureSection';
|
||||
// Types
|
||||
export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine';
|
||||
export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader';
|
||||
export type { DocumentWrapperProps } from './DocumentWrapper';
|
||||
export type { DocumentTableProps } from './DocumentTable';
|
||||
export type { SectionHeaderProps } from './SectionHeader';
|
||||
export type { InfoTableCell, InfoTableProps } from './InfoTable';
|
||||
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 {
|
||||
ApprovalLine,
|
||||
DocumentHeader,
|
||||
DocumentWrapper,
|
||||
DocumentTable,
|
||||
DOC_STYLES,
|
||||
SectionHeader,
|
||||
InfoTable,
|
||||
QualityApprovalTable,
|
||||
@@ -15,6 +18,7 @@ export {
|
||||
|
||||
// Hooks
|
||||
export { useZoom, useDrag } from './viewer/hooks';
|
||||
export { usePrintHandler } from './hooks/usePrintHandler';
|
||||
|
||||
// Presets
|
||||
export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets';
|
||||
@@ -26,6 +30,8 @@ export type {
|
||||
ApprovalLineProps,
|
||||
DocumentHeaderLogo,
|
||||
DocumentHeaderProps,
|
||||
DocumentWrapperProps,
|
||||
DocumentTableProps,
|
||||
SectionHeaderProps,
|
||||
InfoTableCell,
|
||||
InfoTableProps,
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
/**
|
||||
* 발주서 문서 컴포넌트
|
||||
* - 스크린샷 형식 + 지출결의서 디자인 스타일
|
||||
* - DocumentWrapper + DocumentTable building block 활용
|
||||
*/
|
||||
|
||||
import { getTodayString } from "@/lib/utils/date";
|
||||
import { OrderItem } from "../actions";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { DocumentWrapper, DocumentTable, DOC_STYLES } from '@/components/document-system';
|
||||
|
||||
/**
|
||||
* 수량 포맷 함수
|
||||
@@ -61,7 +62,7 @@ export function PurchaseOrderDocument({
|
||||
remarks,
|
||||
}: PurchaseOrderDocumentProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
<DocumentWrapper fontSize="text-sm">
|
||||
{/* 헤더: 제목 + 로트번호/결재란 */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-widest">발 주 서</h1>
|
||||
@@ -98,101 +99,95 @@ export function PurchaseOrderDocument({
|
||||
</div>
|
||||
|
||||
{/* 신청업체 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={2} className="bg-gray-100 border-r border-b border-gray-300 p-2 text-center font-medium w-20">
|
||||
신청업체
|
||||
</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">발주처</td>
|
||||
<td className="border-r border-b border-gray-300 p-2">{client}</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">발주일</td>
|
||||
<td className="border-b border-gray-300 p-2">{orderDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">담당자</td>
|
||||
<td className="border-r border-b border-gray-300 p-2">{manager}</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">연락처</td>
|
||||
<td className="border-b border-gray-300 p-2">{managerContact}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium"></td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2">FAX</td>
|
||||
<td className="border-r border-gray-300 p-2">-</td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2">설치개소<br/>(동)</td>
|
||||
<td className="border-gray-300 p-2">{installationCount}개소</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DocumentTable spacing="mb-4" className="text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={2} className={`${DOC_STYLES.label} text-center w-20`}>
|
||||
신청업체
|
||||
</td>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>발주처</td>
|
||||
<td className={DOC_STYLES.value}>{client}</td>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>발주일</td>
|
||||
<td className={DOC_STYLES.value}>{orderDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>담당자</td>
|
||||
<td className={DOC_STYLES.value}>{manager}</td>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>연락처</td>
|
||||
<td className={DOC_STYLES.value}>{managerContact}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={`${DOC_STYLES.label} text-center w-20`}></td>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>FAX</td>
|
||||
<td className={DOC_STYLES.value}>-</td>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>설치개소<br/>(동)</td>
|
||||
<td className={DOC_STYLES.value}>{installationCount}개소</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
|
||||
{/* 신청내용 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={3} className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium w-20">
|
||||
신청내용
|
||||
</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">현장명</td>
|
||||
<td colSpan={3} className="border-b border-gray-300 p-2">{siteName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">납기요청<br/>일</td>
|
||||
<td className="border-r border-b border-gray-300 p-2">{deliveryRequestDate}</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">배송방법</td>
|
||||
<td className="border-b border-gray-300 p-2">{deliveryMethod}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2">출고일</td>
|
||||
<td className="border-r border-gray-300 p-2">{expectedShipDate}</td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2">납품주소</td>
|
||||
<td className="border-gray-300 p-2">{address}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DocumentTable spacing="mb-4" className="text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={3} className={`${DOC_STYLES.label} text-center w-20`}>
|
||||
신청내용
|
||||
</td>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>현장명</td>
|
||||
<td colSpan={3} className={DOC_STYLES.value}>{siteName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>납기요청<br/>일</td>
|
||||
<td className={DOC_STYLES.value}>{deliveryRequestDate}</td>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>배송방법</td>
|
||||
<td className={DOC_STYLES.value}>{deliveryMethod}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>출고일</td>
|
||||
<td className={DOC_STYLES.value}>{expectedShipDate}</td>
|
||||
<td className={`${DOC_STYLES.label} w-20`}>납품주소</td>
|
||||
<td className={DOC_STYLES.value}>{address}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
|
||||
{/* 부자재 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium mb-2">■ 부자재</p>
|
||||
<div className="border border-gray-300">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">구분</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300">품명</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-20">규격</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-24">길이(mm)</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">수량</th>
|
||||
<th className="p-2 text-center font-medium w-24">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.itemName}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">
|
||||
{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">
|
||||
등록된 품목이 없습니다
|
||||
<DocumentTable spacing="">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${DOC_STYLES.th} w-16`}>구분</th>
|
||||
<th className={`${DOC_STYLES.th} text-left`}>품명</th>
|
||||
<th className={`${DOC_STYLES.th} w-20`}>규격</th>
|
||||
<th className={`${DOC_STYLES.th} w-24`}>길이(mm)</th>
|
||||
<th className={`${DOC_STYLES.th} w-16`}>수량</th>
|
||||
<th className={`${DOC_STYLES.th} w-24`}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className={DOC_STYLES.tdCenter}>{index + 1}</td>
|
||||
<td className={DOC_STYLES.td}>{item.itemName}</td>
|
||||
<td className={DOC_STYLES.tdCenter}>{item.spec}</td>
|
||||
<td className={DOC_STYLES.tdCenter}>
|
||||
{item.width ? `${item.width}` : "-"}
|
||||
</td>
|
||||
<td className={DOC_STYLES.tdCenter}>{formatQuantity(item.quantity, item.unit)}</td>
|
||||
<td className={DOC_STYLES.tdCenter}>{item.symbol || "-"}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-4 text-center text-gray-400 border border-gray-300">
|
||||
등록된 품목이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
</div>
|
||||
|
||||
{/* 특이사항 */}
|
||||
@@ -219,6 +214,6 @@ export function PurchaseOrderDocument({
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
문의: 홍길동 | 010-1234-5678
|
||||
</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';
|
||||
|
||||
interface InspectionRequestDocumentProps {
|
||||
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) {
|
||||
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>
|
||||
@@ -51,47 +63,44 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="border border-gray-400 mb-4">
|
||||
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">기본 정보</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">수주처</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1">{data.companyName || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">담당자</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1">{data.orderNumber || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">담당자 연락처</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1">{data.siteName || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">납품일</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1">{data.siteAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">총 개소</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1">{data.receptionDate || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-t border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300">검사방문요청일</td>
|
||||
<td className="px-2 py-1" colSpan={3}>{data.visitRequestDate || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DocumentTable header="기본 정보" headerVariant="light" spacing="mb-4">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className={lbl}>수주처</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.client || '-'}</td>
|
||||
<td className={lbl}>업체명</td>
|
||||
<td className={val}>{data.companyName || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className={lbl}>담당자</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.manager || '-'}</td>
|
||||
<td className={lbl}>수주번호</td>
|
||||
<td className={val}>{data.orderNumber || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className={lbl}>담당자 연락처</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.managerContact || '-'}</td>
|
||||
<td className={lbl}>현장명</td>
|
||||
<td className={val}>{data.siteName || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className={lbl}>납품일</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.deliveryDate || '-'}</td>
|
||||
<td className={lbl}>현장 주소</td>
|
||||
<td className={val}>{data.siteAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={lbl}>총 개소</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.totalLocations || '-'}</td>
|
||||
<td className={lbl}>접수일</td>
|
||||
<td className={val}>{data.receptionDate || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-t border-gray-300">
|
||||
<td className={lbl}>검사방문요청일</td>
|
||||
<td className={val} colSpan={3}>{data.visitRequestDate || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
|
||||
{/* 입력사항: 4개 섹션 */}
|
||||
<div className="border border-gray-400 mb-4">
|
||||
@@ -103,12 +112,12 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300">현장명</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1 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="px-2 py-1">{data.constructionSite.lotNumber || '-'}</td>
|
||||
<td className={subLbl}>현장명</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.constructionSite.siteName || '-'}</td>
|
||||
<td className={subLbl}>대지위치</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.constructionSite.landLocation || '-'}</td>
|
||||
<td className={`${subLbl} w-20`}>지번</td>
|
||||
<td className={val}>{data.constructionSite.lotNumber || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -120,16 +129,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<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="px-2 py-1 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="px-2 py-1">{data.materialDistributor.companyAddress || '-'}</td>
|
||||
<td className={subLbl}>회사명</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.materialDistributor.companyName || '-'}</td>
|
||||
<td className={subLbl}>회사주소</td>
|
||||
<td className={val}>{data.materialDistributor.companyAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">대표자명</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1">{data.materialDistributor.phone || '-'}</td>
|
||||
<td className={subLbl}>대표자명</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.materialDistributor.representativeName || '-'}</td>
|
||||
<td className={subLbl}>전화번호</td>
|
||||
<td className={val}>{data.materialDistributor.phone || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -141,16 +150,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<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="px-2 py-1 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="px-2 py-1">{data.constructorInfo.companyAddress || '-'}</td>
|
||||
<td className={subLbl}>회사명</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.constructorInfo.companyName || '-'}</td>
|
||||
<td className={subLbl}>회사주소</td>
|
||||
<td className={val}>{data.constructorInfo.companyAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">성명</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1">{data.constructorInfo.phone || '-'}</td>
|
||||
<td className={subLbl}>성명</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.constructorInfo.name || '-'}</td>
|
||||
<td className={subLbl}>전화번호</td>
|
||||
<td className={val}>{data.constructorInfo.phone || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -162,16 +171,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<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="px-2 py-1 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="px-2 py-1">{data.supervisor.officeAddress || '-'}</td>
|
||||
<td className={subLbl}>사무소명</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.supervisor.officeName || '-'}</td>
|
||||
<td className={subLbl}>사무소주소</td>
|
||||
<td className={val}>{data.supervisor.officeAddress || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300">성명</td>
|
||||
<td className="px-2 py-1 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="px-2 py-1">{data.supervisor.phone || '-'}</td>
|
||||
<td className={subLbl}>성명</td>
|
||||
<td className={`${val} border-r border-gray-300`}>{data.supervisor.name || '-'}</td>
|
||||
<td className={subLbl}>전화번호</td>
|
||||
<td className={val}>{data.supervisor.phone || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -179,71 +188,71 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
||||
</div>
|
||||
|
||||
{/* 검사 요청 시 필독 */}
|
||||
<div className="border border-gray-400 mb-4">
|
||||
<div className="bg-gray-800 text-white text-center py-1 font-bold border-b border-gray-400">검사 요청 시 필독</div>
|
||||
<div className="px-4 py-3 text-[11px] leading-relaxed text-center">
|
||||
<p>
|
||||
발주 사이즈와 시공 완료된 사이즈가 다를 시, 일정 범위를 벗어날 경우
|
||||
<br />
|
||||
인정마크를 부착할 수 없습니다. 제품검사를 위한 방문 전 미리
|
||||
<br />
|
||||
<span className="text-red-600 font-medium">변경사항을 고지해주셔야 인정마크를 부착할 수 있습니다.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-gray-600">
|
||||
(사전고지를 하지 않음으로 발생하는 문제의 귀책은 신청업체에 있습니다.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentTable header="검사 요청 시 필독" headerVariant="dark" spacing="mb-4">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-[11px] leading-relaxed text-center">
|
||||
<p>
|
||||
발주 사이즈와 시공 완료된 사이즈가 다를 시, 일정 범위를 벗어날 경우
|
||||
<br />
|
||||
인정마크를 부착할 수 없습니다. 제품검사를 위한 방문 전 미리
|
||||
<br />
|
||||
<span className="text-red-600 font-medium">변경사항을 고지해주셔야 인정마크를 부착할 수 있습니다.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-gray-600">
|
||||
(사전고지를 하지 않음으로 발생하는 문제의 귀책은 신청업체에 있습니다.)
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
|
||||
{/* 검사대상 사전 고지 정보 */}
|
||||
<div className="border border-gray-400 mb-4">
|
||||
<div className="bg-gray-800 text-white text-center py-1 font-bold border-b border-gray-400">검사대상 사전 고지 정보</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
{/* 1단: 오픈사이즈 병합 */}
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-12 text-center" rowSpan={3}>No.</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center" rowSpan={3}>층수</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-20 text-center" rowSpan={3}>부호</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={4}>오픈사이즈</th>
|
||||
<th className="px-2 py-1 text-center" rowSpan={3}>변경사유</th>
|
||||
<DocumentTable header="검사대상 사전 고지 정보" headerVariant="dark" spacing="mb-4">
|
||||
<thead>
|
||||
{/* 1단: 오픈사이즈 병합 */}
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className={`${DOC_STYLES.th} w-12`} rowSpan={3}>No.</th>
|
||||
<th className={`${DOC_STYLES.th} w-16`} rowSpan={3}>층수</th>
|
||||
<th className={`${DOC_STYLES.th} w-20`} rowSpan={3}>부호</th>
|
||||
<th className={DOC_STYLES.th} colSpan={4}>오픈사이즈</th>
|
||||
<th className={`${DOC_STYLES.th} border-r-0`} rowSpan={3}>변경사유</th>
|
||||
</tr>
|
||||
{/* 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>
|
||||
{/* 2단: 발주 규격, 시공후 규격 */}
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={2}>발주 규격</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={2}>시공후 규격</th>
|
||||
))}
|
||||
{data.priorNoticeItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-2 py-4 text-center text-gray-400">
|
||||
검사대상 사전 고지 정보가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
{/* 3단: 가로, 세로 */}
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
|
||||
{/* 서명 영역 */}
|
||||
<div className="mt-8 text-center text-[10px]">
|
||||
@@ -252,6 +261,6 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
|
||||
<p>{data.createdDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,98 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
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 { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { findMatchingUrl } from '@/lib/permissions/utils';
|
||||
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
|
||||
import { AccessDenied } from '@/components/common/AccessDenied';
|
||||
import { stripLocalePrefix } from '@/lib/utils/locale';
|
||||
|
||||
interface PermissionContextType {
|
||||
permissionMap: PermissionMap | null;
|
||||
isLoading: boolean;
|
||||
/** URL 지정 권한 체크 (특수 케이스용) */
|
||||
can: (url: string, action: PermissionAction) => boolean;
|
||||
/** 권한 데이터 다시 로드 (설정 변경 후 호출) */
|
||||
reloadPermissions: () => void;
|
||||
}
|
||||
|
||||
const PermissionContext = createContext<PermissionContextType>({
|
||||
permissionMap: null,
|
||||
isLoading: true,
|
||||
can: () => true,
|
||||
reloadPermissions: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* PermissionProvider — Zustand store 초기화 래퍼
|
||||
*
|
||||
* 기존 Context.Provider 역할을 대체합니다.
|
||||
* 마운트 시 한 번 loadPermissions() 호출만 담당합니다.
|
||||
*/
|
||||
export function PermissionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [permissionMap, setPermissionMap] = useState<PermissionMap | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const loadPermissions = usePermissionStore((s) => s.loadPermissions);
|
||||
|
||||
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(() => {
|
||||
loadPermissions();
|
||||
}, [loadPermissions]);
|
||||
|
||||
const can = useCallback((url: string, action: PermissionAction): boolean => {
|
||||
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>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +31,7 @@ const BYPASS_PATHS = ['/settings/permissions'];
|
||||
|
||||
function isGateBypassed(pathname: string): boolean {
|
||||
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 }) {
|
||||
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 (!permissionMap) {
|
||||
@@ -135,27 +65,21 @@ export function PermissionGate({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/**
|
||||
* 하위호환 훅 — 기존 usePermissionContext() 소비자를 위한 어댑터
|
||||
*
|
||||
* Zustand store에서 읽되, 기존과 동일한 인터페이스를 반환합니다.
|
||||
*/
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
return { permissionMap, isLoading, can, reloadPermissions: loadPermissions };
|
||||
}
|
||||
|
||||
export const usePermissionContext = () => useContext(PermissionContext);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePermissionContext } from '@/contexts/PermissionContext';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { findMatchingUrl } from '@/lib/permissions/utils';
|
||||
import { ALL_ALLOWED } 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 {
|
||||
const pathname = usePathname();
|
||||
const { permissionMap, isLoading } = usePermissionContext();
|
||||
const permissionMap = usePermissionStore((s) => s.permissionMap);
|
||||
const isLoading = usePermissionStore((s) => s.isLoading);
|
||||
|
||||
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?: {
|
||||
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;
|
||||
}
|
||||
export { formatNumber, type FormatNumberOptions } from '@/lib/utils/amount';
|
||||
|
||||
/**
|
||||
* 포맷된 숫자 문자열에서 숫자 추출
|
||||
|
||||
@@ -5,14 +5,56 @@
|
||||
* 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(null) // "-"
|
||||
* @example formatNumber(1234, { suffix: '원' }) // "1,234원"
|
||||
* @example formatNumber('abc', { useComma: true }) // ""
|
||||
*/
|
||||
export function formatNumber(value: number | null | undefined): string {
|
||||
if (value == null || isNaN(value)) return '-';
|
||||
return new Intl.NumberFormat('ko-KR').format(value);
|
||||
export function formatNumber(
|
||||
value: number | string | null | undefined,
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { safeJsonParse } from '@/lib/utils';
|
||||
import { createUserStorage } from './utils/userStorage';
|
||||
|
||||
export interface FavoriteItem {
|
||||
id: string;
|
||||
@@ -12,18 +12,6 @@ export interface FavoriteItem {
|
||||
|
||||
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 {
|
||||
favorites: FavoriteItem[];
|
||||
toggleFavorite: (item: FavoriteItem) => 'added' | 'removed' | 'max_reached';
|
||||
@@ -68,27 +56,7 @@ export const useFavoritesStore = create<FavoritesState>()(
|
||||
}),
|
||||
{
|
||||
name: 'sam-favorites',
|
||||
// 사용자별 키를 위해 storage 커스텀
|
||||
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);
|
||||
},
|
||||
},
|
||||
storage: createUserStorage('sam-favorites'),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
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 { persist } from 'zustand/middleware';
|
||||
import { safeJsonParse } from '@/lib/utils';
|
||||
import { createUserStorage } from './utils/userStorage';
|
||||
|
||||
interface PageColumnSettings {
|
||||
columnWidths: Record<string, number>;
|
||||
@@ -15,18 +15,6 @@ interface TableColumnState {
|
||||
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 = {
|
||||
columnWidths: {},
|
||||
hiddenColumns: [],
|
||||
@@ -77,25 +65,7 @@ export const useTableColumnStore = create<TableColumnState>()(
|
||||
}),
|
||||
{
|
||||
name: 'sam-table-columns',
|
||||
storage: {
|
||||
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);
|
||||
},
|
||||
},
|
||||
storage: createUserStorage('sam-table-columns'),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
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