diff --git a/src/components/accounting/DepositManagement/DepositDetail.tsx b/src/components/accounting/DepositManagement/DepositDetail.tsx deleted file mode 100644 index d5bcae95..00000000 --- a/src/components/accounting/DepositManagement/DepositDetail.tsx +++ /dev/null @@ -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('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 = { - 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 ( - - {/* 페이지 헤더 */} - - - {/* 헤더 액션 버튼 */} -
- {/* view 모드: [목록] [삭제] [수정] */} - {isViewMode ? ( - <> - - - - - ) : ( - /* edit/new 모드: [취소] [저장/등록] */ - <> - - - - )} -
- - {/* 기본 정보 섹션 */} - - - 기본 정보 - - -
- {/* 입금일 */} -
- - -
- - {/* 입금계좌 */} -
- - -
- - {/* 입금자명 */} -
- - -
- - {/* 입금금액 */} -
- - -
- - {/* 적요 */} -
- - setNote(e.target.value)} - placeholder="적요를 입력해주세요" - disabled={isViewMode} - /> -
- - {/* 거래처 */} -
- - -
- - {/* 입금 유형 */} -
- - -
-
-
-
- - {/* ===== 삭제 확인 다이얼로그 ===== */} - -
- ); -} diff --git a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx index 94f3e75a..07b9efea 100644 --- a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx +++ b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx @@ -9,8 +9,6 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Badge } from '@/components/ui/badge'; import { getPresetStyle } from '@/lib/utils/status-config'; -import { QuantityInput } from '@/components/ui/quantity-input'; -import { CurrencyInput } from '@/components/ui/currency-input'; import { Select, SelectContent, @@ -18,17 +16,10 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { FileText, Plus, X, Eye } from 'lucide-react'; +import { FileText, Eye } from 'lucide-react'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable'; import { purchaseConfig } from './purchaseConfig'; import { DocumentDetailModal } from '@/components/approval/DocumentDetail'; import type { ProposalDocumentData, ExpenseReportDocumentData } from '@/components/approval/DocumentDetail/types'; @@ -95,6 +86,16 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { // ===== 다이얼로그 상태 ===== const [documentModalOpen, setDocumentModalOpen] = useState(false); + // ===== 품목 관리 (공통 훅) ===== + const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems({ + items, + setItems, + createEmptyItem, + supplyKey: 'supplyPrice', + vatKey: 'vat', + minItems: 1, + }); + // ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) ===== useEffect(() => { async function loadInitialData() { @@ -138,17 +139,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { loadInitialData(); }, [purchaseId, mode, isNewMode]); - // ===== 합계 계산 ===== - const totals = useMemo(() => { - const totalSupply = items.reduce((sum, item) => sum + item.supplyPrice, 0); - const totalVat = items.reduce((sum, item) => sum + item.vat, 0); - return { - supplyAmount: totalSupply, - vat: totalVat, - total: totalSupply + totalVat, - }; - }, [items]); - // ===== 핸들러 ===== const handleVendorChange = useCallback((clientId: string) => { const client = clients.find(c => c.id === clientId); @@ -158,37 +148,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { } }, [clients]); - const handleItemChange = useCallback((index: number, field: keyof PurchaseItem, value: string | number) => { - setItems(prev => { - const newItems = [...prev]; - const item = { ...newItems[index] }; - - if (field === 'quantity' || field === 'unitPrice') { - const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value; - item[field] = numValue; - // 자동 계산: 공급가액 = 수량 * 단가 - item.supplyPrice = item.quantity * item.unitPrice; - item.vat = Math.floor(item.supplyPrice * 0.1); - } else { - (item as any)[field] = value; - } - - newItems[index] = item; - return newItems; - }); - }, []); - - const handleAddItem = useCallback(() => { - setItems(prev => [...prev, createEmptyItem()]); - }, []); - - const handleRemoveItem = useCallback((index: number) => { - setItems(prev => { - if (prev.length <= 1) return prev; - return prev.filter((_, i) => i !== index); - }); - }, []); - // ===== 저장 (IntegratedDetailTemplate 호환) ===== const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => { if (!vendorId) { @@ -435,101 +394,20 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { 품목 정보 -
- - - - # - 품목명 - 수량 - 단가 - 공급가액 - 부가세 - 적요 - - - - - {items.map((item, index) => ( - - {index + 1} - - handleItemChange(index, 'itemName', e.target.value)} - placeholder="품목명" - disabled={isViewMode} - /> - - - handleItemChange(index, 'quantity', value ?? 0)} - disabled={isViewMode} - min={1} - /> - - - handleItemChange(index, 'unitPrice', value ?? 0)} - disabled={isViewMode} - /> - - - {formatAmount(item.supplyPrice)} - - - {formatAmount(item.vat)} - - - handleItemChange(index, 'note', e.target.value)} - placeholder="적요" - disabled={isViewMode} - /> - - - {!isViewMode && items.length > 1 && ( - - )} - - - ))} - {/* 합계 행 */} - - - 합계 - - - {formatAmount(totals.supplyAmount)} - - - {formatAmount(totals.vat)} - - - - -
-
- - {/* 품목 추가 버튼 */} - {!isViewMode && ( -
- -
- )} + + items={items} + getItemName={(item) => item.itemName} + getQuantity={(item) => item.quantity} + getUnitPrice={(item) => item.unitPrice} + getSupplyAmount={(item) => item.supplyPrice} + getVat={(item) => item.vat} + getNote={(item) => item.note ?? ''} + onItemChange={handleItemChange} + onAddItem={handleAddItem} + onRemoveItem={handleRemoveItem} + totals={totals} + isViewMode={isViewMode} + />
@@ -691,4 +569,4 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { renderForm={() => renderFormContent()} /> ); -} \ No newline at end of file +} diff --git a/src/components/accounting/SalesManagement/SalesDetail.tsx b/src/components/accounting/SalesManagement/SalesDetail.tsx index 3f0bc073..1185f8e1 100644 --- a/src/components/accounting/SalesManagement/SalesDetail.tsx +++ b/src/components/accounting/SalesManagement/SalesDetail.tsx @@ -3,8 +3,6 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import { format } from 'date-fns'; import { - Plus, - X, Send, FileText, } from 'lucide-react'; @@ -14,8 +12,6 @@ import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; -import { QuantityInput } from '@/components/ui/quantity-input'; -import { CurrencyInput } from '@/components/ui/currency-input'; import { Select, SelectContent, @@ -23,14 +19,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; import { AlertDialog, AlertDialogAction, @@ -42,13 +30,13 @@ import { } from '@/components/ui/alert-dialog'; // 삭제 다이얼로그는 IntegratedDetailTemplate이 처리함 import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable'; import { salesConfig } from './salesConfig'; import type { SalesRecord, SalesItem, SalesType } from './types'; import { SALES_TYPE_OPTIONS } from './types'; import { getSaleById, createSale, updateSale, deleteSale } from './actions'; import { toast } from 'sonner'; import { getClients } from '../VendorManagement/actions'; -import { formatNumber as formatAmount } from '@/lib/utils/amount'; // ===== Props ===== interface SalesDetailProps { @@ -100,6 +88,16 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { const [showEmailAlert, setShowEmailAlert] = useState(false); const [emailAlertMessage, setEmailAlertMessage] = useState(''); + // ===== 품목 관리 (공통 훅) ===== + const { handleItemChange, handleAddItem, handleRemoveItem, totals } = useLineItems({ + items, + setItems, + createEmptyItem, + supplyKey: 'supplyAmount', + vatKey: 'vat', + minItems: 1, + }); + // ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) ===== useEffect(() => { async function loadInitialData() { @@ -148,51 +146,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { return clients.find(v => v.id === vendorId); }, [clients, vendorId]); - // ===== 합계 계산 ===== - const totals = useMemo(() => { - const totalSupply = items.reduce((sum, item) => sum + item.supplyAmount, 0); - const totalVat = items.reduce((sum, item) => sum + item.vat, 0); - return { - supplyAmount: totalSupply, - vat: totalVat, - total: totalSupply + totalVat, - }; - }, [items]); - - // ===== 품목 수정 핸들러 ===== - const handleItemChange = useCallback((index: number, field: keyof SalesItem, value: string | number) => { - setItems(prev => { - const newItems = [...prev]; - const item = { ...newItems[index] }; - - if (field === 'quantity' || field === 'unitPrice') { - const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value; - item[field] = numValue; - // 자동 계산 - item.supplyAmount = item.quantity * item.unitPrice; - item.vat = Math.floor(item.supplyAmount * 0.1); - } else { - (item as any)[field] = value; - } - - newItems[index] = item; - return newItems; - }); - }, []); - - // ===== 품목 추가 ===== - const handleAddItem = useCallback(() => { - setItems(prev => [...prev, createEmptyItem()]); - }, []); - - // ===== 품목 삭제 ===== - const handleRemoveItem = useCallback((index: number) => { - setItems(prev => { - if (prev.length <= 1) return prev; // 최소 1개 유지 - return prev.filter((_, i) => i !== index); - }); - }, []); - // ===== 저장 (IntegratedDetailTemplate 호환) ===== const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => { if (!vendorId) { @@ -341,101 +294,20 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { 품목 정보 -
- - - - # - 품목명 - 수량 - 단가 - 공급가액 - 부가세 - 적요 - - - - - {items.map((item, index) => ( - - {index + 1} - - handleItemChange(index, 'itemName', e.target.value)} - placeholder="품목명" - disabled={isViewMode} - /> - - - handleItemChange(index, 'quantity', value ?? 0)} - disabled={isViewMode} - min={1} - /> - - - handleItemChange(index, 'unitPrice', value ?? 0)} - disabled={isViewMode} - /> - - - {formatAmount(item.supplyAmount)} - - - {formatAmount(item.vat)} - - - handleItemChange(index, 'note', e.target.value)} - placeholder="적요" - disabled={isViewMode} - /> - - - {!isViewMode && items.length > 1 && ( - - )} - - - ))} - {/* 합계 행 */} - - - 합계 - - - {formatAmount(totals.supplyAmount)} - - - {formatAmount(totals.vat)} - - - - -
-
- - {/* 품목 추가 버튼 */} - {!isViewMode && ( -
- -
- )} + + items={items} + getItemName={(item) => item.itemName} + getQuantity={(item) => item.quantity} + getUnitPrice={(item) => item.unitPrice} + getSupplyAmount={(item) => item.supplyAmount} + getVat={(item) => item.vat} + getNote={(item) => item.note} + onItemChange={handleItemChange} + onAddItem={handleAddItem} + onRemoveItem={handleRemoveItem} + totals={totals} + isViewMode={isViewMode} + />
@@ -572,4 +444,4 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { renderForm={() => renderFormContent()} /> ); -} \ No newline at end of file +} diff --git a/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx b/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx deleted file mode 100644 index 18870f44..00000000 --- a/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx +++ /dev/null @@ -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('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 = { - 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 ( - - {/* 페이지 헤더 */} - - - {/* 헤더 액션 버튼 */} -
- {/* view 모드: [목록] [삭제] [수정] */} - {isViewMode ? ( - <> - - - - - ) : ( - /* edit/new 모드: [취소] [저장/등록] */ - <> - - - - )} -
- - {/* 기본 정보 섹션 */} - - - 기본 정보 - - -
- {/* 출금일 */} -
- - -
- - {/* 출금계좌 */} -
- - -
- - {/* 수취인명 */} -
- - -
- - {/* 출금금액 */} -
- - -
- - {/* 적요 */} -
- - setNote(e.target.value)} - placeholder="적요를 입력해주세요" - disabled={isViewMode} - /> -
- - {/* 거래처 */} -
- - -
- - {/* 출금 유형 */} -
- - -
-
-
-
- - {/* ===== 삭제 확인 다이얼로그 ===== */} - -
- ); -} diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 5693413f..a7588083 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -23,12 +23,13 @@ import { SalesStatusSection, PurchaseStatusSection, DailyProductionSection, + ShipmentSection, UnshippedSection, ConstructionSection, DailyAttendanceSection, } from './sections'; -import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig } from './types'; -import { DEFAULT_DASHBOARD_SETTINGS } from './types'; +import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailModalConfig, SectionKey } from './types'; +import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } from './types'; import { ScheduleDetailModal, DetailModal } from './modals'; import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog'; import { mockData } from './mockData'; @@ -138,7 +139,34 @@ export function CEODashboard() { const saved = localStorage.getItem('ceo-dashboard-settings'); if (saved) { try { - setDashboardSettings(JSON.parse(saved)); + const parsed = JSON.parse(saved); + // 구버전 설정 마이그레이션: object → boolean 변환 + if (typeof parsed.receivable === 'object' && parsed.receivable !== null) { + parsed.receivable = parsed.receivable.enabled ?? true; + } + if (typeof parsed.salesStatus === 'object' && parsed.salesStatus !== null) { + parsed.salesStatus = parsed.salesStatus.enabled ?? true; + } + if (typeof parsed.purchaseStatus === 'object' && parsed.purchaseStatus !== null) { + parsed.purchaseStatus = parsed.purchaseStatus.enabled ?? true; + } + // sectionOrder 마이그레이션 + if (!parsed.sectionOrder) { + parsed.sectionOrder = DEFAULT_SECTION_ORDER; + } else { + const currentOrder: SectionKey[] = parsed.sectionOrder; + // 새 섹션이 추가됐으면 끝에 추가 + for (const key of DEFAULT_SECTION_ORDER) { + if (!currentOrder.includes(key)) { + currentOrder.push(key); + } + } + // 삭제된 섹션 필터링 + parsed.sectionOrder = currentOrder.filter((key: SectionKey) => + DEFAULT_SECTION_ORDER.includes(key) + ); + } + setDashboardSettings(parsed); } catch { // 파싱 실패 시 기본값 유지 } @@ -276,6 +304,187 @@ export function CEODashboard() { setSelectedSchedule(null); }, []); + // 섹션 순서 + const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; + + // 섹션 렌더링 함수 + const renderDashboardSection = (key: SectionKey): React.ReactNode => { + switch (key) { + case 'todayIssueList': + if (!dashboardSettings.todayIssueList) return null; + return ( + + + + ); + + case 'dailyReport': + if (!dashboardSettings.dailyReport) return null; + return ( + + + + ); + + case 'statusBoard': + if (!(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled)) return null; + return ( + + + + ); + + case 'monthlyExpense': + if (!dashboardSettings.monthlyExpense) return null; + return ( + + + + ); + + case 'cardManagement': + if (!dashboardSettings.cardManagement) return null; + return ( + + + + ); + + case 'entertainment': + if (!dashboardSettings.entertainment.enabled) return null; + return ( + + + + ); + + case 'welfare': + if (!dashboardSettings.welfare.enabled) return null; + return ( + + + + ); + + case 'receivable': + if (!dashboardSettings.receivable) return null; + return ( + + + + ); + + case 'debtCollection': + if (!dashboardSettings.debtCollection) return null; + return ( + + + + ); + + case 'vat': + if (!dashboardSettings.vat) return null; + return ( + + + + ); + + case 'calendar': + if (!dashboardSettings.calendar) return null; + return ( + + + + ); + + case 'salesStatus': + if (!(dashboardSettings.salesStatus ?? true) || !data.salesStatus) return null; + return ( + + + + ); + + case 'purchaseStatus': + if (!(dashboardSettings.purchaseStatus ?? true) || !data.purchaseStatus) return null; + return ( + + + + ); + + case 'production': + if (!(dashboardSettings.production ?? true) || !data.dailyProduction) return null; + return ( + + + + ); + + case 'shipment': + if (!(dashboardSettings.shipment ?? true) || !data.dailyProduction) return null; + return ( + + + + ); + + case 'unshipped': + if (!(dashboardSettings.unshipped ?? true) || !data.unshipped) return null; + return ( + + + + ); + + case 'construction': + if (!(dashboardSettings.construction ?? true) || !data.constructionData) return null; + return ( + + + + ); + + case 'attendance': + if (!(dashboardSettings.attendance ?? true) || !data.dailyAttendance) return null; + return ( + + + + ); + + default: + return null; + } + }; + if (isLoading) { return ( @@ -312,158 +521,7 @@ export function CEODashboard() { />
- {/* 오늘의 이슈 (새 리스트 형태) */} - {dashboardSettings.todayIssueList && ( - - - - )} - - {/* 일일 일보 (Enhanced) */} - {dashboardSettings.dailyReport && ( - - - - )} - - {/* 현황판 (Enhanced - 아이콘 + 컬러 테마) */} - {(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && ( - - - - )} - - {/* 당월 예상 지출 내역 (Enhanced) */} - {dashboardSettings.monthlyExpense && ( - - - - )} - - {/* 카드/가지급금 관리 */} - {dashboardSettings.cardManagement && ( - - - - )} - - {/* 접대비 현황 */} - {dashboardSettings.entertainment.enabled && ( - - - - )} - - {/* 복리후생비 현황 */} - {dashboardSettings.welfare.enabled && ( - - - - )} - - {/* 미수금 현황 */} - {dashboardSettings.receivable.enabled && ( - - - - )} - - {/* 채권추심 현황 */} - {dashboardSettings.debtCollection && ( - - - - )} - - {/* 부가세 현황 */} - {dashboardSettings.vat && ( - - - - )} - - {/* 캘린더 */} - {dashboardSettings.calendar && ( - - - - )} - - {/* ===== 신규 섹션 (캘린더 하단) ===== */} - - {/* 매출 현황 */} - {(dashboardSettings.salesStatus?.enabled ?? true) && data.salesStatus && ( - - - - )} - - {/* 매입 현황 */} - {(dashboardSettings.purchaseStatus?.enabled ?? true) && data.purchaseStatus && ( - - - - )} - - {/* 생산 현황 */} - {(dashboardSettings.production ?? true) && data.dailyProduction && ( - - - - )} - - {/* 미출고 내역 */} - {(dashboardSettings.unshipped ?? true) && data.unshipped && ( - - - - )} - - {/* 시공 현황 */} - {(dashboardSettings.construction ?? true) && data.constructionData && ( - - - - )} - - {/* 근태 현황 */} - {(dashboardSettings.attendance ?? true) && data.dailyAttendance && ( - - - - )} + {sectionOrder.map(renderDashboardSection)}
{/* 일정 상세 모달 */} diff --git a/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx b/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx index f162942c..85ddff71 100644 --- a/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx +++ b/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx @@ -1,10 +1,8 @@ 'use client'; import { useState, useCallback, useEffect } from 'react'; -import { ChevronDown, ChevronUp } from 'lucide-react'; +import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; -import { Input } from '@/components/ui/input'; import { CurrencyInput } from '@/components/ui/currency-input'; import { NumberInput } from '@/components/ui/number-input'; import { @@ -34,8 +32,9 @@ import type { CompanyType, WelfareLimitType, WelfareCalculationType, + SectionKey, } from '../types'; -import { DEFAULT_DASHBOARD_SETTINGS } from '../types'; +import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types'; // 현황판 항목 라벨 (구 오늘의 이슈) const STATUS_BOARD_LABELS: Record = { @@ -68,10 +67,17 @@ export function DashboardSettingsDialog({ const [expandedSections, setExpandedSections] = useState>({ entertainment: false, welfare: false, - receivable: false, + statusBoard: false, companyTypeInfo: false, }); + // DnD 상태 + const [draggedSection, setDraggedSection] = useState(null); + const [dragOverSection, setDragOverSection] = useState(null); + + // 섹션 순서 + const sectionOrder = localSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; + // settings가 변경될 때 로컬 상태 업데이트 useEffect(() => { setLocalSettings(settings); @@ -101,7 +107,6 @@ export function DashboardSettingsDialog({ ...prev, statusBoard: { enabled, - // 전체 OFF 시 개별 항목도 모두 OFF items: enabled ? statusBoardItems : Object.keys(statusBoardItems).reduce( @@ -109,7 +114,6 @@ export function DashboardSettingsDialog({ {} as TodayIssueSettings ), }, - // Legacy 호환성 유지 todayIssue: { enabled, items: enabled @@ -139,7 +143,6 @@ export function DashboardSettingsDialog({ enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled, items: newItems, }, - // Legacy 호환성 유지 todayIssue: { ...prev.todayIssue, items: newItems, @@ -175,29 +178,12 @@ export function DashboardSettingsDialog({ [] ); - // 매출 현황 설정 변경 - const handleSalesStatusChange = useCallback( - (key: 'enabled' | 'dailySalesDetail', value: boolean) => { + // 매출/매입/미수금 현황 토글 (단순 boolean) + const handleSimpleSectionToggle = useCallback( + (section: 'salesStatus' | 'purchaseStatus' | 'receivable', enabled: boolean) => { setLocalSettings((prev) => ({ ...prev, - salesStatus: { - ...prev.salesStatus, - [key]: value, - }, - })); - }, - [] - ); - - // 매입 현황 설정 변경 - const handlePurchaseStatusChange = useCallback( - (key: 'enabled' | 'dailyPurchaseDetail', value: boolean) => { - setLocalSettings((prev) => ({ - ...prev, - purchaseStatus: { - ...prev.purchaseStatus, - [key]: value, - }, + [section]: enabled, })); }, [] @@ -237,19 +223,18 @@ export function DashboardSettingsDialog({ [] ); - // 미수금 설정 변경 - const handleReceivableChange = useCallback( - (key: 'enabled' | 'topCompanies', value: boolean) => { - setLocalSettings((prev) => ({ - ...prev, - receivable: { - ...prev.receivable, - [key]: value, - }, - })); - }, - [] - ); + // 섹션 순서 변경 (DnD) + const handleReorder = useCallback((sourceKey: SectionKey, targetKey: SectionKey) => { + setLocalSettings((prev) => { + const order = [...(prev.sectionOrder ?? DEFAULT_SECTION_ORDER)]; + const sourceIdx = order.indexOf(sourceKey); + const targetIdx = order.indexOf(targetKey); + if (sourceIdx === -1 || targetIdx === -1) return prev; + order.splice(sourceIdx, 1); + order.splice(targetIdx, 0, sourceKey); + return { ...prev, sectionOrder: order }; + }); + }, []); // 저장 const handleSave = useCallback(() => { @@ -259,7 +244,7 @@ export function DashboardSettingsDialog({ // 취소 const handleCancel = useCallback(() => { - setLocalSettings(settings); // 원래 설정으로 복원 + setLocalSettings(settings); onClose(); }, [settings, onClose]); @@ -297,6 +282,7 @@ export function DashboardSettingsDialog({ isExpanded, onToggleExpand, children, + showGrip, }: { label: string; checked: boolean; @@ -305,6 +291,7 @@ export function DashboardSettingsDialog({ isExpanded?: boolean; onToggleExpand?: () => void; children?: React.ReactNode; + showGrip?: boolean; }) => (
+ {showGrip && ( + + )} {hasExpand && ( + * + * ); + * } + * + * @example + * // .print-area 기반 사용 (DocumentWrapper의 printArea prop 활용) + * function MyDocument() { + * const { handlePrintArea } = usePrintHandler({ title: '검사 성적서' }); + * return ( + * <> + * ... + * + * + * ); + * } + */ +export function usePrintHandler(options: UsePrintHandlerOptions = {}): UsePrintHandlerReturn { + const { title = '문서 인쇄', styles } = options; + const printRef = useRef(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 }; +} diff --git a/src/components/document-system/index.ts b/src/components/document-system/index.ts index 96923f04..af9f2c6a 100644 --- a/src/components/document-system/index.ts +++ b/src/components/document-system/index.ts @@ -5,6 +5,9 @@ export { DocumentViewer } from './viewer'; export { ApprovalLine, DocumentHeader, + DocumentWrapper, + DocumentTable, + DOC_STYLES, SectionHeader, InfoTable, QualityApprovalTable, @@ -15,6 +18,7 @@ export { // Hooks export { useZoom, useDrag } from './viewer/hooks'; +export { usePrintHandler } from './hooks/usePrintHandler'; // Presets export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets'; @@ -26,6 +30,8 @@ export type { ApprovalLineProps, DocumentHeaderLogo, DocumentHeaderProps, + DocumentWrapperProps, + DocumentTableProps, SectionHeaderProps, InfoTableCell, InfoTableProps, diff --git a/src/components/orders/documents/PurchaseOrderDocument.tsx b/src/components/orders/documents/PurchaseOrderDocument.tsx index 765c2cb8..14d0b1da 100644 --- a/src/components/orders/documents/PurchaseOrderDocument.tsx +++ b/src/components/orders/documents/PurchaseOrderDocument.tsx @@ -2,12 +2,13 @@ /** * 발주서 문서 컴포넌트 - * - 스크린샷 형식 + 지출결의서 디자인 스타일 + * - DocumentWrapper + DocumentTable building block 활용 */ import { getTodayString } from "@/lib/utils/date"; import { OrderItem } from "../actions"; import { formatNumber } from '@/lib/utils/amount'; +import { DocumentWrapper, DocumentTable, DOC_STYLES } from '@/components/document-system'; /** * 수량 포맷 함수 @@ -61,7 +62,7 @@ export function PurchaseOrderDocument({ remarks, }: PurchaseOrderDocumentProps) { return ( -
+ {/* 헤더: 제목 + 로트번호/결재란 */}

발 주 서

@@ -98,101 +99,95 @@ export function PurchaseOrderDocument({
{/* 신청업체 */} -
- - - - - - - - - - - - - - - - - - - - - - - -
- 신청업체 - 발주처{client}발주일{orderDate}
담당자{manager}연락처{managerContact}
FAX-설치개소
(동)
{installationCount}개소
-
+ + + + + 신청업체 + + 발주처 + {client} + 발주일 + {orderDate} + + + 담당자 + {manager} + 연락처 + {managerContact} + + + + FAX + - + 설치개소
(동) + {installationCount}개소 + + +
{/* 신청내용 */} -
- - - - - - - - - - - - - - - - - - - - -
- 신청내용 - 현장명{siteName}
납기요청
{deliveryRequestDate}배송방법{deliveryMethod}
출고일{expectedShipDate}납품주소{address}
-
+ + + + + 신청내용 + + 현장명 + {siteName} + + + 납기요청
일 + {deliveryRequestDate} + 배송방법 + {deliveryMethod} + + + 출고일 + {expectedShipDate} + 납품주소 + {address} + + +
{/* 부자재 */}

■ 부자재

-
- - - - - - - - - - - - - {items.length > 0 ? ( - items.map((item, index) => ( - - - - - - - - - )) - ) : ( - - + + + + + + + + + + + {items.length > 0 ? ( + items.map((item, index) => ( + + + + + + + - )} - -
구분품명규격길이(mm)수량비고
{index + 1}{item.itemName}{item.spec} - {item.width ? `${item.width}` : "-"} - {formatQuantity(item.quantity, item.unit)}{item.symbol || "-"}
- 등록된 품목이 없습니다 + +
구분품명규격길이(mm)수량비고
{index + 1}{item.itemName}{item.spec} + {item.width ? `${item.width}` : "-"} {formatQuantity(item.quantity, item.unit)}{item.symbol || "-"}
-
+ )) + ) : ( + + + 등록된 품목이 없습니다 + + + )} + +
{/* 특이사항 */} @@ -219,6 +214,6 @@ export function PurchaseOrderDocument({
문의: 홍길동 | 010-1234-5678
-
+ ); -} \ No newline at end of file +} diff --git a/src/components/organisms/LineItemsTable/LineItemsTable.tsx b/src/components/organisms/LineItemsTable/LineItemsTable.tsx new file mode 100644 index 00000000..8fdb2916 --- /dev/null +++ b/src/components/organisms/LineItemsTable/LineItemsTable.tsx @@ -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 { + 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({ + items, + getItemName, + getQuantity, + getUnitPrice, + getSupplyAmount, + getVat, + getNote, + onItemChange, + onAddItem, + onRemoveItem, + totals, + isViewMode = false, + minItems = 1, + addButtonLabel = '추가', + showNote = true, + renderExtraHeaders, + renderExtraCells, + renderExtraTotalCells, +}: LineItemsTableProps) { + const noteColSpan = showNote ? 2 : 1; // 적요+삭제 or 삭제만 + + return ( + <> +
+ + + + # + 품목명 + 수량 + 단가 + 공급가액 + 부가세 + {renderExtraHeaders?.()} + {showNote && 적요} + + + + + {items.map((item, index) => ( + + {index + 1} + + onItemChange(index, 'itemName', e.target.value)} + placeholder="품목명" + disabled={isViewMode} + /> + + + onItemChange(index, 'quantity', value ?? 0)} + disabled={isViewMode} + min={1} + /> + + + onItemChange(index, 'unitPrice', value ?? 0)} + disabled={isViewMode} + /> + + + {formatAmount(getSupplyAmount(item))} + + + {formatAmount(getVat(item))} + + {renderExtraCells?.(item, index)} + {showNote && ( + + onItemChange(index, 'note', e.target.value)} + placeholder="적요" + disabled={isViewMode} + /> + + )} + + {!isViewMode && items.length > minItems && ( + + )} + + + ))} + {/* 합계 행 */} + + + 합계 + + + {formatAmount(totals.supplyAmount)} + + + {formatAmount(totals.vat)} + + {renderExtraTotalCells?.()} + + + +
+
+ + {/* 품목 추가 버튼 */} + {!isViewMode && ( +
+ +
+ )} + + ); +} diff --git a/src/components/organisms/LineItemsTable/calculations.ts b/src/components/organisms/LineItemsTable/calculations.ts new file mode 100644 index 00000000..a796f595 --- /dev/null +++ b/src/components/organisms/LineItemsTable/calculations.ts @@ -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 }; +} diff --git a/src/components/organisms/LineItemsTable/index.ts b/src/components/organisms/LineItemsTable/index.ts new file mode 100644 index 00000000..c3db5430 --- /dev/null +++ b/src/components/organisms/LineItemsTable/index.ts @@ -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'; diff --git a/src/components/organisms/LineItemsTable/types.ts b/src/components/organisms/LineItemsTable/types.ts new file mode 100644 index 00000000..33d9866d --- /dev/null +++ b/src/components/organisms/LineItemsTable/types.ts @@ -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; +} diff --git a/src/components/organisms/LineItemsTable/useLineItems.ts b/src/components/organisms/LineItemsTable/useLineItems.ts new file mode 100644 index 00000000..dcc4cdec --- /dev/null +++ b/src/components/organisms/LineItemsTable/useLineItems.ts @@ -0,0 +1,88 @@ +/** + * 품목 리스트 관리 훅 + * add / remove / update + 자동 계산 로직을 공통화 + */ + +import { useCallback, useMemo } from 'react'; +import { recalculate } from './calculations'; +import type { LineItemTotals } from './types'; + +interface UseLineItemsOptions { + items: T[]; + setItems: React.Dispatch>; + createEmptyItem: () => T; + /** 공급가액 필드 키 (기본 'supplyAmount') */ + supplyKey?: keyof T; + /** 부가세 필드 키 (기본 'vat') */ + vatKey?: keyof T; + /** 세율 (기본 0.1) */ + taxRate?: number; + /** 최소 유지 행 수 (기본 1) */ + minItems?: number; +} + +export function useLineItems({ + items, + setItems, + createEmptyItem, + supplyKey = 'supplyAmount' as keyof T, + vatKey = 'vat' as keyof T, + taxRate = 0.1, + minItems = 1, +}: UseLineItemsOptions) { + 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, + }; +} diff --git a/src/components/quality/InspectionManagement/documents/InspectionRequestDocument.tsx b/src/components/quality/InspectionManagement/documents/InspectionRequestDocument.tsx index 9a39667c..be4569d1 100644 --- a/src/components/quality/InspectionManagement/documents/InspectionRequestDocument.tsx +++ b/src/components/quality/InspectionManagement/documents/InspectionRequestDocument.tsx @@ -10,16 +10,28 @@ * - 검사대상 사전 고지 정보 테이블 */ -import { ConstructionApprovalTable } from '@/components/document-system'; +import { + ConstructionApprovalTable, + DocumentWrapper, + DocumentTable, + DOC_STYLES, +} from '@/components/document-system'; import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types'; interface InspectionRequestDocumentProps { data: InspectionRequestDocumentType; } +/** 라벨 셀 */ +const lbl = `${DOC_STYLES.label} w-28`; +/** 서브 라벨 셀 (bg-gray-50) */ +const subLbl = 'bg-gray-50 px-2 py-1 font-medium border-r border-gray-300 w-28'; +/** 값 셀 */ +const val = DOC_STYLES.value; + export function InspectionRequestDocument({ data }: InspectionRequestDocumentProps) { return ( -
+ {/* 헤더: 제목 (좌측) + 결재란 (우측) */}
@@ -51,47 +63,44 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
{/* 기본 정보 */} -
-
기본 정보
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
수주처{data.client || '-'}업체명{data.companyName || '-'}
담당자{data.manager || '-'}수주번호{data.orderNumber || '-'}
담당자 연락처{data.managerContact || '-'}현장명{data.siteName || '-'}
납품일{data.deliveryDate || '-'}현장 주소{data.siteAddress || '-'}
총 개소{data.totalLocations || '-'}접수일{data.receptionDate || '-'}
검사방문요청일{data.visitRequestDate || '-'}
-
+ + + + 수주처 + {data.client || '-'} + 업체명 + {data.companyName || '-'} + + + 담당자 + {data.manager || '-'} + 수주번호 + {data.orderNumber || '-'} + + + 담당자 연락처 + {data.managerContact || '-'} + 현장명 + {data.siteName || '-'} + + + 납품일 + {data.deliveryDate || '-'} + 현장 주소 + {data.siteAddress || '-'} + + + 총 개소 + {data.totalLocations || '-'} + 접수일 + {data.receptionDate || '-'} + + + 검사방문요청일 + {data.visitRequestDate || '-'} + + + {/* 입력사항: 4개 섹션 */}
@@ -103,12 +112,12 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro - - - - - - + + + + + +
현장명{data.constructionSite.siteName || '-'}대지위치{data.constructionSite.landLocation || '-'}지번{data.constructionSite.lotNumber || '-'}현장명{data.constructionSite.siteName || '-'}대지위치{data.constructionSite.landLocation || '-'}지번{data.constructionSite.lotNumber || '-'}
@@ -120,16 +129,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro - - - - + + + + - - - - + + + +
회사명{data.materialDistributor.companyName || '-'}회사주소{data.materialDistributor.companyAddress || '-'}회사명{data.materialDistributor.companyName || '-'}회사주소{data.materialDistributor.companyAddress || '-'}
대표자명{data.materialDistributor.representativeName || '-'}전화번호{data.materialDistributor.phone || '-'}대표자명{data.materialDistributor.representativeName || '-'}전화번호{data.materialDistributor.phone || '-'}
@@ -141,16 +150,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro - - - - + + + + - - - - + + + +
회사명{data.constructorInfo.companyName || '-'}회사주소{data.constructorInfo.companyAddress || '-'}회사명{data.constructorInfo.companyName || '-'}회사주소{data.constructorInfo.companyAddress || '-'}
성명{data.constructorInfo.name || '-'}전화번호{data.constructorInfo.phone || '-'}성명{data.constructorInfo.name || '-'}전화번호{data.constructorInfo.phone || '-'}
@@ -162,16 +171,16 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro - - - - + + + + - - - - + + + +
사무소명{data.supervisor.officeName || '-'}사무소주소{data.supervisor.officeAddress || '-'}사무소명{data.supervisor.officeName || '-'}사무소주소{data.supervisor.officeAddress || '-'}
성명{data.supervisor.name || '-'}전화번호{data.supervisor.phone || '-'}성명{data.supervisor.name || '-'}전화번호{data.supervisor.phone || '-'}
@@ -179,71 +188,71 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
{/* 검사 요청 시 필독 */} -
-
검사 요청 시 필독
-
-

- 발주 사이즈와 시공 완료된 사이즈가 다를 시, 일정 범위를 벗어날 경우 -
- 인정마크를 부착할 수 없습니다. 제품검사를 위한 방문 전 미리 -
- 변경사항을 고지해주셔야 인정마크를 부착할 수 있습니다. -

-

- (사전고지를 하지 않음으로 발생하는 문제의 귀책은 신청업체에 있습니다.) -

-
-
+ + + + +

+ 발주 사이즈와 시공 완료된 사이즈가 다를 시, 일정 범위를 벗어날 경우 +
+ 인정마크를 부착할 수 없습니다. 제품검사를 위한 방문 전 미리 +
+ 변경사항을 고지해주셔야 인정마크를 부착할 수 있습니다. +

+

+ (사전고지를 하지 않음으로 발생하는 문제의 귀책은 신청업체에 있습니다.) +

+ + + +
{/* 검사대상 사전 고지 정보 */} -
-
검사대상 사전 고지 정보
- - - {/* 1단: 오픈사이즈 병합 */} - - - - - - + + + {/* 1단: 오픈사이즈 병합 */} + + + + + + + + {/* 2단: 발주 규격, 시공후 규격 */} + + + + + {/* 3단: 가로, 세로 */} + + + + + + + + + {data.priorNoticeItems.map((item, index) => ( + + + + + + + + + - {/* 2단: 발주 규격, 시공후 규격 */} - - - + ))} + {data.priorNoticeItems.length === 0 && ( + + - {/* 3단: 가로, 세로 */} - - - - - - - - - {data.priorNoticeItems.map((item, index) => ( - - - - - - - - - - - ))} - {data.priorNoticeItems.length === 0 && ( - - - - )} - -
No.층수부호오픈사이즈변경사유
No.층수부호오픈사이즈변경사유
발주 규격시공후 규격
가로세로가로세로
{index + 1}{item.floor}{item.symbol}{item.orderWidth}{item.orderHeight}{item.constructionWidth}{item.constructionHeight}{item.changeReason || '-'}
발주 규격시공후 규격
+ 검사대상 사전 고지 정보가 없습니다. +
가로세로가로세로
{index + 1}{item.floor}{item.symbol}{item.orderWidth}{item.orderHeight}{item.constructionWidth}{item.constructionHeight}{item.changeReason || '-'}
- 검사대상 사전 고지 정보가 없습니다. -
-
+ )} + + {/* 서명 영역 */}
@@ -252,6 +261,6 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro

{data.createdDate}

-
+ ); } diff --git a/src/contexts/PermissionContext.tsx b/src/contexts/PermissionContext.tsx index 04ca50f0..1e8ad959 100644 --- a/src/contexts/PermissionContext.tsx +++ b/src/contexts/PermissionContext.tsx @@ -1,98 +1,27 @@ 'use client'; -import { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import { useEffect } from 'react'; import { usePathname } from 'next/navigation'; -import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions'; -import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils'; -import { ALL_DENIED_PERMS } from '@/lib/permissions/types'; +import { usePermissionStore } from '@/stores/permissionStore'; +import { findMatchingUrl } from '@/lib/permissions/utils'; import type { PermissionMap, PermissionAction } from '@/lib/permissions/types'; import { AccessDenied } from '@/components/common/AccessDenied'; import { stripLocalePrefix } from '@/lib/utils/locale'; -interface PermissionContextType { - permissionMap: PermissionMap | null; - isLoading: boolean; - /** URL 지정 권한 체크 (특수 케이스용) */ - can: (url: string, action: PermissionAction) => boolean; - /** 권한 데이터 다시 로드 (설정 변경 후 호출) */ - reloadPermissions: () => void; -} - -const PermissionContext = createContext({ - permissionMap: null, - isLoading: true, - can: () => true, - reloadPermissions: () => {}, -}); - +/** + * PermissionProvider — Zustand store 초기화 래퍼 + * + * 기존 Context.Provider 역할을 대체합니다. + * 마운트 시 한 번 loadPermissions() 호출만 담당합니다. + */ export function PermissionProvider({ children }: { children: React.ReactNode }) { - const [permissionMap, setPermissionMap] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const loadPermissions = usePermissionStore((s) => s.loadPermissions); - const loadPermissions = useCallback(async () => { - const userData = getUserData(); - if (!userData || userData.roleIds.length === 0) { - setIsLoading(false); - return; - } - - const { roleIds, menuIdToUrl } = userData; - setIsLoading(true); - try { - // 사이드바 메뉴에 없는 권한 메뉴의 URL 매핑 보완 - // (기준정보 관리, 공정관리 등 사이드바 미등록 메뉴 대응) - const [permMenuUrlMap, ...results] = await Promise.all([ - getPermissionMenuUrlMap(), - ...roleIds.map(id => getRolePermissionMatrix(id)), - ]); - - // 권한 메뉴 URL을 베이스로, 사이드바 메뉴 URL로 덮어쓰기 (사이드바 우선) - const mergedMenuIdToUrl = { ...permMenuUrlMap, ...menuIdToUrl }; - - const maps = results - .filter(r => r.success && r.data?.permissions) - .map(r => convertMatrixToPermissionMap(r.data.permissions, mergedMenuIdToUrl)); - - if (maps.length > 0) { - const merged = mergePermissionMaps(maps); - - // 권한 메뉴에 등록되어 있지만 매트릭스 응답에 없는 메뉴 처리 - // (모든 권한 OFF → API가 해당 menuId를 생략 → "all denied"로 보완) - for (const [, url] of Object.entries(permMenuUrlMap)) { - if (url && !merged[url]) { - merged[url] = { ...ALL_DENIED_PERMS }; - } - } - - setPermissionMap(merged); - } else { - setPermissionMap(null); - } - } catch (error) { - console.error('[Permission] 권한 로드 실패:', error); - setPermissionMap(null); - } - setIsLoading(false); - }, []); - - // 마운트 시 1회 로드 useEffect(() => { loadPermissions(); }, [loadPermissions]); - const can = useCallback((url: string, action: PermissionAction): boolean => { - if (!permissionMap) return true; - const matchedUrl = findMatchingUrl(url, permissionMap); - if (!matchedUrl) return true; - const perms = permissionMap[matchedUrl]; - return perms?.[action] ?? true; - }, [permissionMap]); - - return ( - - {children} - - ); + return <>{children}; } /** @@ -102,7 +31,7 @@ const BYPASS_PATHS = ['/settings/permissions']; function isGateBypassed(pathname: string): boolean { const pathWithoutLocale = stripLocalePrefix(pathname); - return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp)); + return BYPASS_PATHS.some((bp) => pathWithoutLocale.startsWith(bp)); } /** @@ -110,7 +39,8 @@ function isGateBypassed(pathname: string): boolean { */ export function PermissionGate({ children }: { children: React.ReactNode }) { const pathname = usePathname(); - const { permissionMap, isLoading } = useContext(PermissionContext); + const permissionMap = usePermissionStore((s) => s.permissionMap); + const isLoading = usePermissionStore((s) => s.isLoading); if (isLoading) return null; if (!permissionMap) { @@ -135,27 +65,21 @@ export function PermissionGate({ children }: { children: React.ReactNode }) { return <>{children}; } -/** localStorage 'user' 키에서 역할 ID 배열 + menuId→URL 매핑 추출 */ -function getUserData(): { roleIds: number[]; menuIdToUrl: Record } | null { - if (typeof window === 'undefined') return null; - try { - const raw = localStorage.getItem('user'); - if (!raw) return null; +/** + * 하위호환 훅 — 기존 usePermissionContext() 소비자를 위한 어댑터 + * + * Zustand store에서 읽되, 기존과 동일한 인터페이스를 반환합니다. + */ +export function usePermissionContext(): { + permissionMap: PermissionMap | null; + isLoading: boolean; + can: (url: string, action: PermissionAction) => boolean; + reloadPermissions: () => void; +} { + const permissionMap = usePermissionStore((s) => s.permissionMap); + const isLoading = usePermissionStore((s) => s.isLoading); + const can = usePermissionStore((s) => s.can); + const loadPermissions = usePermissionStore((s) => s.loadPermissions); - const parsed = JSON.parse(raw); - - const roleIds = Array.isArray(parsed.roles) - ? parsed.roles.map((r: { id: number }) => r.id).filter(Boolean) - : []; - - const menuIdToUrl = Array.isArray(parsed.menu) - ? buildMenuIdToUrlMap(parsed.menu) - : {}; - - return { roleIds, menuIdToUrl }; - } catch { - return null; - } + return { permissionMap, isLoading, can, reloadPermissions: loadPermissions }; } - -export const usePermissionContext = () => useContext(PermissionContext); diff --git a/src/hooks/usePermission.ts b/src/hooks/usePermission.ts index ba807be6..fcf4476f 100644 --- a/src/hooks/usePermission.ts +++ b/src/hooks/usePermission.ts @@ -1,6 +1,6 @@ import { usePathname } from 'next/navigation'; -import { usePermissionContext } from '@/contexts/PermissionContext'; +import { usePermissionStore } from '@/stores/permissionStore'; import { findMatchingUrl } from '@/lib/permissions/utils'; import { ALL_ALLOWED } from '@/lib/permissions/types'; import type { UsePermissionReturn } from '@/lib/permissions/types'; @@ -20,7 +20,8 @@ import type { UsePermissionReturn } from '@/lib/permissions/types'; */ export function usePermission(overrideUrl?: string): UsePermissionReturn { const pathname = usePathname(); - const { permissionMap, isLoading } = usePermissionContext(); + const permissionMap = usePermissionStore((s) => s.permissionMap); + const isLoading = usePermissionStore((s) => s.isLoading); const targetPath = overrideUrl || pathname; diff --git a/src/lib/api/dashboard/transformers.ts b/src/lib/api/dashboard/transformers.ts index 7d8ecfd9..b70639cd 100644 --- a/src/lib/api/dashboard/transformers.ts +++ b/src/lib/api/dashboard/transformers.ts @@ -1,1578 +1,14 @@ /** * CEO Dashboard API 응답 → Frontend 타입 변환 함수 + * Barrel export - 도메인별 분할 파일에서 re-export * * 참조: docs/plans/AI_리포트_키워드_색상체계_가이드_v1.4.md */ -import type { - DailyReportApiResponse, - ReceivablesApiResponse, - BadDebtApiResponse, - ExpectedExpenseApiResponse, - CardTransactionApiResponse, - StatusBoardApiResponse, - TodayIssueApiResponse, - CalendarApiResponse, - VatApiResponse, - EntertainmentApiResponse, - WelfareApiResponse, - WelfareDetailApiResponse, - PurchaseDashboardDetailApiResponse, - CardDashboardDetailApiResponse, - BillDashboardDetailApiResponse, - ExpectedExpenseDashboardDetailApiResponse, - LoanDashboardApiResponse, - TaxSimulationApiResponse, -} from './types'; - -import type { - DailyReportData, - ReceivableData, - DebtCollectionData, - MonthlyExpenseData, - CardManagementData, - TodayIssueItem, - TodayIssueListItem, - TodayIssueListBadgeType, - TodayIssueNotificationType, - CalendarScheduleItem, - CheckPoint, - CheckPointType, - VatData, - EntertainmentData, - WelfareData, - 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' - */ -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억원" - */ -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일 월요일" - */ -function formatDate(dateStr: string, dayOfWeek: string): string { - const date = new Date(dateStr); - return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일 ${dayOfWeek}`; -} - -/** - * 퍼센트 변화율 계산 - */ -function calculateChangeRate(current: number, previous: number): number { - if (previous === 0) return current > 0 ? 100 : 0; - return ((current - previous) / previous) * 100; -} - -// ============================================ -// 1. DailyReport 변환 -// ============================================ - -/** - * 운영자금 안정성에 따른 색상 반환 - * 참조: 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; -} - -/** - * 변동률 → changeRate/changeDirection 변환 헬퍼 - */ -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, - }; -} - -/** - * 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), - }; -} - -// ============================================ -// 2. 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'), - }; -} - -// ============================================ -// 3. 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 = { - 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'), - }; -} - -// ============================================ -// 4. 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), - }; -} - -// ============================================ -// 5. 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), - }; -} - -// ============================================ -// 6. 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 = { - 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, - })); -} - -// ============================================ -// 7. 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 = { - 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 = { - // === 백엔드 실제 값 (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, - }; -} - -// ============================================ -// 8. Calendar 변환 -// ============================================ - -/** - * 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, - }; -} - -// ============================================ -// 9. Vat 변환 -// ============================================ - -/** 유효한 하이라이트 색상 목록 */ -const VALID_HIGHLIGHT_COLORS: HighlightColor[] = ['red', 'green', 'blue']; - -/** - * API 색상 문자열 → Frontend 하이라이트 색상 변환 - * 유효하지 않은 색상은 'blue'로 폴백 - */ -function validateHighlightColor(color: string): HighlightColor { - if (VALID_HIGHLIGHT_COLORS.includes(color as HighlightColor)) { - return color as HighlightColor; - } - return 'blue'; -} - -/** - * 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), - })), - })), - }; -} - -// ============================================ -// 10. 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), - })), - })), - }; -} - -// ============================================ -// 11. 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), - })), - })), - }; -} - -// ============================================ -// 12. Welfare Detail 변환 -// ============================================ - -import type { DetailModalConfig } from '@/components/business/CEODashboard/types'; - -/** - * 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) || '', - }, - ], - }, - }; -} - -// ============================================ -// 13. 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', - }, - }; -} - -// ============================================ -// 14. 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', - }, - }; -} - -// ============================================ -// 15. 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', - }, - }; -} - -// ============================================ -// 16. Expected Expense Dashboard Detail 변환 (me1~me4 통합) -// ============================================ - -// cardId별 제목 및 차트 설정 매핑 -const EXPENSE_CARD_CONFIG: Record = { - 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; -} \ No newline at end of file +export { transformDailyReportResponse } from './transformers/daily-report'; +export { transformReceivableResponse, transformDebtCollectionResponse } from './transformers/receivable'; +export { transformMonthlyExpenseResponse, transformCardManagementResponse } from './transformers/expense'; +export { transformStatusBoardResponse, transformTodayIssueResponse } from './transformers/status-issue'; +export { transformCalendarResponse } from './transformers/calendar'; +export { transformVatResponse, transformEntertainmentResponse, transformWelfareResponse, transformWelfareDetailResponse } from './transformers/tax-benefits'; +export { transformPurchaseDetailResponse, transformCardDetailResponse, transformBillDetailResponse, transformExpectedExpenseDetailResponse } from './transformers/expense-detail'; diff --git a/src/lib/api/dashboard/transformers/calendar.ts b/src/lib/api/dashboard/transformers/calendar.ts new file mode 100644 index 00000000..fd25b346 --- /dev/null +++ b/src/lib/api/dashboard/transformers/calendar.ts @@ -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, + }; +} diff --git a/src/lib/api/dashboard/transformers/common.ts b/src/lib/api/dashboard/transformers/common.ts new file mode 100644 index 00000000..db20736f --- /dev/null +++ b/src/lib/api/dashboard/transformers/common.ts @@ -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'; +} diff --git a/src/lib/api/dashboard/transformers/daily-report.ts b/src/lib/api/dashboard/transformers/daily-report.ts new file mode 100644 index 00000000..d6d41c82 --- /dev/null +++ b/src/lib/api/dashboard/transformers/daily-report.ts @@ -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), + }; +} diff --git a/src/lib/api/dashboard/transformers/expense-detail.ts b/src/lib/api/dashboard/transformers/expense-detail.ts new file mode 100644 index 00000000..c9c33afd --- /dev/null +++ b/src/lib/api/dashboard/transformers/expense-detail.ts @@ -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 = { + 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; +} diff --git a/src/lib/api/dashboard/transformers/expense.ts b/src/lib/api/dashboard/transformers/expense.ts new file mode 100644 index 00000000..2d11580d --- /dev/null +++ b/src/lib/api/dashboard/transformers/expense.ts @@ -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), + }; +} diff --git a/src/lib/api/dashboard/transformers/receivable.ts b/src/lib/api/dashboard/transformers/receivable.ts new file mode 100644 index 00000000..0b8f726a --- /dev/null +++ b/src/lib/api/dashboard/transformers/receivable.ts @@ -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 = { + 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'), + }; +} diff --git a/src/lib/api/dashboard/transformers/status-issue.ts b/src/lib/api/dashboard/transformers/status-issue.ts new file mode 100644 index 00000000..7216ee6e --- /dev/null +++ b/src/lib/api/dashboard/transformers/status-issue.ts @@ -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 = { + 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 = { + 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 = { + // === 백엔드 실제 값 (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, + }; +} diff --git a/src/lib/api/dashboard/transformers/tax-benefits.ts b/src/lib/api/dashboard/transformers/tax-benefits.ts new file mode 100644 index 00000000..cbe76614 --- /dev/null +++ b/src/lib/api/dashboard/transformers/tax-benefits.ts @@ -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) || '', + }, + ], + }, + }; +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index a362c1c5..7980e228 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -152,35 +152,9 @@ export function parseAccountNumber(formatted: string): string { } /** - * 숫자 천단위 콤마 포맷팅 + * 숫자 천단위 콤마 포맷팅 — @/lib/utils/amount 통합 버전으로 위임 */ -export function formatNumber(value: number | string, options?: { - useComma?: boolean; - decimalPlaces?: number; - suffix?: string; -}): string { - const { useComma = true, decimalPlaces, suffix = '' } = options || {}; - - const num = typeof value === 'string' ? parseFloat(value) : value; - - if (isNaN(num)) return ''; - - let formatted: string; - - if (decimalPlaces !== undefined) { - formatted = num.toFixed(decimalPlaces); - } else { - formatted = String(num); - } - - if (useComma) { - const parts = formatted.split('.'); - parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); - formatted = parts.join('.'); - } - - return suffix ? `${formatted}${suffix}` : formatted; -} +export { formatNumber, type FormatNumberOptions } from '@/lib/utils/amount'; /** * 포맷된 숫자 문자열에서 숫자 추출 diff --git a/src/lib/utils/amount.ts b/src/lib/utils/amount.ts index 944c0095..8ff070f0 100644 --- a/src/lib/utils/amount.ts +++ b/src/lib/utils/amount.ts @@ -5,14 +5,56 @@ * 1만원 이상: "1,000만원" */ +/** + * formatNumber 옵션 (formatters.ts 호환) + */ +export interface FormatNumberOptions { + useComma?: boolean; + decimalPlaces?: number; + suffix?: string; +} + /** * 단순 숫자 포맷 (천단위 콤마만, 단위 없음) + * + * - 옵션 없이 호출: Intl.NumberFormat 사용, null/NaN → '-' + * - 옵션과 함께 호출: formatters.ts 호환, NaN → '' + * * @example formatNumber(1234567) // "1,234,567" * @example formatNumber(null) // "-" + * @example formatNumber(1234, { suffix: '원' }) // "1,234원" + * @example formatNumber('abc', { useComma: true }) // "" */ -export function formatNumber(value: number | null | undefined): string { - if (value == null || isNaN(value)) return '-'; - return new Intl.NumberFormat('ko-KR').format(value); +export function formatNumber( + value: number | string | null | undefined, + options?: FormatNumberOptions, +): string { + if (options) { + const { useComma = true, decimalPlaces, suffix = '' } = options; + const num = typeof value === 'string' ? parseFloat(value) : (value ?? NaN); + if (isNaN(num)) return ''; + + let formatted: string; + if (decimalPlaces !== undefined) { + formatted = num.toFixed(decimalPlaces); + } else { + formatted = String(num); + } + + if (useComma) { + const parts = formatted.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + formatted = parts.join('.'); + } + + return suffix ? `${formatted}${suffix}` : formatted; + } + + // 옵션 없는 호출: 기존 amount.ts 동작 유지 + if (value == null) return '-'; + const num = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(num)) return '-'; + return new Intl.NumberFormat('ko-KR').format(num); } export function formatAmount(amount: number): string { diff --git a/src/stores/favoritesStore.ts b/src/stores/favoritesStore.ts index a998c871..df47b455 100644 --- a/src/stores/favoritesStore.ts +++ b/src/stores/favoritesStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { safeJsonParse } from '@/lib/utils'; +import { createUserStorage } from './utils/userStorage'; export interface FavoriteItem { id: string; @@ -12,18 +12,6 @@ export interface FavoriteItem { export const MAX_FAVORITES = 10; -function getUserId(): string { - if (typeof window === 'undefined') return 'default'; - const userStr = localStorage.getItem('user'); - if (!userStr) return 'default'; - const user = safeJsonParse | null>(userStr, null); - return user?.id ? String(user.id) : 'default'; -} - -function getStorageKey(): string { - return `sam-favorites-${getUserId()}`; -} - interface FavoritesState { favorites: FavoriteItem[]; toggleFavorite: (item: FavoriteItem) => 'added' | 'removed' | 'max_reached'; @@ -68,27 +56,7 @@ export const useFavoritesStore = create()( }), { name: 'sam-favorites', - // 사용자별 키를 위해 storage 커스텀 - storage: { - getItem: (name) => { - const key = getStorageKey(); - const str = localStorage.getItem(key); - if (!str) { - // fallback: 기본 키에서도 확인 - const fallback = localStorage.getItem(name); - return fallback ? JSON.parse(fallback) : null; - } - return JSON.parse(str); - }, - setItem: (name, value) => { - const key = getStorageKey(); - localStorage.setItem(key, JSON.stringify(value)); - }, - removeItem: (name) => { - const key = getStorageKey(); - localStorage.removeItem(key); - }, - }, + storage: createUserStorage('sam-favorites'), } ) ); diff --git a/src/stores/permissionStore.ts b/src/stores/permissionStore.ts new file mode 100644 index 00000000..da0f3d4b --- /dev/null +++ b/src/stores/permissionStore.ts @@ -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; +} + +/** localStorage 'user' 키에서 역할 ID 배열 + menuId→URL 매핑 추출 */ +function getUserData(): { roleIds: number[]; menuIdToUrl: Record } | 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((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 }); + } + }, +})); diff --git a/src/stores/useTableColumnStore.ts b/src/stores/useTableColumnStore.ts index ba5e1917..7a744c11 100644 --- a/src/stores/useTableColumnStore.ts +++ b/src/stores/useTableColumnStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { safeJsonParse } from '@/lib/utils'; +import { createUserStorage } from './utils/userStorage'; interface PageColumnSettings { columnWidths: Record; @@ -15,18 +15,6 @@ interface TableColumnState { getPageSettings: (pageId: string) => PageColumnSettings; } -function getUserId(): string { - if (typeof window === 'undefined') return 'default'; - const userStr = localStorage.getItem('user'); - if (!userStr) return 'default'; - const user = safeJsonParse | null>(userStr, null); - return user?.id ? String(user.id) : 'default'; -} - -function getStorageKey(): string { - return `sam-table-columns-${getUserId()}`; -} - const DEFAULT_PAGE_SETTINGS: PageColumnSettings = { columnWidths: {}, hiddenColumns: [], @@ -77,25 +65,7 @@ export const useTableColumnStore = create()( }), { name: 'sam-table-columns', - storage: { - getItem: (name) => { - const key = getStorageKey(); - const str = localStorage.getItem(key); - if (!str) { - const fallback = localStorage.getItem(name); - return fallback ? JSON.parse(fallback) : null; - } - return JSON.parse(str); - }, - setItem: (name, value) => { - const key = getStorageKey(); - localStorage.setItem(key, JSON.stringify(value)); - }, - removeItem: (name) => { - const key = getStorageKey(); - localStorage.removeItem(key); - }, - }, + storage: createUserStorage('sam-table-columns'), } ) ); diff --git a/src/stores/useUIStore.ts b/src/stores/useUIStore.ts new file mode 100644 index 00000000..75c66b01 --- /dev/null +++ b/src/stores/useUIStore.ts @@ -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((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: '' } }), +})); diff --git a/src/stores/utils/userStorage.ts b/src/stores/utils/userStorage.ts new file mode 100644 index 00000000..ba077822 --- /dev/null +++ b/src/stores/utils/userStorage.ts @@ -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 | 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); + }, + }; +}