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:
유병철
2026-02-23 20:59:25 +09:00
parent 718be1cfdb
commit 8f4a7ee842
43 changed files with 3489 additions and 3463 deletions

View File

@@ -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>
);
}

View File

@@ -9,8 +9,6 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config'; import { getPresetStyle } from '@/lib/utils/status-config';
import { QuantityInput } from '@/components/ui/quantity-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -18,17 +16,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FileText, Plus, X, Eye } from 'lucide-react'; import { FileText, Eye } from 'lucide-react';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
import { purchaseConfig } from './purchaseConfig'; import { purchaseConfig } from './purchaseConfig';
import { DocumentDetailModal } from '@/components/approval/DocumentDetail'; import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type { ProposalDocumentData, ExpenseReportDocumentData } from '@/components/approval/DocumentDetail/types'; import type { ProposalDocumentData, ExpenseReportDocumentData } from '@/components/approval/DocumentDetail/types';
@@ -95,6 +86,16 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
// ===== 다이얼로그 상태 ===== // ===== 다이얼로그 상태 =====
const [documentModalOpen, setDocumentModalOpen] = useState(false); const [documentModalOpen, setDocumentModalOpen] = useState(false);
// ===== 품목 관리 (공통 훅) =====
const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<PurchaseItem>({
items,
setItems,
createEmptyItem,
supplyKey: 'supplyPrice',
vatKey: 'vat',
minItems: 1,
});
// ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) ===== // ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) =====
useEffect(() => { useEffect(() => {
async function loadInitialData() { async function loadInitialData() {
@@ -138,17 +139,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
loadInitialData(); loadInitialData();
}, [purchaseId, mode, isNewMode]); }, [purchaseId, mode, isNewMode]);
// ===== 합계 계산 =====
const totals = useMemo(() => {
const totalSupply = items.reduce((sum, item) => sum + item.supplyPrice, 0);
const totalVat = items.reduce((sum, item) => sum + item.vat, 0);
return {
supplyAmount: totalSupply,
vat: totalVat,
total: totalSupply + totalVat,
};
}, [items]);
// ===== 핸들러 ===== // ===== 핸들러 =====
const handleVendorChange = useCallback((clientId: string) => { const handleVendorChange = useCallback((clientId: string) => {
const client = clients.find(c => c.id === clientId); const client = clients.find(c => c.id === clientId);
@@ -158,37 +148,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
} }
}, [clients]); }, [clients]);
const handleItemChange = useCallback((index: number, field: keyof PurchaseItem, value: string | number) => {
setItems(prev => {
const newItems = [...prev];
const item = { ...newItems[index] };
if (field === 'quantity' || field === 'unitPrice') {
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
item[field] = numValue;
// 자동 계산: 공급가액 = 수량 * 단가
item.supplyPrice = item.quantity * item.unitPrice;
item.vat = Math.floor(item.supplyPrice * 0.1);
} else {
(item as any)[field] = value;
}
newItems[index] = item;
return newItems;
});
}, []);
const handleAddItem = useCallback(() => {
setItems(prev => [...prev, createEmptyItem()]);
}, []);
const handleRemoveItem = useCallback((index: number) => {
setItems(prev => {
if (prev.length <= 1) return prev;
return prev.filter((_, i) => i !== index);
});
}, []);
// ===== 저장 (IntegratedDetailTemplate 호환) ===== // ===== 저장 (IntegratedDetailTemplate 호환) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => { const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!vendorId) { if (!vendorId) {
@@ -435,101 +394,20 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
<CardTitle className="text-lg"> </CardTitle> <CardTitle className="text-lg"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <LineItemsTable<PurchaseItem>
<Table> items={items}
<TableHeader> getItemName={(item) => item.itemName}
<TableRow> getQuantity={(item) => item.quantity}
<TableHead className="w-[50px] text-center">#</TableHead> getUnitPrice={(item) => item.unitPrice}
<TableHead></TableHead> getSupplyAmount={(item) => item.supplyPrice}
<TableHead className="w-[100px] text-right"></TableHead> getVat={(item) => item.vat}
<TableHead className="w-[120px] text-right"></TableHead> getNote={(item) => item.note ?? ''}
<TableHead className="w-[120px] text-right"></TableHead> onItemChange={handleItemChange}
<TableHead className="w-[100px] text-right"></TableHead> onAddItem={handleAddItem}
<TableHead></TableHead> onRemoveItem={handleRemoveItem}
<TableHead className="w-[50px]"></TableHead> totals={totals}
</TableRow> isViewMode={isViewMode}
</TableHeader> />
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<Input
value={item.itemName}
onChange={(e) => handleItemChange(index, 'itemName', e.target.value)}
placeholder="품목명"
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<QuantityInput
value={item.quantity}
onChange={(value) => handleItemChange(index, 'quantity', value ?? 0)}
disabled={isViewMode}
min={1}
/>
</TableCell>
<TableCell>
<CurrencyInput
value={item.unitPrice}
onChange={(value) => handleItemChange(index, 'unitPrice', value ?? 0)}
disabled={isViewMode}
/>
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.supplyPrice)}
</TableCell>
<TableCell className="text-right">
{formatAmount(item.vat)}
</TableCell>
<TableCell>
<Input
value={item.note || ''}
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
placeholder="적요"
disabled={isViewMode}
/>
</TableCell>
<TableCell>
{!isViewMode && items.length > 1 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700"
onClick={() => handleRemoveItem(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
{/* 합계 행 */}
<TableRow className="bg-gray-50 font-medium">
<TableCell colSpan={4} className="text-right">
</TableCell>
<TableCell className="text-right">
{formatAmount(totals.supplyAmount)}
</TableCell>
<TableCell className="text-right">
{formatAmount(totals.vat)}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{/* 품목 추가 버튼 */}
{!isViewMode && (
<div className="mt-4">
<Button variant="outline" onClick={handleAddItem}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -691,4 +569,4 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
renderForm={() => renderFormContent()} renderForm={() => renderFormContent()}
/> />
); );
} }

View File

@@ -3,8 +3,6 @@
import { useState, useCallback, useMemo, useEffect } from 'react'; import { useState, useCallback, useMemo, useEffect } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { import {
Plus,
X,
Send, Send,
FileText, FileText,
} from 'lucide-react'; } from 'lucide-react';
@@ -14,8 +12,6 @@ import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { QuantityInput } from '@/components/ui/quantity-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -23,14 +19,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -42,13 +30,13 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
// 삭제 다이얼로그는 IntegratedDetailTemplate이 처리함 // 삭제 다이얼로그는 IntegratedDetailTemplate이 처리함
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
import { salesConfig } from './salesConfig'; import { salesConfig } from './salesConfig';
import type { SalesRecord, SalesItem, SalesType } from './types'; import type { SalesRecord, SalesItem, SalesType } from './types';
import { SALES_TYPE_OPTIONS } from './types'; import { SALES_TYPE_OPTIONS } from './types';
import { getSaleById, createSale, updateSale, deleteSale } from './actions'; import { getSaleById, createSale, updateSale, deleteSale } from './actions';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getClients } from '../VendorManagement/actions'; import { getClients } from '../VendorManagement/actions';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
// ===== Props ===== // ===== Props =====
interface SalesDetailProps { interface SalesDetailProps {
@@ -100,6 +88,16 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const [showEmailAlert, setShowEmailAlert] = useState(false); const [showEmailAlert, setShowEmailAlert] = useState(false);
const [emailAlertMessage, setEmailAlertMessage] = useState(''); const [emailAlertMessage, setEmailAlertMessage] = useState('');
// ===== 품목 관리 (공통 훅) =====
const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems<SalesItem>({
items,
setItems,
createEmptyItem,
supplyKey: 'supplyAmount',
vatKey: 'vat',
minItems: 1,
});
// ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) ===== // ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) =====
useEffect(() => { useEffect(() => {
async function loadInitialData() { async function loadInitialData() {
@@ -148,51 +146,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
return clients.find(v => v.id === vendorId); return clients.find(v => v.id === vendorId);
}, [clients, vendorId]); }, [clients, vendorId]);
// ===== 합계 계산 =====
const totals = useMemo(() => {
const totalSupply = items.reduce((sum, item) => sum + item.supplyAmount, 0);
const totalVat = items.reduce((sum, item) => sum + item.vat, 0);
return {
supplyAmount: totalSupply,
vat: totalVat,
total: totalSupply + totalVat,
};
}, [items]);
// ===== 품목 수정 핸들러 =====
const handleItemChange = useCallback((index: number, field: keyof SalesItem, value: string | number) => {
setItems(prev => {
const newItems = [...prev];
const item = { ...newItems[index] };
if (field === 'quantity' || field === 'unitPrice') {
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
item[field] = numValue;
// 자동 계산
item.supplyAmount = item.quantity * item.unitPrice;
item.vat = Math.floor(item.supplyAmount * 0.1);
} else {
(item as any)[field] = value;
}
newItems[index] = item;
return newItems;
});
}, []);
// ===== 품목 추가 =====
const handleAddItem = useCallback(() => {
setItems(prev => [...prev, createEmptyItem()]);
}, []);
// ===== 품목 삭제 =====
const handleRemoveItem = useCallback((index: number) => {
setItems(prev => {
if (prev.length <= 1) return prev; // 최소 1개 유지
return prev.filter((_, i) => i !== index);
});
}, []);
// ===== 저장 (IntegratedDetailTemplate 호환) ===== // ===== 저장 (IntegratedDetailTemplate 호환) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => { const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!vendorId) { if (!vendorId) {
@@ -341,101 +294,20 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
<CardTitle className="text-lg"> </CardTitle> <CardTitle className="text-lg"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <LineItemsTable<SalesItem>
<Table> items={items}
<TableHeader> getItemName={(item) => item.itemName}
<TableRow> getQuantity={(item) => item.quantity}
<TableHead className="w-[50px] text-center">#</TableHead> getUnitPrice={(item) => item.unitPrice}
<TableHead></TableHead> getSupplyAmount={(item) => item.supplyAmount}
<TableHead className="w-[100px] text-right"></TableHead> getVat={(item) => item.vat}
<TableHead className="w-[120px] text-right"></TableHead> getNote={(item) => item.note}
<TableHead className="w-[120px] text-right"></TableHead> onItemChange={handleItemChange}
<TableHead className="w-[100px] text-right"></TableHead> onAddItem={handleAddItem}
<TableHead></TableHead> onRemoveItem={handleRemoveItem}
<TableHead className="w-[50px]"></TableHead> totals={totals}
</TableRow> isViewMode={isViewMode}
</TableHeader> />
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<Input
value={item.itemName}
onChange={(e) => handleItemChange(index, 'itemName', e.target.value)}
placeholder="품목명"
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<QuantityInput
value={item.quantity}
onChange={(value) => handleItemChange(index, 'quantity', value ?? 0)}
disabled={isViewMode}
min={1}
/>
</TableCell>
<TableCell>
<CurrencyInput
value={item.unitPrice}
onChange={(value) => handleItemChange(index, 'unitPrice', value ?? 0)}
disabled={isViewMode}
/>
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.supplyAmount)}
</TableCell>
<TableCell className="text-right">
{formatAmount(item.vat)}
</TableCell>
<TableCell>
<Input
value={item.note}
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
placeholder="적요"
disabled={isViewMode}
/>
</TableCell>
<TableCell>
{!isViewMode && items.length > 1 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700"
onClick={() => handleRemoveItem(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
{/* 합계 행 */}
<TableRow className="bg-gray-50 font-medium">
<TableCell colSpan={4} className="text-right">
</TableCell>
<TableCell className="text-right">
{formatAmount(totals.supplyAmount)}
</TableCell>
<TableCell className="text-right">
{formatAmount(totals.vat)}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{/* 품목 추가 버튼 */}
{!isViewMode && (
<div className="mt-4">
<Button variant="outline" onClick={handleAddItem}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -572,4 +444,4 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
renderForm={() => renderFormContent()} renderForm={() => renderFormContent()}
/> />
); );
} }

View File

@@ -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>
);
}

View File

@@ -23,12 +23,13 @@ import {
SalesStatusSection, SalesStatusSection,
PurchaseStatusSection, PurchaseStatusSection,
DailyProductionSection, DailyProductionSection,
ShipmentSection,
UnshippedSection, UnshippedSection,
ConstructionSection, ConstructionSection,
DailyAttendanceSection, DailyAttendanceSection,
} from './sections'; } from './sections';
import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig } from './types'; import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig, SectionKey } from './types';
import { DEFAULT_DASHBOARD_SETTINGS } from './types'; import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } from './types';
import { ScheduleDetailModal, DetailModal } from './modals'; import { ScheduleDetailModal, DetailModal } from './modals';
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog'; import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
import { mockData } from './mockData'; import { mockData } from './mockData';
@@ -138,7 +139,34 @@ export function CEODashboard() {
const saved = localStorage.getItem('ceo-dashboard-settings'); const saved = localStorage.getItem('ceo-dashboard-settings');
if (saved) { if (saved) {
try { try {
setDashboardSettings(JSON.parse(saved)); const parsed = JSON.parse(saved);
// 구버전 설정 마이그레이션: object → boolean 변환
if (typeof parsed.receivable === 'object' && parsed.receivable !== null) {
parsed.receivable = parsed.receivable.enabled ?? true;
}
if (typeof parsed.salesStatus === 'object' && parsed.salesStatus !== null) {
parsed.salesStatus = parsed.salesStatus.enabled ?? true;
}
if (typeof parsed.purchaseStatus === 'object' && parsed.purchaseStatus !== null) {
parsed.purchaseStatus = parsed.purchaseStatus.enabled ?? true;
}
// sectionOrder 마이그레이션
if (!parsed.sectionOrder) {
parsed.sectionOrder = DEFAULT_SECTION_ORDER;
} else {
const currentOrder: SectionKey[] = parsed.sectionOrder;
// 새 섹션이 추가됐으면 끝에 추가
for (const key of DEFAULT_SECTION_ORDER) {
if (!currentOrder.includes(key)) {
currentOrder.push(key);
}
}
// 삭제된 섹션 필터링
parsed.sectionOrder = currentOrder.filter((key: SectionKey) =>
DEFAULT_SECTION_ORDER.includes(key)
);
}
setDashboardSettings(parsed);
} catch { } catch {
// 파싱 실패 시 기본값 유지 // 파싱 실패 시 기본값 유지
} }
@@ -276,6 +304,187 @@ export function CEODashboard() {
setSelectedSchedule(null); setSelectedSchedule(null);
}, []); }, []);
// 섹션 순서
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
// 섹션 렌더링 함수
const renderDashboardSection = (key: SectionKey): React.ReactNode => {
switch (key) {
case 'todayIssueList':
if (!dashboardSettings.todayIssueList) return null;
return (
<LazySection key={key}>
<TodayIssueSection items={data.todayIssueList} />
</LazySection>
);
case 'dailyReport':
if (!dashboardSettings.dailyReport) return null;
return (
<LazySection key={key}>
<EnhancedDailyReportSection
data={data.dailyReport}
onClick={handleDailyReportClick}
/>
</LazySection>
);
case 'statusBoard':
if (!(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled)) return null;
return (
<LazySection key={key}>
<EnhancedStatusBoardSection
items={data.todayIssue}
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
/>
</LazySection>
);
case 'monthlyExpense':
if (!dashboardSettings.monthlyExpense) return null;
return (
<LazySection key={key}>
<EnhancedMonthlyExpenseSection
data={data.monthlyExpense}
onCardClick={handleMonthlyExpenseCardClick}
/>
</LazySection>
);
case 'cardManagement':
if (!dashboardSettings.cardManagement) return null;
return (
<LazySection key={key}>
<CardManagementSection
data={data.cardManagement}
onCardClick={handleCardManagementCardClick}
/>
</LazySection>
);
case 'entertainment':
if (!dashboardSettings.entertainment.enabled) return null;
return (
<LazySection key={key}>
<EntertainmentSection
data={data.entertainment}
onCardClick={handleEntertainmentCardClick}
/>
</LazySection>
);
case 'welfare':
if (!dashboardSettings.welfare.enabled) return null;
return (
<LazySection key={key}>
<WelfareSection
data={data.welfare}
onCardClick={handleWelfareCardClick}
/>
</LazySection>
);
case 'receivable':
if (!dashboardSettings.receivable) return null;
return (
<LazySection key={key}>
<ReceivableSection data={data.receivable} />
</LazySection>
);
case 'debtCollection':
if (!dashboardSettings.debtCollection) return null;
return (
<LazySection key={key}>
<DebtCollectionSection data={data.debtCollection} />
</LazySection>
);
case 'vat':
if (!dashboardSettings.vat) return null;
return (
<LazySection key={key}>
<VatSection data={data.vat} onClick={handleVatClick} />
</LazySection>
);
case 'calendar':
if (!dashboardSettings.calendar) return null;
return (
<LazySection key={key} minHeight={500}>
<CalendarSection
schedules={data.calendarSchedules}
issues={data.todayIssueList}
onScheduleClick={handleScheduleClick}
onScheduleEdit={handleScheduleEdit}
/>
</LazySection>
);
case 'salesStatus':
if (!(dashboardSettings.salesStatus ?? true) || !data.salesStatus) return null;
return (
<LazySection key={key} minHeight={600}>
<SalesStatusSection data={data.salesStatus} />
</LazySection>
);
case 'purchaseStatus':
if (!(dashboardSettings.purchaseStatus ?? true) || !data.purchaseStatus) return null;
return (
<LazySection key={key} minHeight={600}>
<PurchaseStatusSection data={data.purchaseStatus} />
</LazySection>
);
case 'production':
if (!(dashboardSettings.production ?? true) || !data.dailyProduction) return null;
return (
<LazySection key={key} minHeight={400}>
<DailyProductionSection
data={data.dailyProduction}
showShipment={false}
/>
</LazySection>
);
case 'shipment':
if (!(dashboardSettings.shipment ?? true) || !data.dailyProduction) return null;
return (
<LazySection key={key}>
<ShipmentSection data={data.dailyProduction} />
</LazySection>
);
case 'unshipped':
if (!(dashboardSettings.unshipped ?? true) || !data.unshipped) return null;
return (
<LazySection key={key}>
<UnshippedSection data={data.unshipped} />
</LazySection>
);
case 'construction':
if (!(dashboardSettings.construction ?? true) || !data.constructionData) return null;
return (
<LazySection key={key}>
<ConstructionSection data={data.constructionData} />
</LazySection>
);
case 'attendance':
if (!(dashboardSettings.attendance ?? true) || !data.dailyAttendance) return null;
return (
<LazySection key={key}>
<DailyAttendanceSection data={data.dailyAttendance} />
</LazySection>
);
default:
return null;
}
};
if (isLoading) { if (isLoading) {
return ( return (
<PageLayout> <PageLayout>
@@ -312,158 +521,7 @@ export function CEODashboard() {
/> />
<div className="space-y-6"> <div className="space-y-6">
{/* 오늘의 이슈 (새 리스트 형태) */} {sectionOrder.map(renderDashboardSection)}
{dashboardSettings.todayIssueList && (
<LazySection>
<TodayIssueSection items={data.todayIssueList} />
</LazySection>
)}
{/* 일일 일보 (Enhanced) */}
{dashboardSettings.dailyReport && (
<LazySection>
<EnhancedDailyReportSection
data={data.dailyReport}
onClick={handleDailyReportClick}
/>
</LazySection>
)}
{/* 현황판 (Enhanced - 아이콘 + 컬러 테마) */}
{(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && (
<LazySection>
<EnhancedStatusBoardSection
items={data.todayIssue}
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
/>
</LazySection>
)}
{/* 당월 예상 지출 내역 (Enhanced) */}
{dashboardSettings.monthlyExpense && (
<LazySection>
<EnhancedMonthlyExpenseSection
data={data.monthlyExpense}
onCardClick={handleMonthlyExpenseCardClick}
/>
</LazySection>
)}
{/* 카드/가지급금 관리 */}
{dashboardSettings.cardManagement && (
<LazySection>
<CardManagementSection
data={data.cardManagement}
onCardClick={handleCardManagementCardClick}
/>
</LazySection>
)}
{/* 접대비 현황 */}
{dashboardSettings.entertainment.enabled && (
<LazySection>
<EntertainmentSection
data={data.entertainment}
onCardClick={handleEntertainmentCardClick}
/>
</LazySection>
)}
{/* 복리후생비 현황 */}
{dashboardSettings.welfare.enabled && (
<LazySection>
<WelfareSection
data={data.welfare}
onCardClick={handleWelfareCardClick}
/>
</LazySection>
)}
{/* 미수금 현황 */}
{dashboardSettings.receivable.enabled && (
<LazySection>
<ReceivableSection data={data.receivable} />
</LazySection>
)}
{/* 채권추심 현황 */}
{dashboardSettings.debtCollection && (
<LazySection>
<DebtCollectionSection data={data.debtCollection} />
</LazySection>
)}
{/* 부가세 현황 */}
{dashboardSettings.vat && (
<LazySection>
<VatSection data={data.vat} onClick={handleVatClick} />
</LazySection>
)}
{/* 캘린더 */}
{dashboardSettings.calendar && (
<LazySection minHeight={500}>
<CalendarSection
schedules={data.calendarSchedules}
issues={data.todayIssueList}
onScheduleClick={handleScheduleClick}
onScheduleEdit={handleScheduleEdit}
/>
</LazySection>
)}
{/* ===== 신규 섹션 (캘린더 하단) ===== */}
{/* 매출 현황 */}
{(dashboardSettings.salesStatus?.enabled ?? true) && data.salesStatus && (
<LazySection minHeight={600}>
<SalesStatusSection
data={data.salesStatus}
showDailyDetail={dashboardSettings.salesStatus?.dailySalesDetail ?? true}
/>
</LazySection>
)}
{/* 매입 현황 */}
{(dashboardSettings.purchaseStatus?.enabled ?? true) && data.purchaseStatus && (
<LazySection minHeight={600}>
<PurchaseStatusSection
data={data.purchaseStatus}
showDailyDetail={dashboardSettings.purchaseStatus?.dailyPurchaseDetail ?? true}
/>
</LazySection>
)}
{/* 생산 현황 */}
{(dashboardSettings.production ?? true) && data.dailyProduction && (
<LazySection minHeight={400}>
<DailyProductionSection
data={data.dailyProduction}
showShipment={dashboardSettings.shipment ?? true}
/>
</LazySection>
)}
{/* 미출고 내역 */}
{(dashboardSettings.unshipped ?? true) && data.unshipped && (
<LazySection>
<UnshippedSection data={data.unshipped} />
</LazySection>
)}
{/* 시공 현황 */}
{(dashboardSettings.construction ?? true) && data.constructionData && (
<LazySection>
<ConstructionSection data={data.constructionData} />
</LazySection>
)}
{/* 근태 현황 */}
{(dashboardSettings.attendance ?? true) && data.dailyAttendance && (
<LazySection>
<DailyAttendanceSection data={data.dailyAttendance} />
</LazySection>
)}
</div> </div>
{/* 일정 상세 모달 */} {/* 일정 상세 모달 */}

View File

@@ -1,10 +1,8 @@
'use client'; 'use client';
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react'; import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { CurrencyInput } from '@/components/ui/currency-input'; import { CurrencyInput } from '@/components/ui/currency-input';
import { NumberInput } from '@/components/ui/number-input'; import { NumberInput } from '@/components/ui/number-input';
import { import {
@@ -34,8 +32,9 @@ import type {
CompanyType, CompanyType,
WelfareLimitType, WelfareLimitType,
WelfareCalculationType, WelfareCalculationType,
SectionKey,
} from '../types'; } from '../types';
import { DEFAULT_DASHBOARD_SETTINGS } from '../types'; import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types';
// 현황판 항목 라벨 (구 오늘의 이슈) // 현황판 항목 라벨 (구 오늘의 이슈)
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = { const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
@@ -68,10 +67,17 @@ export function DashboardSettingsDialog({
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({ const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
entertainment: false, entertainment: false,
welfare: false, welfare: false,
receivable: false, statusBoard: false,
companyTypeInfo: false, companyTypeInfo: false,
}); });
// DnD 상태
const [draggedSection, setDraggedSection] = useState<SectionKey | null>(null);
const [dragOverSection, setDragOverSection] = useState<SectionKey | null>(null);
// 섹션 순서
const sectionOrder = localSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
// settings가 변경될 때 로컬 상태 업데이트 // settings가 변경될 때 로컬 상태 업데이트
useEffect(() => { useEffect(() => {
setLocalSettings(settings); setLocalSettings(settings);
@@ -101,7 +107,6 @@ export function DashboardSettingsDialog({
...prev, ...prev,
statusBoard: { statusBoard: {
enabled, enabled,
// 전체 OFF 시 개별 항목도 모두 OFF
items: enabled items: enabled
? statusBoardItems ? statusBoardItems
: Object.keys(statusBoardItems).reduce( : Object.keys(statusBoardItems).reduce(
@@ -109,7 +114,6 @@ export function DashboardSettingsDialog({
{} as TodayIssueSettings {} as TodayIssueSettings
), ),
}, },
// Legacy 호환성 유지
todayIssue: { todayIssue: {
enabled, enabled,
items: enabled items: enabled
@@ -139,7 +143,6 @@ export function DashboardSettingsDialog({
enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled, enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled,
items: newItems, items: newItems,
}, },
// Legacy 호환성 유지
todayIssue: { todayIssue: {
...prev.todayIssue, ...prev.todayIssue,
items: newItems, items: newItems,
@@ -175,29 +178,12 @@ export function DashboardSettingsDialog({
[] []
); );
// 매출 현황 설정 변경 // 매출/매입/미수금 현황 토글 (단순 boolean)
const handleSalesStatusChange = useCallback( const handleSimpleSectionToggle = useCallback(
(key: 'enabled' | 'dailySalesDetail', value: boolean) => { (section: 'salesStatus' | 'purchaseStatus' | 'receivable', enabled: boolean) => {
setLocalSettings((prev) => ({ setLocalSettings((prev) => ({
...prev, ...prev,
salesStatus: { [section]: enabled,
...prev.salesStatus,
[key]: value,
},
}));
},
[]
);
// 매입 현황 설정 변경
const handlePurchaseStatusChange = useCallback(
(key: 'enabled' | 'dailyPurchaseDetail', value: boolean) => {
setLocalSettings((prev) => ({
...prev,
purchaseStatus: {
...prev.purchaseStatus,
[key]: value,
},
})); }));
}, },
[] []
@@ -237,19 +223,18 @@ export function DashboardSettingsDialog({
[] []
); );
// 미수금 설정 변경 // 섹션 순서 변경 (DnD)
const handleReceivableChange = useCallback( const handleReorder = useCallback((sourceKey: SectionKey, targetKey: SectionKey) => {
(key: 'enabled' | 'topCompanies', value: boolean) => { setLocalSettings((prev) => {
setLocalSettings((prev) => ({ const order = [...(prev.sectionOrder ?? DEFAULT_SECTION_ORDER)];
...prev, const sourceIdx = order.indexOf(sourceKey);
receivable: { const targetIdx = order.indexOf(targetKey);
...prev.receivable, if (sourceIdx === -1 || targetIdx === -1) return prev;
[key]: value, order.splice(sourceIdx, 1);
}, order.splice(targetIdx, 0, sourceKey);
})); return { ...prev, sectionOrder: order };
}, });
[] }, []);
);
// 저장 // 저장
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@@ -259,7 +244,7 @@ export function DashboardSettingsDialog({
// 취소 // 취소
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setLocalSettings(settings); // 원래 설정으로 복원 setLocalSettings(settings);
onClose(); onClose();
}, [settings, onClose]); }, [settings, onClose]);
@@ -297,6 +282,7 @@ export function DashboardSettingsDialog({
isExpanded, isExpanded,
onToggleExpand, onToggleExpand,
children, children,
showGrip,
}: { }: {
label: string; label: string;
checked: boolean; checked: boolean;
@@ -305,6 +291,7 @@ export function DashboardSettingsDialog({
isExpanded?: boolean; isExpanded?: boolean;
onToggleExpand?: () => void; onToggleExpand?: () => void;
children?: React.ReactNode; children?: React.ReactNode;
showGrip?: boolean;
}) => ( }) => (
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}> <Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
<div <div
@@ -314,6 +301,9 @@ export function DashboardSettingsDialog({
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showGrip && (
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab flex-shrink-0" />
)}
{hasExpand && ( {hasExpand && (
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button type="button" className="p-1 hover:bg-gray-300 rounded"> <button type="button" className="p-1 hover:bg-gray-300 rounded">
@@ -337,83 +327,93 @@ export function DashboardSettingsDialog({
</Collapsible> </Collapsible>
); );
return ( // 섹션 렌더링 함수
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}> const renderSection = (key: SectionKey): React.ReactNode => {
<DialogContent className="w-[95vw] max-w-[450px] sm:max-w-[450px] max-h-[85vh] overflow-y-auto bg-white border-gray-200 p-0"> switch (key) {
<DialogHeader className="p-4 border-b border-gray-200"> case 'todayIssueList':
<DialogTitle className="text-lg font-bold text-gray-900"> </DialogTitle> return (
</DialogHeader>
<div className="space-y-3 p-4">
{/* 오늘의 이슈 (리스트 형태) */}
<SectionRow <SectionRow
label="오늘의 이슈" label={SECTION_LABELS.todayIssueList}
checked={localSettings.todayIssueList} checked={localSettings.todayIssueList}
onCheckedChange={handleTodayIssueListToggle} onCheckedChange={handleTodayIssueListToggle}
showGrip
/> />
);
{/* 자금현황 */} case 'dailyReport':
return (
<SectionRow <SectionRow
label="자금현황" label={SECTION_LABELS.dailyReport}
checked={localSettings.dailyReport} checked={localSettings.dailyReport}
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)} onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
showGrip
/> />
);
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */} case 'statusBoard':
<div className="space-y-0 rounded-lg overflow-hidden"> return (
<div className="flex items-center justify-between py-3 px-4 bg-gray-200">
<span className="text-sm font-medium text-gray-800"></span>
<ToggleSwitch
checked={localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled}
onCheckedChange={handleStatusBoardToggle}
/>
</div>
{(localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled) && (
<div className="bg-gray-50">
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
(key) => (
<div
key={key}
className="flex items-center justify-between py-2.5 px-6 border-t border-gray-200"
>
<span className="text-sm text-gray-600">
{STATUS_BOARD_LABELS[key]}
</span>
<ToggleSwitch
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[key]}
onCheckedChange={(checked) =>
handleStatusBoardItemToggle(key, checked)
}
/>
</div>
)
)}
</div>
)}
</div>
{/* 당월 예상 지출 내역 */}
<SectionRow <SectionRow
label="당월 예상 지출 내역" label={SECTION_LABELS.statusBoard}
checked={localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled}
onCheckedChange={handleStatusBoardToggle}
hasExpand
isExpanded={expandedSections.statusBoard}
onToggleExpand={() => toggleSection('statusBoard')}
showGrip
>
<div className="space-y-0">
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
(itemKey) => (
<div
key={itemKey}
className="flex items-center justify-between py-2.5 px-2"
>
<span className="text-sm text-gray-600">
{STATUS_BOARD_LABELS[itemKey]}
</span>
<ToggleSwitch
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[itemKey]}
onCheckedChange={(checked) =>
handleStatusBoardItemToggle(itemKey, checked)
}
/>
</div>
)
)}
</div>
</SectionRow>
);
case 'monthlyExpense':
return (
<SectionRow
label={SECTION_LABELS.monthlyExpense}
checked={localSettings.monthlyExpense} checked={localSettings.monthlyExpense}
onCheckedChange={(checked) => handleSectionToggle('monthlyExpense', checked)} onCheckedChange={(checked) => handleSectionToggle('monthlyExpense', checked)}
showGrip
/> />
);
{/* 카드/가지급금 관리 */} case 'cardManagement':
return (
<SectionRow <SectionRow
label="카드/가지급금 관리" label={SECTION_LABELS.cardManagement}
checked={localSettings.cardManagement} checked={localSettings.cardManagement}
onCheckedChange={(checked) => handleSectionToggle('cardManagement', checked)} onCheckedChange={(checked) => handleSectionToggle('cardManagement', checked)}
showGrip
/> />
);
{/* 접대비 현황 */} case 'entertainment':
return (
<SectionRow <SectionRow
label="접대비 현황" label={SECTION_LABELS.entertainment}
checked={localSettings.entertainment.enabled} checked={localSettings.entertainment.enabled}
onCheckedChange={(checked) => handleEntertainmentChange('enabled', checked)} onCheckedChange={(checked) => handleEntertainmentChange('enabled', checked)}
hasExpand hasExpand
isExpanded={expandedSections.entertainment} isExpanded={expandedSections.entertainment}
onToggleExpand={() => toggleSection('entertainment')} onToggleExpand={() => toggleSection('entertainment')}
showGrip
> >
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -621,15 +621,18 @@ export function DashboardSettingsDialog({
</Collapsible> </Collapsible>
</div> </div>
</SectionRow> </SectionRow>
);
{/* 복리후생비 현황 */} case 'welfare':
return (
<SectionRow <SectionRow
label="복리후생비 현황" label={SECTION_LABELS.welfare}
checked={localSettings.welfare.enabled} checked={localSettings.welfare.enabled}
onCheckedChange={(checked) => handleWelfareChange('enabled', checked)} onCheckedChange={(checked) => handleWelfareChange('enabled', checked)}
hasExpand hasExpand
isExpanded={expandedSections.welfare} isExpanded={expandedSections.welfare}
onToggleExpand={() => toggleSection('welfare')} onToggleExpand={() => toggleSection('welfare')}
showGrip
> >
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -713,124 +716,177 @@ export function DashboardSettingsDialog({
</div> </div>
</div> </div>
</SectionRow> </SectionRow>
);
{/* 미수금 현황 */} case 'receivable':
return (
<SectionRow <SectionRow
label="미수금 현황" label={SECTION_LABELS.receivable}
checked={localSettings.receivable.enabled} checked={localSettings.receivable ?? true}
onCheckedChange={(checked) => handleReceivableChange('enabled', checked)} onCheckedChange={(checked) => handleSimpleSectionToggle('receivable', checked)}
hasExpand showGrip
isExpanded={expandedSections.receivable} />
onToggleExpand={() => toggleSection('receivable')} );
>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<ToggleSwitch
checked={localSettings.receivable.topCompanies}
onCheckedChange={(checked) =>
handleReceivableChange('topCompanies', checked)
}
/>
</div>
</SectionRow>
{/* 채권추심 현황 */} case 'debtCollection':
return (
<SectionRow <SectionRow
label="채권추심 현황" label={SECTION_LABELS.debtCollection}
checked={localSettings.debtCollection} checked={localSettings.debtCollection}
onCheckedChange={(checked) => handleSectionToggle('debtCollection', checked)} onCheckedChange={(checked) => handleSectionToggle('debtCollection', checked)}
showGrip
/> />
);
{/* 부가세 현황 */} case 'vat':
return (
<SectionRow <SectionRow
label="부가세 현황" label={SECTION_LABELS.vat}
checked={localSettings.vat} checked={localSettings.vat}
onCheckedChange={(checked) => handleSectionToggle('vat', checked)} onCheckedChange={(checked) => handleSectionToggle('vat', checked)}
showGrip
/> />
);
{/* 캘린더 */} case 'calendar':
return (
<SectionRow <SectionRow
label="캘린더" label={SECTION_LABELS.calendar}
checked={localSettings.calendar} checked={localSettings.calendar}
onCheckedChange={(checked) => handleSectionToggle('calendar', checked)} onCheckedChange={(checked) => handleSectionToggle('calendar', checked)}
showGrip
/> />
);
{/* ===== 신규 섹션 ===== */} case 'salesStatus':
return (
{/* 매출 현황 */}
<SectionRow <SectionRow
label="매출 현황" label={SECTION_LABELS.salesStatus}
checked={localSettings.salesStatus?.enabled ?? true} checked={localSettings.salesStatus ?? true}
onCheckedChange={(checked) => handleSalesStatusChange('enabled', checked)} onCheckedChange={(checked) => handleSimpleSectionToggle('salesStatus', checked)}
hasExpand showGrip
isExpanded={expandedSections.salesStatus} />
onToggleExpand={() => toggleSection('salesStatus')} );
>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<ToggleSwitch
checked={localSettings.salesStatus?.dailySalesDetail ?? true}
onCheckedChange={(checked) =>
handleSalesStatusChange('dailySalesDetail', checked)
}
/>
</div>
</SectionRow>
{/* 매입 현황 */} case 'purchaseStatus':
return (
<SectionRow <SectionRow
label="매입 현황" label={SECTION_LABELS.purchaseStatus}
checked={localSettings.purchaseStatus?.enabled ?? true} checked={localSettings.purchaseStatus ?? true}
onCheckedChange={(checked) => handlePurchaseStatusChange('enabled', checked)} onCheckedChange={(checked) => handleSimpleSectionToggle('purchaseStatus', checked)}
hasExpand showGrip
isExpanded={expandedSections.purchaseStatus} />
onToggleExpand={() => toggleSection('purchaseStatus')} );
>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<ToggleSwitch
checked={localSettings.purchaseStatus?.dailyPurchaseDetail ?? true}
onCheckedChange={(checked) =>
handlePurchaseStatusChange('dailyPurchaseDetail', checked)
}
/>
</div>
</SectionRow>
{/* 생산 현황 */} case 'production':
return (
<SectionRow <SectionRow
label="생산 현황" label={SECTION_LABELS.production}
checked={localSettings.production ?? true} checked={localSettings.production ?? true}
onCheckedChange={(checked) => handleSectionToggle('production', checked)} onCheckedChange={(checked) => handleSectionToggle('production', checked)}
showGrip
/> />
);
{/* 출고 현황 */} case 'shipment':
return (
<SectionRow <SectionRow
label="출고 현황" label={SECTION_LABELS.shipment}
checked={localSettings.shipment ?? true} checked={localSettings.shipment ?? true}
onCheckedChange={(checked) => handleSectionToggle('shipment', checked)} onCheckedChange={(checked) => handleSectionToggle('shipment', checked)}
showGrip
/> />
);
{/* 미출고 내역 */} case 'unshipped':
return (
<SectionRow <SectionRow
label="미출고 내역" label={SECTION_LABELS.unshipped}
checked={localSettings.unshipped ?? true} checked={localSettings.unshipped ?? true}
onCheckedChange={(checked) => handleSectionToggle('unshipped', checked)} onCheckedChange={(checked) => handleSectionToggle('unshipped', checked)}
showGrip
/> />
);
{/* 시공 현황 */} case 'construction':
return (
<SectionRow <SectionRow
label="시공 현황" label={SECTION_LABELS.construction}
checked={localSettings.construction ?? true} checked={localSettings.construction ?? true}
onCheckedChange={(checked) => handleSectionToggle('construction', checked)} onCheckedChange={(checked) => handleSectionToggle('construction', checked)}
showGrip
/> />
);
{/* 근태 현황 */} case 'attendance':
return (
<SectionRow <SectionRow
label="근태 현황" label={SECTION_LABELS.attendance}
checked={localSettings.attendance ?? true} checked={localSettings.attendance ?? true}
onCheckedChange={(checked) => handleSectionToggle('attendance', checked)} onCheckedChange={(checked) => handleSectionToggle('attendance', checked)}
showGrip
/> />
);
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="w-[95vw] max-w-[450px] sm:max-w-[450px] max-h-[85vh] overflow-y-auto bg-white border-gray-200 p-0">
<DialogHeader className="p-4 border-b border-gray-200">
<DialogTitle className="text-lg font-bold text-gray-900"> </DialogTitle>
</DialogHeader>
<div className="space-y-3 p-4">
{sectionOrder.map((key) => (
<div
key={key}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(
'application/json',
JSON.stringify({ type: 'dashboard-section', key })
);
setDraggedSection(key);
}}
onDragEnd={() => {
setDraggedSection(null);
setDragOverSection(null);
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (key !== draggedSection) {
setDragOverSection(key);
}
}}
onDragLeave={() => setDragOverSection(null)}
onDrop={(e) => {
e.preventDefault();
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
if (data.type !== 'dashboard-section') return;
const sourceKey = data.key as SectionKey;
if (sourceKey !== key) handleReorder(sourceKey, key);
} catch {
// ignore
}
setDraggedSection(null);
setDragOverSection(null);
}}
className={cn(
'transition-all',
draggedSection === key && 'opacity-50',
dragOverSection === key && draggedSection !== key && 'border-t-2 border-blue-500'
)}
>
{renderSection(key)}
</div>
))}
</div> </div>
<DialogFooter className="flex gap-3 p-4 border-t border-gray-200 sm:justify-center"> <DialogFooter className="flex gap-3 p-4 border-t border-gray-200 sm:justify-center">
@@ -851,4 +907,4 @@ export function DashboardSettingsDialog({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -38,7 +38,7 @@ export function ConstructionSection({ data }: ConstructionSectionProps) {
<div style={{ backgroundColor: '#ffffff' }} className="p-6"> <div style={{ backgroundColor: '#ffffff' }} className="p-6">
{/* 시공 요약 카드 */} {/* 시공 요약 카드 */}
<div className="grid grid-cols-1 xs:grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div className="rounded-lg border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}> <div className="rounded-lg border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<HardHat className="h-4 w-4 text-blue-500" /> <HardHat className="h-4 w-4 text-blue-500" />

View File

@@ -71,7 +71,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
<div style={{ backgroundColor: '#ffffff' }} className="p-6"> <div style={{ backgroundColor: '#ffffff' }} className="p-6">
{/* 요약 카드 4개 */} {/* 요약 카드 4개 */}
<div className="grid grid-cols-2 xs:grid-cols-4 gap-3 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="rounded-lg border p-3 text-center" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}> <div className="rounded-lg border p-3 text-center" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
<div className="flex items-center justify-center gap-1 mb-1"> <div className="flex items-center justify-center gap-1 mb-1">
<UserCheck className="h-3.5 w-3.5 text-green-500" /> <UserCheck className="h-3.5 w-3.5 text-green-500" />

View File

@@ -19,6 +19,51 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { formatKoreanAmount } from '@/lib/utils/amount'; import { formatKoreanAmount } from '@/lib/utils/amount';
import type { DailyProductionData } from '../types'; import type { DailyProductionData } from '../types';
// 출고 현황 독립 섹션
interface ShipmentSectionProps {
data: DailyProductionData;
}
export function ShipmentSection({ data }: ShipmentSectionProps) {
return (
<div className="rounded-xl border overflow-hidden">
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
<Truck style={{ color: '#ffffff' }} className="h-5 w-5" />
</div>
<div>
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
</div>
</div>
</div>
</div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="rounded-xl border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
<div className="flex items-center gap-2 mb-3">
<Package className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-gray-700"> (7 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.expectedCount}</p>
</div>
<div className="rounded-xl border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
<div className="flex items-center gap-2 mb-3">
<Truck className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-gray-700"> (30 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.actualCount}</p>
</div>
</div>
</div>
</div>
);
}
interface DailyProductionSectionProps { interface DailyProductionSectionProps {
data: DailyProductionData; data: DailyProductionData;
showShipment?: boolean; showShipment?: boolean;
@@ -34,6 +79,7 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
const [activeTab, setActiveTab] = useState(data.processes[0]?.processName ?? ''); const [activeTab, setActiveTab] = useState(data.processes[0]?.processName ?? '');
return ( return (
<div className="space-y-6">
<div className="rounded-xl border overflow-hidden"> <div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */} {/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4"> <div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
@@ -216,33 +262,47 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
))} ))}
</Tabs> </Tabs>
{/* 출고 현황 */} </div>
{showShipment && ( </div>
<div className="mt-6">
<div className="border rounded-lg p-4"> {/* 출고 현황 (별도 카드) */}
<h4 className="text-sm font-semibold text-gray-700 mb-3"> </h4> {showShipment && (
<div className="grid grid-cols-1 xs:grid-cols-2 gap-4"> <div className="rounded-xl border overflow-hidden">
<div className="rounded-lg border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}> <div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center justify-between">
<Package className="h-4 w-4 text-blue-500" /> <div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700"> (7 )</span> <div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
</div> <Truck style={{ color: '#ffffff' }} className="h-5 w-5" />
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p> </div>
<p className="text-xs text-gray-500">{data.shipment.expectedCount}</p> <div>
</div> <h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<div className="rounded-lg border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}> <p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
<div className="flex items-center gap-2 mb-3">
<Truck className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-gray-700"> (30 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.actualCount}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
)} </div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="rounded-xl border p-4" style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}>
<div className="flex items-center gap-2 mb-3">
<Package className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-gray-700"> (7 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.expectedAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.expectedCount}</p>
</div>
<div className="rounded-xl border p-4" style={{ backgroundColor: '#f0fdf4', borderColor: '#bbf7d0' }}>
<div className="flex items-center gap-2 mb-3">
<Truck className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-gray-700"> (30 )</span>
</div>
<p className="text-xl font-bold text-gray-900 mb-1">{formatKoreanAmount(data.shipment.actualAmount)}</p>
<p className="text-xs text-gray-500">{data.shipment.actualCount}</p>
</div>
</div>
</div>
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -36,7 +36,6 @@ import type { PurchaseStatusData } from '../types';
interface PurchaseStatusSectionProps { interface PurchaseStatusSectionProps {
data: PurchaseStatusData; data: PurchaseStatusData;
showDailyDetail?: boolean;
} }
const formatAmount = (value: number) => { const formatAmount = (value: number) => {
@@ -45,7 +44,7 @@ const formatAmount = (value: number) => {
return value.toLocaleString(); return value.toLocaleString();
}; };
export function PurchaseStatusSection({ data, showDailyDetail = true }: PurchaseStatusSectionProps) { export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
const [supplierFilter, setSupplierFilter] = useState<string[]>([]); const [supplierFilter, setSupplierFilter] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('date-desc'); const [sortOrder, setSortOrder] = useState('date-desc');
@@ -61,6 +60,7 @@ export function PurchaseStatusSection({ data, showDailyDetail = true }: Purchase
const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))]; const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))];
return ( return (
<div className="space-y-6">
<div className="rounded-xl border overflow-hidden"> <div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */} {/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4"> <div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
@@ -84,8 +84,8 @@ export function PurchaseStatusSection({ data, showDailyDetail = true }: Purchase
</div> </div>
<div style={{ backgroundColor: '#ffffff' }} className="p-6"> <div style={{ backgroundColor: '#ffffff' }} className="p-6">
{/* 통계카드 3개 */} {/* 통계카드 3개 - 가로 배치 */}
<div className="grid grid-cols-1 xs:grid-cols-3 gap-4 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
{/* 누적 매입 */} {/* 누적 매입 */}
<div <div
style={{ backgroundColor: '#fff7ed', borderColor: '#fed7aa' }} style={{ backgroundColor: '#fff7ed', borderColor: '#fed7aa' }}
@@ -174,94 +174,108 @@ export function PurchaseStatusSection({ data, showDailyDetail = true }: Purchase
outerRadius={80} outerRadius={80}
dataKey="value" dataKey="value"
nameKey="name" nameKey="name"
label={({ name, payload }) => `${name} ${(payload as { percentage?: number })?.percentage ?? 0}%`}
> >
{data.materialRatio.map((entry, index) => ( {data.materialRatio.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} /> <Cell key={`cell-${index}`} fill={entry.color} />
))} ))}
</Pie> </Pie>
<Legend /> <Tooltip formatter={(value: number | undefined) => [formatKoreanAmount(value ?? 0), '금액']} />
<Legend formatter={(value: string) => {
const item = data.materialRatio.find((r) => r.name === value);
return `${value} ${item?.percentage ?? 0}%`;
}} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
{/* 당월 매입 내역 테이블 */} </div>
{showDailyDetail && ( </div>
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-4 bg-gray-50 border-b"> {/* 당월 매입 내역 (별도 카드) */}
<h4 className="text-sm font-semibold text-gray-700"> </h4> <div className="rounded-xl border overflow-hidden">
<div className="flex gap-2"> <div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<MultiSelectCombobox <div className="flex items-center gap-3">
options={suppliers.map((s) => ({ value: s, label: s }))} <div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
value={supplierFilter} <ShoppingCart style={{ color: '#ffffff' }} className="h-5 w-5" />
onChange={setSupplierFilter}
placeholder="전체 공급처"
className="w-[160px] h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<div className="overflow-x-auto"> <div>
<table className="w-full text-sm"> <h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<thead> <p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
<tr className="bg-gray-50 border-b">
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-right text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{item.date}</td>
<td className="px-4 py-2 text-gray-700">{item.supplier}</td>
<td className="px-4 py-2 text-gray-700">{item.item}</td>
<td className="px-4 py-2 text-right text-gray-900 font-medium">
{item.amount.toLocaleString()}
</td>
<td className="px-4 py-2 text-center">
<Badge
variant="outline"
className={
item.status === '결제완료'
? 'text-green-600 border-green-200 bg-green-50'
: item.status === '미결제'
? 'text-red-600 border-red-200 bg-red-50'
: 'text-orange-600 border-orange-200 bg-orange-50'
}
>
{item.status}
</Badge>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-100 font-semibold">
<td className="px-4 py-2 text-gray-700" colSpan={3}></td>
<td className="px-4 py-2 text-right text-gray-900">
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}
</td>
<td />
</tr>
</tfoot>
</table>
</div> </div>
</div> </div>
)} </div>
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
<div className="text-sm text-gray-500"> {filteredItems.length}</div>
<div className="flex gap-2">
<MultiSelectCombobox
options={suppliers.map((s) => ({ value: s, label: s }))}
value={supplierFilter}
onChange={setSupplierFilter}
placeholder="전체 공급처"
className="w-[160px] h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-right text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{item.date}</td>
<td className="px-4 py-2 text-gray-700">{item.supplier}</td>
<td className="px-4 py-2 text-gray-700">{item.item}</td>
<td className="px-4 py-2 text-right text-gray-900 font-medium">
{item.amount.toLocaleString()}
</td>
<td className="px-4 py-2 text-center">
<Badge
variant="outline"
className={
item.status === '결제완료'
? 'text-green-600 border-green-200 bg-green-50'
: item.status === '미결제'
? 'text-red-600 border-red-200 bg-red-50'
: 'text-orange-600 border-orange-200 bg-orange-50'
}
>
{item.status}
</Badge>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-100 font-semibold">
<td className="px-4 py-2 text-gray-700" colSpan={3}></td>
<td className="px-4 py-2 text-right text-gray-900">
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -34,7 +34,6 @@ import type { SalesStatusData } from '../types';
interface SalesStatusSectionProps { interface SalesStatusSectionProps {
data: SalesStatusData; data: SalesStatusData;
showDailyDetail?: boolean;
} }
const formatAmount = (value: number) => { const formatAmount = (value: number) => {
@@ -43,7 +42,7 @@ const formatAmount = (value: number) => {
return value.toLocaleString(); return value.toLocaleString();
}; };
export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatusSectionProps) { export function SalesStatusSection({ data }: SalesStatusSectionProps) {
const [clientFilter, setClientFilter] = useState<string[]>([]); const [clientFilter, setClientFilter] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('date-desc'); const [sortOrder, setSortOrder] = useState('date-desc');
@@ -59,6 +58,7 @@ export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatus
const clients = [...new Set(data.dailyItems.map((item) => item.client))]; const clients = [...new Set(data.dailyItems.map((item) => item.client))];
return ( return (
<div className="space-y-6">
<div className="rounded-xl border overflow-hidden"> <div className="rounded-xl border overflow-hidden">
{/* 다크 헤더 */} {/* 다크 헤더 */}
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4"> <div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
@@ -83,7 +83,7 @@ export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatus
<div style={{ backgroundColor: '#ffffff' }} className="p-6"> <div style={{ backgroundColor: '#ffffff' }} className="p-6">
{/* 통계카드 4개 */} {/* 통계카드 4개 */}
<div className="grid grid-cols-1 xs:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* 누적 매출 */} {/* 누적 매출 */}
<div <div
style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }} style={{ backgroundColor: '#eff6ff', borderColor: '#bfdbfe' }}
@@ -194,82 +194,93 @@ export function SalesStatusSection({ data, showDailyDetail = true }: SalesStatus
</div> </div>
</div> </div>
{/* 당월 매출 내역 테이블 */} </div>
{showDailyDetail && ( </div>
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-4 bg-gray-50 border-b"> {/* 당월 매출 내역 (별도 카드) */}
<h4 className="text-sm font-semibold text-gray-700"> </h4> <div className="rounded-xl border overflow-hidden">
<div className="flex gap-2"> <div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
<MultiSelectCombobox <div className="flex items-center gap-3">
options={clients.map((c) => ({ value: c, label: c }))} <div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
value={clientFilter} <BarChart3 style={{ color: '#ffffff' }} className="h-5 w-5" />
onChange={setClientFilter}
placeholder="전체 거래처"
className="w-[160px] h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<div className="overflow-x-auto"> <div>
<table className="w-full text-sm"> <h3 style={{ color: '#ffffff' }} className="text-lg font-semibold"> </h3>
<thead> <p style={{ color: '#cbd5e1' }} className="text-sm"> </p>
<tr className="bg-gray-50 border-b">
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-right text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{item.date}</td>
<td className="px-4 py-2 text-gray-700">{item.client}</td>
<td className="px-4 py-2 text-gray-700">{item.item}</td>
<td className="px-4 py-2 text-right text-gray-900 font-medium">
{item.amount.toLocaleString()}
</td>
<td className="px-4 py-2 text-center">
<Badge
variant="outline"
className={
item.status === '입금완료'
? 'text-green-600 border-green-200 bg-green-50'
: item.status === '미입금'
? 'text-red-600 border-red-200 bg-red-50'
: 'text-orange-600 border-orange-200 bg-orange-50'
}
>
{item.status}
</Badge>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-100 font-semibold">
<td className="px-4 py-2 text-gray-700" colSpan={3}></td>
<td className="px-4 py-2 text-right text-gray-900">
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}
</td>
<td />
</tr>
</tfoot>
</table>
</div> </div>
</div> </div>
)} </div>
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
<div className="text-sm text-gray-500"> {filteredItems.length}</div>
<div className="flex gap-2">
<MultiSelectCombobox
options={clients.map((c) => ({ value: c, label: c }))}
value={clientFilter}
onChange={setClientFilter}
placeholder="전체 거래처"
className="w-[160px] h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-[120px] h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b">
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-left text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-right text-gray-600 font-medium"></th>
<th className="px-4 py-2 text-center text-gray-600 font-medium"></th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{item.date}</td>
<td className="px-4 py-2 text-gray-700">{item.client}</td>
<td className="px-4 py-2 text-gray-700">{item.item}</td>
<td className="px-4 py-2 text-right text-gray-900 font-medium">
{item.amount.toLocaleString()}
</td>
<td className="px-4 py-2 text-center">
<Badge
variant="outline"
className={
item.status === '입금완료'
? 'text-green-600 border-green-200 bg-green-50'
: item.status === '미입금'
? 'text-red-600 border-red-200 bg-red-50'
: 'text-orange-600 border-orange-200 bg-orange-50'
}
>
{item.status}
</Badge>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-100 font-semibold">
<td className="px-4 py-2 text-gray-700" colSpan={3}></td>
<td className="px-4 py-2 text-right text-gray-900">
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -13,7 +13,7 @@ export { CalendarSection } from './CalendarSection';
// 신규 섹션 // 신규 섹션
export { SalesStatusSection } from './SalesStatusSection'; export { SalesStatusSection } from './SalesStatusSection';
export { PurchaseStatusSection } from './PurchaseStatusSection'; export { PurchaseStatusSection } from './PurchaseStatusSection';
export { DailyProductionSection } from './DailyProductionSection'; export { DailyProductionSection, ShipmentSection } from './DailyProductionSection';
export { UnshippedSection } from './UnshippedSection'; export { UnshippedSection } from './UnshippedSection';
export { ConstructionSection } from './ConstructionSection'; export { ConstructionSection } from './ConstructionSection';
export { DailyAttendanceSection } from './DailyAttendanceSection'; export { DailyAttendanceSection } from './DailyAttendanceSection';

View File

@@ -347,6 +347,71 @@ export interface CEODashboardData {
dailyAttendance?: DailyAttendanceData; dailyAttendance?: DailyAttendanceData;
} }
// ===== 섹션 순서 관리 =====
// 대시보드 섹션 키
export type SectionKey =
| 'todayIssueList'
| 'dailyReport'
| 'statusBoard'
| 'monthlyExpense'
| 'cardManagement'
| 'entertainment'
| 'welfare'
| 'receivable'
| 'debtCollection'
| 'vat'
| 'calendar'
| 'salesStatus'
| 'purchaseStatus'
| 'production'
| 'shipment'
| 'unshipped'
| 'construction'
| 'attendance';
export const DEFAULT_SECTION_ORDER: SectionKey[] = [
'todayIssueList',
'dailyReport',
'statusBoard',
'monthlyExpense',
'cardManagement',
'entertainment',
'welfare',
'receivable',
'debtCollection',
'vat',
'calendar',
'salesStatus',
'purchaseStatus',
'production',
'shipment',
'unshipped',
'construction',
'attendance',
];
export const SECTION_LABELS: Record<SectionKey, string> = {
todayIssueList: '오늘의 이슈',
dailyReport: '자금현황',
statusBoard: '현황판',
monthlyExpense: '당월 예상 지출 내역',
cardManagement: '카드/가지급금 관리',
entertainment: '접대비 현황',
welfare: '복리후생비 현황',
receivable: '미수금 현황',
debtCollection: '채권추심 현황',
vat: '부가세 현황',
calendar: '캘린더',
salesStatus: '매출 현황',
purchaseStatus: '매입 현황',
production: '생산 현황',
shipment: '출고 현황',
unshipped: '미출고 내역',
construction: '시공 현황',
attendance: '근태 현황',
};
// ===== 대시보드 설정 타입 ===== // ===== 대시보드 설정 타입 =====
// 오늘의 이슈 개별 항목 설정 // 오늘의 이슈 개별 항목 설정
@@ -407,21 +472,20 @@ export interface DashboardSettings {
cardManagement: boolean; cardManagement: boolean;
entertainment: EntertainmentSettings; entertainment: EntertainmentSettings;
welfare: WelfareSettings; welfare: WelfareSettings;
receivable: { receivable: boolean;
enabled: boolean;
topCompanies: boolean; // 미수금 상위 회사 현황
};
debtCollection: boolean; debtCollection: boolean;
vat: boolean; vat: boolean;
calendar: boolean; calendar: boolean;
// 신규 섹션 설정 // 신규 섹션 설정
salesStatus: { enabled: boolean; dailySalesDetail: boolean }; salesStatus: boolean;
purchaseStatus: { enabled: boolean; dailyPurchaseDetail: boolean }; purchaseStatus: boolean;
production: boolean; production: boolean;
shipment: boolean; shipment: boolean;
unshipped: boolean; unshipped: boolean;
construction: boolean; construction: boolean;
attendance: boolean; attendance: boolean;
// 섹션 순서
sectionOrder?: SectionKey[];
// Legacy: 기존 todayIssue 호환용 (deprecated, statusBoard로 대체) // Legacy: 기존 todayIssue 호환용 (deprecated, statusBoard로 대체)
todayIssue: { todayIssue: {
enabled: boolean; enabled: boolean;
@@ -649,21 +713,20 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
ratio: 20.5, ratio: 20.5,
annualTotal: 20000000, annualTotal: 20000000,
}, },
receivable: { receivable: true,
enabled: true,
topCompanies: true,
},
debtCollection: true, debtCollection: true,
vat: true, vat: true,
calendar: true, calendar: true,
// 신규 섹션 // 신규 섹션
salesStatus: { enabled: true, dailySalesDetail: true }, salesStatus: true,
purchaseStatus: { enabled: true, dailyPurchaseDetail: true }, purchaseStatus: true,
production: true, production: true,
shipment: true, shipment: true,
unshipped: true, unshipped: true,
construction: true, construction: true,
attendance: true, attendance: true,
// 섹션 순서
sectionOrder: DEFAULT_SECTION_ORDER,
// Legacy: 기존 todayIssue 호환용 (statusBoard와 동일) // Legacy: 기존 todayIssue 호환용 (statusBoard와 동일)
todayIssue: { todayIssue: {
enabled: true, enabled: true,

View 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;

View File

@@ -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>
);
},
);

View File

@@ -1,6 +1,8 @@
// 문서 공통 컴포넌트 // 문서 공통 컴포넌트
export { ApprovalLine } from './ApprovalLine'; export { ApprovalLine } from './ApprovalLine';
export { DocumentHeader } from './DocumentHeader'; export { DocumentHeader } from './DocumentHeader';
export { DocumentWrapper } from './DocumentWrapper';
export { DocumentTable, DOC_STYLES } from './DocumentTable';
export { SectionHeader } from './SectionHeader'; export { SectionHeader } from './SectionHeader';
export { InfoTable } from './InfoTable'; export { InfoTable } from './InfoTable';
export { QualityApprovalTable } from './QualityApprovalTable'; export { QualityApprovalTable } from './QualityApprovalTable';
@@ -11,6 +13,8 @@ export { SignatureSection } from './SignatureSection';
// Types // Types
export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine'; export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine';
export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader'; export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader';
export type { DocumentWrapperProps } from './DocumentWrapper';
export type { DocumentTableProps } from './DocumentTable';
export type { SectionHeaderProps } from './SectionHeader'; export type { SectionHeaderProps } from './SectionHeader';
export type { InfoTableCell, InfoTableProps } from './InfoTable'; export type { InfoTableCell, InfoTableProps } from './InfoTable';
export type { export type {

View 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 };
}

View File

@@ -5,6 +5,9 @@ export { DocumentViewer } from './viewer';
export { export {
ApprovalLine, ApprovalLine,
DocumentHeader, DocumentHeader,
DocumentWrapper,
DocumentTable,
DOC_STYLES,
SectionHeader, SectionHeader,
InfoTable, InfoTable,
QualityApprovalTable, QualityApprovalTable,
@@ -15,6 +18,7 @@ export {
// Hooks // Hooks
export { useZoom, useDrag } from './viewer/hooks'; export { useZoom, useDrag } from './viewer/hooks';
export { usePrintHandler } from './hooks/usePrintHandler';
// Presets // Presets
export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets'; export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets';
@@ -26,6 +30,8 @@ export type {
ApprovalLineProps, ApprovalLineProps,
DocumentHeaderLogo, DocumentHeaderLogo,
DocumentHeaderProps, DocumentHeaderProps,
DocumentWrapperProps,
DocumentTableProps,
SectionHeaderProps, SectionHeaderProps,
InfoTableCell, InfoTableCell,
InfoTableProps, InfoTableProps,

View File

@@ -2,12 +2,13 @@
/** /**
* 발주서 문서 컴포넌트 * 발주서 문서 컴포넌트
* - 스크린샷 형식 + 지출결의서 디자인 스타일 * - DocumentWrapper + DocumentTable building block 활용
*/ */
import { getTodayString } from "@/lib/utils/date"; import { getTodayString } from "@/lib/utils/date";
import { OrderItem } from "../actions"; import { OrderItem } from "../actions";
import { formatNumber } from '@/lib/utils/amount'; import { formatNumber } from '@/lib/utils/amount';
import { DocumentWrapper, DocumentTable, DOC_STYLES } from '@/components/document-system';
/** /**
* 수량 포맷 함수 * 수량 포맷 함수
@@ -61,7 +62,7 @@ export function PurchaseOrderDocument({
remarks, remarks,
}: PurchaseOrderDocumentProps) { }: PurchaseOrderDocumentProps) {
return ( return (
<div className="bg-white p-8 min-h-full"> <DocumentWrapper fontSize="text-sm">
{/* 헤더: 제목 + 로트번호/결재란 */} {/* 헤더: 제목 + 로트번호/결재란 */}
<div className="flex justify-between items-start mb-6"> <div className="flex justify-between items-start mb-6">
<h1 className="text-2xl font-bold tracking-widest"> </h1> <h1 className="text-2xl font-bold tracking-widest"> </h1>
@@ -98,101 +99,95 @@ export function PurchaseOrderDocument({
</div> </div>
{/* 신청업체 */} {/* 신청업체 */}
<div className="border border-gray-300 mb-4"> <DocumentTable spacing="mb-4" className="text-sm">
<table className="w-full text-sm"> <tbody>
<tbody> <tr>
<tr> <td rowSpan={2} className={`${DOC_STYLES.label} text-center w-20`}>
<td rowSpan={2} className="bg-gray-100 border-r border-b border-gray-300 p-2 text-center font-medium w-20">
</td>
</td> <td className={`${DOC_STYLES.label} w-20`}></td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20"></td> <td className={DOC_STYLES.value}>{client}</td>
<td className="border-r border-b border-gray-300 p-2">{client}</td> <td className={`${DOC_STYLES.label} w-20`}></td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20"></td> <td className={DOC_STYLES.value}>{orderDate}</td>
<td className="border-b border-gray-300 p-2">{orderDate}</td> </tr>
</tr> <tr>
<tr> <td className={`${DOC_STYLES.label} w-20`}></td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2"></td> <td className={DOC_STYLES.value}>{manager}</td>
<td className="border-r border-b border-gray-300 p-2">{manager}</td> <td className={`${DOC_STYLES.label} w-20`}></td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2"></td> <td className={DOC_STYLES.value}>{managerContact}</td>
<td className="border-b border-gray-300 p-2">{managerContact}</td> </tr>
</tr> <tr>
<tr> <td className={`${DOC_STYLES.label} text-center w-20`}></td>
<td className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium"></td> <td className={`${DOC_STYLES.label} w-20`}>FAX</td>
<td className="bg-gray-100 border-r border-gray-300 p-2">FAX</td> <td className={DOC_STYLES.value}>-</td>
<td className="border-r border-gray-300 p-2">-</td> <td className={`${DOC_STYLES.label} w-20`}><br/>()</td>
<td className="bg-gray-100 border-r border-gray-300 p-2"><br/>()</td> <td className={DOC_STYLES.value}>{installationCount}</td>
<td className="border-gray-300 p-2">{installationCount}</td> </tr>
</tr> </tbody>
</tbody> </DocumentTable>
</table>
</div>
{/* 신청내용 */} {/* 신청내용 */}
<div className="border border-gray-300 mb-4"> <DocumentTable spacing="mb-4" className="text-sm">
<table className="w-full text-sm"> <tbody>
<tbody> <tr>
<tr> <td rowSpan={3} className={`${DOC_STYLES.label} text-center w-20`}>
<td rowSpan={3} className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium w-20">
</td>
</td> <td className={`${DOC_STYLES.label} w-20`}></td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20"></td> <td colSpan={3} className={DOC_STYLES.value}>{siteName}</td>
<td colSpan={3} className="border-b border-gray-300 p-2">{siteName}</td> </tr>
</tr> <tr>
<tr> <td className={`${DOC_STYLES.label} w-20`}><br/></td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2"><br/></td> <td className={DOC_STYLES.value}>{deliveryRequestDate}</td>
<td className="border-r border-b border-gray-300 p-2">{deliveryRequestDate}</td> <td className={`${DOC_STYLES.label} w-20`}></td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20"></td> <td className={DOC_STYLES.value}>{deliveryMethod}</td>
<td className="border-b border-gray-300 p-2">{deliveryMethod}</td> </tr>
</tr> <tr>
<tr> <td className={`${DOC_STYLES.label} w-20`}></td>
<td className="bg-gray-100 border-r border-gray-300 p-2"></td> <td className={DOC_STYLES.value}>{expectedShipDate}</td>
<td className="border-r border-gray-300 p-2">{expectedShipDate}</td> <td className={`${DOC_STYLES.label} w-20`}></td>
<td className="bg-gray-100 border-r border-gray-300 p-2"></td> <td className={DOC_STYLES.value}>{address}</td>
<td className="border-gray-300 p-2">{address}</td> </tr>
</tr> </tbody>
</tbody> </DocumentTable>
</table>
</div>
{/* 부자재 */} {/* 부자재 */}
<div className="mb-4"> <div className="mb-4">
<p className="text-sm font-medium mb-2"> </p> <p className="text-sm font-medium mb-2"> </p>
<div className="border border-gray-300"> <DocumentTable spacing="">
<table className="w-full text-sm"> <thead>
<thead> <tr>
<tr className="bg-gray-100 border-b border-gray-300"> <th className={`${DOC_STYLES.th} w-16`}></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-16"></th> <th className={`${DOC_STYLES.th} text-left`}></th>
<th className="p-2 text-left font-medium border-r border-gray-300"></th> <th className={`${DOC_STYLES.th} w-20`}></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-20"></th> <th className={`${DOC_STYLES.th} w-24`}>(mm)</th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-24">(mm)</th> <th className={`${DOC_STYLES.th} w-16`}></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-16"></th> <th className={`${DOC_STYLES.th} w-24`}></th>
<th className="p-2 text-center font-medium w-24"></th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {items.length > 0 ? (
{items.length > 0 ? ( items.map((item, index) => (
items.map((item, index) => ( <tr key={item.id}>
<tr key={item.id} className="border-b border-gray-300"> <td className={DOC_STYLES.tdCenter}>{index + 1}</td>
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td> <td className={DOC_STYLES.td}>{item.itemName}</td>
<td className="p-2 border-r border-gray-300">{item.itemName}</td> <td className={DOC_STYLES.tdCenter}>{item.spec}</td>
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td> <td className={DOC_STYLES.tdCenter}>
<td className="p-2 text-center border-r border-gray-300"> {item.width ? `${item.width}` : "-"}
{item.width ? `${item.width}` : "-"}
</td>
<td className="p-2 text-center border-r border-gray-300">{formatQuantity(item.quantity, item.unit)}</td>
<td className="p-2 text-center">{item.symbol || "-"}</td>
</tr>
))
) : (
<tr className="border-b border-gray-300">
<td colSpan={6} className="p-4 text-center text-gray-400">
</td> </td>
<td className={DOC_STYLES.tdCenter}>{formatQuantity(item.quantity, item.unit)}</td>
<td className={DOC_STYLES.tdCenter}>{item.symbol || "-"}</td>
</tr> </tr>
)} ))
</tbody> ) : (
</table> <tr>
</div> <td colSpan={6} className="p-4 text-center text-gray-400 border border-gray-300">
</td>
</tr>
)}
</tbody>
</DocumentTable>
</div> </div>
{/* 특이사항 */} {/* 특이사항 */}
@@ -219,6 +214,6 @@ export function PurchaseOrderDocument({
<div className="text-center text-sm text-gray-600"> <div className="text-center text-sm text-gray-600">
문의: 홍길동 | 010-1234-5678 문의: 홍길동 | 010-1234-5678
</div> </div>
</div> </DocumentWrapper>
); );
} }

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

View 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 };
}

View 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';

View 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;
}

View 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,
};
}

View File

@@ -10,16 +10,28 @@
* - 검사대상 사전 고지 정보 테이블 * - 검사대상 사전 고지 정보 테이블
*/ */
import { ConstructionApprovalTable } from '@/components/document-system'; import {
ConstructionApprovalTable,
DocumentWrapper,
DocumentTable,
DOC_STYLES,
} from '@/components/document-system';
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types'; import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
interface InspectionRequestDocumentProps { interface InspectionRequestDocumentProps {
data: InspectionRequestDocumentType; data: InspectionRequestDocumentType;
} }
/** 라벨 셀 */
const lbl = `${DOC_STYLES.label} w-28`;
/** 서브 라벨 셀 (bg-gray-50) */
const subLbl = 'bg-gray-50 px-2 py-1 font-medium border-r border-gray-300 w-28';
/** 값 셀 */
const val = DOC_STYLES.value;
export function InspectionRequestDocument({ data }: InspectionRequestDocumentProps) { export function InspectionRequestDocument({ data }: InspectionRequestDocumentProps) {
return ( return (
<div className="bg-white p-8 min-h-full text-[11px]"> <DocumentWrapper fontSize="text-[11px]">
{/* 헤더: 제목 (좌측) + 결재란 (우측) */} {/* 헤더: 제목 (좌측) + 결재란 (우측) */}
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
@@ -51,47 +63,44 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
</div> </div>
{/* 기본 정보 */} {/* 기본 정보 */}
<div className="border border-gray-400 mb-4"> <DocumentTable header="기본 정보" headerVariant="light" spacing="mb-4">
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400"> </div> <tbody>
<table className="w-full"> <tr className="border-b border-gray-300">
<tbody> <td className={lbl}></td>
<tr className="border-b border-gray-300"> <td className={`${val} border-r border-gray-300`}>{data.client || '-'}</td>
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={lbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.client || '-'}</td> <td className={val}>{data.companyName || '-'}</td>
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> </tr>
<td className="px-2 py-1">{data.companyName || '-'}</td> <tr className="border-b border-gray-300">
</tr> <td className={lbl}></td>
<tr className="border-b border-gray-300"> <td className={`${val} border-r border-gray-300`}>{data.manager || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"></td> <td className={lbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.manager || '-'}</td> <td className={val}>{data.orderNumber || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"></td> </tr>
<td className="px-2 py-1">{data.orderNumber || '-'}</td> <tr className="border-b border-gray-300">
</tr> <td className={lbl}> </td>
<tr className="border-b border-gray-300"> <td className={`${val} border-r border-gray-300`}>{data.managerContact || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"> </td> <td className={lbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.managerContact || '-'}</td> <td className={val}>{data.siteName || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"></td> </tr>
<td className="px-2 py-1">{data.siteName || '-'}</td> <tr className="border-b border-gray-300">
</tr> <td className={lbl}></td>
<tr className="border-b border-gray-300"> <td className={`${val} border-r border-gray-300`}>{data.deliveryDate || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"></td> <td className={lbl}> </td>
<td className="px-2 py-1 border-r border-gray-300">{data.deliveryDate || '-'}</td> <td className={val}>{data.siteAddress || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"> </td> </tr>
<td className="px-2 py-1">{data.siteAddress || '-'}</td> <tr>
</tr> <td className={lbl}> </td>
<tr> <td className={`${val} border-r border-gray-300`}>{data.totalLocations || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"> </td> <td className={lbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.totalLocations || '-'}</td> <td className={val}>{data.receptionDate || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"></td> </tr>
<td className="px-2 py-1">{data.receptionDate || '-'}</td> <tr className="border-t border-gray-300">
</tr> <td className={lbl}></td>
<tr className="border-t border-gray-300"> <td className={val} colSpan={3}>{data.visitRequestDate || '-'}</td>
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-300"></td> </tr>
<td className="px-2 py-1" colSpan={3}>{data.visitRequestDate || '-'}</td> </tbody>
</tr> </DocumentTable>
</tbody>
</table>
</div>
{/* 입력사항: 4개 섹션 */} {/* 입력사항: 4개 섹션 */}
<div className="border border-gray-400 mb-4"> <div className="border border-gray-400 mb-4">
@@ -103,12 +112,12 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
<tr> <tr>
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.constructionSite.siteName || '-'}</td> <td className={`${val} border-r border-gray-300`}>{data.constructionSite.siteName || '-'}</td>
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.constructionSite.landLocation || '-'}</td> <td className={`${val} border-r border-gray-300`}>{data.constructionSite.landLocation || '-'}</td>
<td className="bg-gray-50 px-2 py-1 w-20 font-medium border-r border-gray-300"></td> <td className={`${subLbl} w-20`}></td>
<td className="px-2 py-1">{data.constructionSite.lotNumber || '-'}</td> <td className={val}>{data.constructionSite.lotNumber || '-'}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -120,16 +129,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
<tr className="border-b border-gray-300"> <tr className="border-b border-gray-300">
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.materialDistributor.companyName || '-'}</td> <td className={`${val} border-r border-gray-300`}>{data.materialDistributor.companyName || '-'}</td>
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1">{data.materialDistributor.companyAddress || '-'}</td> <td className={val}>{data.materialDistributor.companyAddress || '-'}</td>
</tr> </tr>
<tr> <tr>
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.materialDistributor.representativeName || '-'}</td> <td className={`${val} border-r border-gray-300`}>{data.materialDistributor.representativeName || '-'}</td>
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1">{data.materialDistributor.phone || '-'}</td> <td className={val}>{data.materialDistributor.phone || '-'}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -141,16 +150,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
<tr className="border-b border-gray-300"> <tr className="border-b border-gray-300">
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.constructorInfo.companyName || '-'}</td> <td className={`${val} border-r border-gray-300`}>{data.constructorInfo.companyName || '-'}</td>
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1">{data.constructorInfo.companyAddress || '-'}</td> <td className={val}>{data.constructorInfo.companyAddress || '-'}</td>
</tr> </tr>
<tr> <tr>
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.constructorInfo.name || '-'}</td> <td className={`${val} border-r border-gray-300`}>{data.constructorInfo.name || '-'}</td>
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1">{data.constructorInfo.phone || '-'}</td> <td className={val}>{data.constructorInfo.phone || '-'}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -162,16 +171,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
<table className="w-full"> <table className="w-full">
<tbody> <tbody>
<tr className="border-b border-gray-300"> <tr className="border-b border-gray-300">
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.supervisor.officeName || '-'}</td> <td className={`${val} border-r border-gray-300`}>{data.supervisor.officeName || '-'}</td>
<td className="bg-gray-50 px-2 py-1 w-28 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1">{data.supervisor.officeAddress || '-'}</td> <td className={val}>{data.supervisor.officeAddress || '-'}</td>
</tr> </tr>
<tr> <tr>
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1 border-r border-gray-300">{data.supervisor.name || '-'}</td> <td className={`${val} border-r border-gray-300`}>{data.supervisor.name || '-'}</td>
<td className="bg-gray-50 px-2 py-1 font-medium border-r border-gray-300"></td> <td className={subLbl}></td>
<td className="px-2 py-1">{data.supervisor.phone || '-'}</td> <td className={val}>{data.supervisor.phone || '-'}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -179,71 +188,71 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
</div> </div>
{/* 검사 요청 시 필독 */} {/* 검사 요청 시 필독 */}
<div className="border border-gray-400 mb-4"> <DocumentTable header="검사 요청 시 필독" headerVariant="dark" spacing="mb-4">
<div className="bg-gray-800 text-white text-center py-1 font-bold border-b border-gray-400"> </div> <tbody>
<div className="px-4 py-3 text-[11px] leading-relaxed text-center"> <tr>
<p> <td className="px-4 py-3 text-[11px] leading-relaxed text-center">
, <p>
<br /> ,
. <br />
<br /> .
<span className="text-red-600 font-medium"> .</span> <br />
</p> <span className="text-red-600 font-medium"> .</span>
<p className="mt-2 text-gray-600"> </p>
( .) <p className="mt-2 text-gray-600">
</p> ( .)
</div> </p>
</div> </td>
</tr>
</tbody>
</DocumentTable>
{/* 검사대상 사전 고지 정보 */} {/* 검사대상 사전 고지 정보 */}
<div className="border border-gray-400 mb-4"> <DocumentTable header="검사대상 사전 고지 정보" headerVariant="dark" spacing="mb-4">
<div className="bg-gray-800 text-white text-center py-1 font-bold border-b border-gray-400"> </div> <thead>
<table className="w-full"> {/* 1단: 오픈사이즈 병합 */}
<thead> <tr className="bg-gray-100 border-b border-gray-300">
{/* 1단: 오픈사이즈 병합 */} <th className={`${DOC_STYLES.th} w-12`} rowSpan={3}>No.</th>
<tr className="bg-gray-100 border-b border-gray-300"> <th className={`${DOC_STYLES.th} w-16`} rowSpan={3}></th>
<th className="border-r border-gray-400 px-2 py-1 w-12 text-center" rowSpan={3}>No.</th> <th className={`${DOC_STYLES.th} w-20`} rowSpan={3}></th>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center" rowSpan={3}></th> <th className={DOC_STYLES.th} colSpan={4}></th>
<th className="border-r border-gray-400 px-2 py-1 w-20 text-center" rowSpan={3}></th> <th className={`${DOC_STYLES.th} border-r-0`} rowSpan={3}></th>
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={4}></th> </tr>
<th className="px-2 py-1 text-center" rowSpan={3}></th> {/* 2단: 발주 규격, 시공후 규격 */}
<tr className="bg-gray-100 border-b border-gray-300">
<th className={DOC_STYLES.th} colSpan={2}> </th>
<th className={DOC_STYLES.th} colSpan={2}> </th>
</tr>
{/* 3단: 가로, 세로 */}
<tr className="bg-gray-100 border-b border-gray-400">
<th className={`${DOC_STYLES.th} w-16`}></th>
<th className={`${DOC_STYLES.th} w-16`}></th>
<th className={`${DOC_STYLES.th} w-16`}></th>
<th className={`${DOC_STYLES.th} w-16`}></th>
</tr>
</thead>
<tbody>
{data.priorNoticeItems.map((item, index) => (
<tr key={item.id} className="border-b border-gray-300">
<td className={DOC_STYLES.tdCenter}>{index + 1}</td>
<td className={DOC_STYLES.tdCenter}>{item.floor}</td>
<td className={DOC_STYLES.tdCenter}>{item.symbol}</td>
<td className={DOC_STYLES.tdCenter}>{item.orderWidth}</td>
<td className={DOC_STYLES.tdCenter}>{item.orderHeight}</td>
<td className={DOC_STYLES.tdCenter}>{item.constructionWidth}</td>
<td className={DOC_STYLES.tdCenter}>{item.constructionHeight}</td>
<td className={DOC_STYLES.td}>{item.changeReason || '-'}</td>
</tr> </tr>
{/* 2단: 발주 규격, 시공후 규격 */} ))}
<tr className="bg-gray-100 border-b border-gray-300"> {data.priorNoticeItems.length === 0 && (
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={2}> </th> <tr>
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={2}> </th> <td colSpan={8} className="px-2 py-4 text-center text-gray-400">
.
</td>
</tr> </tr>
{/* 3단: 가로, 세로 */} )}
<tr className="bg-gray-100 border-b border-gray-400"> </tbody>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center"></th> </DocumentTable>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center"></th>
</tr>
</thead>
<tbody>
{data.priorNoticeItems.map((item, index) => (
<tr key={item.id} className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center">{index + 1}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.floor}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.symbol}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderWidth}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderHeight}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionWidth}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionHeight}</td>
<td className="px-2 py-1">{item.changeReason || '-'}</td>
</tr>
))}
{data.priorNoticeItems.length === 0 && (
<tr>
<td colSpan={8} className="px-2 py-4 text-center text-gray-400">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 서명 영역 */} {/* 서명 영역 */}
<div className="mt-8 text-center text-[10px]"> <div className="mt-8 text-center text-[10px]">
@@ -252,6 +261,6 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
<p>{data.createdDate}</p> <p>{data.createdDate}</p>
</div> </div>
</div> </div>
</div> </DocumentWrapper>
); );
} }

View File

@@ -1,98 +1,27 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState, useCallback } from 'react'; import { useEffect } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions'; import { usePermissionStore } from '@/stores/permissionStore';
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils'; import { findMatchingUrl } from '@/lib/permissions/utils';
import { ALL_DENIED_PERMS } from '@/lib/permissions/types';
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types'; import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
import { AccessDenied } from '@/components/common/AccessDenied'; import { AccessDenied } from '@/components/common/AccessDenied';
import { stripLocalePrefix } from '@/lib/utils/locale'; import { stripLocalePrefix } from '@/lib/utils/locale';
interface PermissionContextType { /**
permissionMap: PermissionMap | null; * PermissionProvider — Zustand store 초기화 래퍼
isLoading: boolean; *
/** URL 지정 권한 체크 (특수 케이스용) */ * 기존 Context.Provider 역할을 대체합니다.
can: (url: string, action: PermissionAction) => boolean; * 마운트 시 한 번 loadPermissions() 호출만 담당합니다.
/** 권한 데이터 다시 로드 (설정 변경 후 호출) */ */
reloadPermissions: () => void;
}
const PermissionContext = createContext<PermissionContextType>({
permissionMap: null,
isLoading: true,
can: () => true,
reloadPermissions: () => {},
});
export function PermissionProvider({ children }: { children: React.ReactNode }) { export function PermissionProvider({ children }: { children: React.ReactNode }) {
const [permissionMap, setPermissionMap] = useState<PermissionMap | null>(null); const loadPermissions = usePermissionStore((s) => s.loadPermissions);
const [isLoading, setIsLoading] = useState(true);
const loadPermissions = useCallback(async () => {
const userData = getUserData();
if (!userData || userData.roleIds.length === 0) {
setIsLoading(false);
return;
}
const { roleIds, menuIdToUrl } = userData;
setIsLoading(true);
try {
// 사이드바 메뉴에 없는 권한 메뉴의 URL 매핑 보완
// (기준정보 관리, 공정관리 등 사이드바 미등록 메뉴 대응)
const [permMenuUrlMap, ...results] = await Promise.all([
getPermissionMenuUrlMap(),
...roleIds.map(id => getRolePermissionMatrix(id)),
]);
// 권한 메뉴 URL을 베이스로, 사이드바 메뉴 URL로 덮어쓰기 (사이드바 우선)
const mergedMenuIdToUrl = { ...permMenuUrlMap, ...menuIdToUrl };
const maps = results
.filter(r => r.success && r.data?.permissions)
.map(r => convertMatrixToPermissionMap(r.data.permissions, mergedMenuIdToUrl));
if (maps.length > 0) {
const merged = mergePermissionMaps(maps);
// 권한 메뉴에 등록되어 있지만 매트릭스 응답에 없는 메뉴 처리
// (모든 권한 OFF → API가 해당 menuId를 생략 → "all denied"로 보완)
for (const [, url] of Object.entries(permMenuUrlMap)) {
if (url && !merged[url]) {
merged[url] = { ...ALL_DENIED_PERMS };
}
}
setPermissionMap(merged);
} else {
setPermissionMap(null);
}
} catch (error) {
console.error('[Permission] 권한 로드 실패:', error);
setPermissionMap(null);
}
setIsLoading(false);
}, []);
// 마운트 시 1회 로드
useEffect(() => { useEffect(() => {
loadPermissions(); loadPermissions();
}, [loadPermissions]); }, [loadPermissions]);
const can = useCallback((url: string, action: PermissionAction): boolean => { return <>{children}</>;
if (!permissionMap) return true;
const matchedUrl = findMatchingUrl(url, permissionMap);
if (!matchedUrl) return true;
const perms = permissionMap[matchedUrl];
return perms?.[action] ?? true;
}, [permissionMap]);
return (
<PermissionContext.Provider value={{ permissionMap, isLoading, can, reloadPermissions: loadPermissions }}>
{children}
</PermissionContext.Provider>
);
} }
/** /**
@@ -102,7 +31,7 @@ const BYPASS_PATHS = ['/settings/permissions'];
function isGateBypassed(pathname: string): boolean { function isGateBypassed(pathname: string): boolean {
const pathWithoutLocale = stripLocalePrefix(pathname); const pathWithoutLocale = stripLocalePrefix(pathname);
return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp)); return BYPASS_PATHS.some((bp) => pathWithoutLocale.startsWith(bp));
} }
/** /**
@@ -110,7 +39,8 @@ function isGateBypassed(pathname: string): boolean {
*/ */
export function PermissionGate({ children }: { children: React.ReactNode }) { export function PermissionGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { permissionMap, isLoading } = useContext(PermissionContext); const permissionMap = usePermissionStore((s) => s.permissionMap);
const isLoading = usePermissionStore((s) => s.isLoading);
if (isLoading) return null; if (isLoading) return null;
if (!permissionMap) { if (!permissionMap) {
@@ -135,27 +65,21 @@ export function PermissionGate({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
/** localStorage 'user' 키에서 역할 ID 배열 + menuId→URL 매핑 추출 */ /**
function getUserData(): { roleIds: number[]; menuIdToUrl: Record<string, string> } | null { * 하위호환 훅 — 기존 usePermissionContext() 소비자를 위한 어댑터
if (typeof window === 'undefined') return null; *
try { * Zustand store에서 읽되, 기존과 동일한 인터페이스를 반환합니다.
const raw = localStorage.getItem('user'); */
if (!raw) return null; export function usePermissionContext(): {
permissionMap: PermissionMap | null;
isLoading: boolean;
can: (url: string, action: PermissionAction) => boolean;
reloadPermissions: () => void;
} {
const permissionMap = usePermissionStore((s) => s.permissionMap);
const isLoading = usePermissionStore((s) => s.isLoading);
const can = usePermissionStore((s) => s.can);
const loadPermissions = usePermissionStore((s) => s.loadPermissions);
const parsed = JSON.parse(raw); return { permissionMap, isLoading, can, reloadPermissions: loadPermissions };
const roleIds = Array.isArray(parsed.roles)
? parsed.roles.map((r: { id: number }) => r.id).filter(Boolean)
: [];
const menuIdToUrl = Array.isArray(parsed.menu)
? buildMenuIdToUrlMap(parsed.menu)
: {};
return { roleIds, menuIdToUrl };
} catch {
return null;
}
} }
export const usePermissionContext = () => useContext(PermissionContext);

View File

@@ -1,6 +1,6 @@
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { usePermissionContext } from '@/contexts/PermissionContext'; import { usePermissionStore } from '@/stores/permissionStore';
import { findMatchingUrl } from '@/lib/permissions/utils'; import { findMatchingUrl } from '@/lib/permissions/utils';
import { ALL_ALLOWED } from '@/lib/permissions/types'; import { ALL_ALLOWED } from '@/lib/permissions/types';
import type { UsePermissionReturn } from '@/lib/permissions/types'; import type { UsePermissionReturn } from '@/lib/permissions/types';
@@ -20,7 +20,8 @@ import type { UsePermissionReturn } from '@/lib/permissions/types';
*/ */
export function usePermission(overrideUrl?: string): UsePermissionReturn { export function usePermission(overrideUrl?: string): UsePermissionReturn {
const pathname = usePathname(); const pathname = usePathname();
const { permissionMap, isLoading } = usePermissionContext(); const permissionMap = usePermissionStore((s) => s.permissionMap);
const isLoading = usePermissionStore((s) => s.isLoading);
const targetPath = overrideUrl || pathname; const targetPath = overrideUrl || pathname;

File diff suppressed because it is too large Load Diff

View 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,
};
}

View 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';
}

View 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),
};
}

View 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;
}

View 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),
};
}

View 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'),
};
}

View 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,
};
}

View 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) || '',
},
],
},
};
}

View File

@@ -152,35 +152,9 @@ export function parseAccountNumber(formatted: string): string {
} }
/** /**
* 숫자 천단위 콤마 포맷팅 * 숫자 천단위 콤마 포맷팅 — @/lib/utils/amount 통합 버전으로 위임
*/ */
export function formatNumber(value: number | string, options?: { export { formatNumber, type FormatNumberOptions } from '@/lib/utils/amount';
useComma?: boolean;
decimalPlaces?: number;
suffix?: string;
}): string {
const { useComma = true, decimalPlaces, suffix = '' } = options || {};
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return '';
let formatted: string;
if (decimalPlaces !== undefined) {
formatted = num.toFixed(decimalPlaces);
} else {
formatted = String(num);
}
if (useComma) {
const parts = formatted.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
formatted = parts.join('.');
}
return suffix ? `${formatted}${suffix}` : formatted;
}
/** /**
* 포맷된 숫자 문자열에서 숫자 추출 * 포맷된 숫자 문자열에서 숫자 추출

View File

@@ -5,14 +5,56 @@
* 1만원 이상: "1,000만원" * 1만원 이상: "1,000만원"
*/ */
/**
* formatNumber 옵션 (formatters.ts 호환)
*/
export interface FormatNumberOptions {
useComma?: boolean;
decimalPlaces?: number;
suffix?: string;
}
/** /**
* 단순 숫자 포맷 (천단위 콤마만, 단위 없음) * 단순 숫자 포맷 (천단위 콤마만, 단위 없음)
*
* - 옵션 없이 호출: Intl.NumberFormat 사용, null/NaN → '-'
* - 옵션과 함께 호출: formatters.ts 호환, NaN → ''
*
* @example formatNumber(1234567) // "1,234,567" * @example formatNumber(1234567) // "1,234,567"
* @example formatNumber(null) // "-" * @example formatNumber(null) // "-"
* @example formatNumber(1234, { suffix: '원' }) // "1,234원"
* @example formatNumber('abc', { useComma: true }) // ""
*/ */
export function formatNumber(value: number | null | undefined): string { export function formatNumber(
if (value == null || isNaN(value)) return '-'; value: number | string | null | undefined,
return new Intl.NumberFormat('ko-KR').format(value); options?: FormatNumberOptions,
): string {
if (options) {
const { useComma = true, decimalPlaces, suffix = '' } = options;
const num = typeof value === 'string' ? parseFloat(value) : (value ?? NaN);
if (isNaN(num)) return '';
let formatted: string;
if (decimalPlaces !== undefined) {
formatted = num.toFixed(decimalPlaces);
} else {
formatted = String(num);
}
if (useComma) {
const parts = formatted.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
formatted = parts.join('.');
}
return suffix ? `${formatted}${suffix}` : formatted;
}
// 옵션 없는 호출: 기존 amount.ts 동작 유지
if (value == null) return '-';
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return '-';
return new Intl.NumberFormat('ko-KR').format(num);
} }
export function formatAmount(amount: number): string { export function formatAmount(amount: number): string {

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { safeJsonParse } from '@/lib/utils'; import { createUserStorage } from './utils/userStorage';
export interface FavoriteItem { export interface FavoriteItem {
id: string; id: string;
@@ -12,18 +12,6 @@ export interface FavoriteItem {
export const MAX_FAVORITES = 10; export const MAX_FAVORITES = 10;
function getUserId(): string {
if (typeof window === 'undefined') return 'default';
const userStr = localStorage.getItem('user');
if (!userStr) return 'default';
const user = safeJsonParse<Record<string, unknown> | null>(userStr, null);
return user?.id ? String(user.id) : 'default';
}
function getStorageKey(): string {
return `sam-favorites-${getUserId()}`;
}
interface FavoritesState { interface FavoritesState {
favorites: FavoriteItem[]; favorites: FavoriteItem[];
toggleFavorite: (item: FavoriteItem) => 'added' | 'removed' | 'max_reached'; toggleFavorite: (item: FavoriteItem) => 'added' | 'removed' | 'max_reached';
@@ -68,27 +56,7 @@ export const useFavoritesStore = create<FavoritesState>()(
}), }),
{ {
name: 'sam-favorites', name: 'sam-favorites',
// 사용자별 키를 위해 storage 커스텀 storage: createUserStorage('sam-favorites'),
storage: {
getItem: (name) => {
const key = getStorageKey();
const str = localStorage.getItem(key);
if (!str) {
// fallback: 기본 키에서도 확인
const fallback = localStorage.getItem(name);
return fallback ? JSON.parse(fallback) : null;
}
return JSON.parse(str);
},
setItem: (name, value) => {
const key = getStorageKey();
localStorage.setItem(key, JSON.stringify(value));
},
removeItem: (name) => {
const key = getStorageKey();
localStorage.removeItem(key);
},
},
} }
) )
); );

View 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 });
}
},
}));

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { safeJsonParse } from '@/lib/utils'; import { createUserStorage } from './utils/userStorage';
interface PageColumnSettings { interface PageColumnSettings {
columnWidths: Record<string, number>; columnWidths: Record<string, number>;
@@ -15,18 +15,6 @@ interface TableColumnState {
getPageSettings: (pageId: string) => PageColumnSettings; getPageSettings: (pageId: string) => PageColumnSettings;
} }
function getUserId(): string {
if (typeof window === 'undefined') return 'default';
const userStr = localStorage.getItem('user');
if (!userStr) return 'default';
const user = safeJsonParse<Record<string, unknown> | null>(userStr, null);
return user?.id ? String(user.id) : 'default';
}
function getStorageKey(): string {
return `sam-table-columns-${getUserId()}`;
}
const DEFAULT_PAGE_SETTINGS: PageColumnSettings = { const DEFAULT_PAGE_SETTINGS: PageColumnSettings = {
columnWidths: {}, columnWidths: {},
hiddenColumns: [], hiddenColumns: [],
@@ -77,25 +65,7 @@ export const useTableColumnStore = create<TableColumnState>()(
}), }),
{ {
name: 'sam-table-columns', name: 'sam-table-columns',
storage: { storage: createUserStorage('sam-table-columns'),
getItem: (name) => {
const key = getStorageKey();
const str = localStorage.getItem(key);
if (!str) {
const fallback = localStorage.getItem(name);
return fallback ? JSON.parse(fallback) : null;
}
return JSON.parse(str);
},
setItem: (name, value) => {
const key = getStorageKey();
localStorage.setItem(key, JSON.stringify(value));
},
removeItem: (name) => {
const key = getStorageKey();
localStorage.removeItem(key);
},
},
} }
) )
); );

30
src/stores/useUIStore.ts Normal file
View 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: '' } }),
}));

View 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);
},
};
}