refactor(WEB): CEO 대시보드 대규모 개선 및 문서/권한/스토어 리팩토링

- CEO 대시보드: 섹션별 API 연동 강화 (매출/매입/생산 실데이터 표시)
- DashboardSettingsDialog 드래그 정렬 및 설정 UX 개선
- dashboard transformers 모듈 분리 (파일 분할)
- DocumentTable/DocumentWrapper 공통 문서 컴포넌트 추출
- LineItemsTable organisms 컴포넌트 추가
- PurchaseOrderDocument/InspectionRequestDocument 문서 컴포넌트 리팩토링
- PermissionContext → permissionStore(Zustand) 전환
- useUIStore, stores/utils/userStorage 추가
- favoritesStore/useTableColumnStore 사용자별 저장 지원
- DepositDetail/WithdrawalDetail 삭제 (통합)
- PurchaseDetail/SalesDetail 간소화
- amount.ts/formatters.ts 유틸 확장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-23 20:59:25 +09:00
parent 718be1cfdb
commit 8f4a7ee842
43 changed files with 3489 additions and 3463 deletions

View File

@@ -0,0 +1,189 @@
'use client';
/**
* 공통 품목 테이블 컴포넌트
* 매출/매입/세금계산서 등의 품목 입력 테이블에서 공유
*
* 기본 컬럼: #, 품목명, 수량, 단가, 공급가액, 부가세, 적요, 삭제
*/
import React from 'react';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { QuantityInput } from '@/components/ui/quantity-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import type { LineItemTotals } from './types';
export interface LineItemsTableProps<T extends { id: string }> {
items: T[];
/** 품목명 가져오기 */
getItemName: (item: T) => string;
/** 수량 가져오기 */
getQuantity: (item: T) => number;
/** 단가 가져오기 */
getUnitPrice: (item: T) => number;
/** 공급가액 가져오기 */
getSupplyAmount: (item: T) => number;
/** 부가세 가져오기 */
getVat: (item: T) => number;
/** 적요 가져오기 */
getNote?: (item: T) => string;
/** 필드 변경 핸들러 */
onItemChange: (index: number, field: string, value: string | number) => void;
/** 행 추가 */
onAddItem: () => void;
/** 행 삭제 */
onRemoveItem: (index: number) => void;
/** 합계 */
totals: LineItemTotals;
/** view 모드 여부 */
isViewMode?: boolean;
/** 최소 유지 행 수 (삭제 버튼 표시 기준, 기본 1) */
minItems?: number;
/** 추가 버튼 라벨 */
addButtonLabel?: string;
/** 적요 컬럼 표시 여부 (기본 true) */
showNote?: boolean;
/** 테이블 앞뒤에 추가 컬럼 렌더링 */
renderExtraHeaders?: () => React.ReactNode;
renderExtraCells?: (item: T, index: number) => React.ReactNode;
renderExtraTotalCells?: () => React.ReactNode;
}
export function LineItemsTable<T extends { id: string }>({
items,
getItemName,
getQuantity,
getUnitPrice,
getSupplyAmount,
getVat,
getNote,
onItemChange,
onAddItem,
onRemoveItem,
totals,
isViewMode = false,
minItems = 1,
addButtonLabel = '추가',
showNote = true,
renderExtraHeaders,
renderExtraCells,
renderExtraTotalCells,
}: LineItemsTableProps<T>) {
const noteColSpan = showNote ? 2 : 1; // 적요+삭제 or 삭제만
return (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
{renderExtraHeaders?.()}
{showNote && <TableHead></TableHead>}
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<Input
value={getItemName(item)}
onChange={(e) => onItemChange(index, 'itemName', e.target.value)}
placeholder="품목명"
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<QuantityInput
value={getQuantity(item)}
onChange={(value) => onItemChange(index, 'quantity', value ?? 0)}
disabled={isViewMode}
min={1}
/>
</TableCell>
<TableCell>
<CurrencyInput
value={getUnitPrice(item)}
onChange={(value) => onItemChange(index, 'unitPrice', value ?? 0)}
disabled={isViewMode}
/>
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(getSupplyAmount(item))}
</TableCell>
<TableCell className="text-right">
{formatAmount(getVat(item))}
</TableCell>
{renderExtraCells?.(item, index)}
{showNote && (
<TableCell>
<Input
value={getNote?.(item) ?? ''}
onChange={(e) => onItemChange(index, 'note', e.target.value)}
placeholder="적요"
disabled={isViewMode}
/>
</TableCell>
)}
<TableCell>
{!isViewMode && items.length > minItems && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700"
onClick={() => onRemoveItem(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
{/* 합계 행 */}
<TableRow className="bg-gray-50 font-medium">
<TableCell colSpan={4} className="text-right">
</TableCell>
<TableCell className="text-right">
{formatAmount(totals.supplyAmount)}
</TableCell>
<TableCell className="text-right">
{formatAmount(totals.vat)}
</TableCell>
{renderExtraTotalCells?.()}
<TableCell colSpan={noteColSpan}></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{/* 품목 추가 버튼 */}
{!isViewMode && (
<div className="mt-4">
<Button variant="outline" onClick={onAddItem}>
<Plus className="h-4 w-4 mr-2" />
{addButtonLabel}
</Button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,33 @@
/**
* 품목 테이블 금액 계산 유틸리티
*/
const DEFAULT_TAX_RATE = 0.1;
/**
* 공급가액 계산
*/
export function calcSupplyAmount(quantity: number, unitPrice: number): number {
return quantity * unitPrice;
}
/**
* 부가세 계산 (공급가액의 10%, 소수점 내림)
*/
export function calcVat(supplyAmount: number, taxRate: number = DEFAULT_TAX_RATE): number {
return Math.floor(supplyAmount * taxRate);
}
/**
* 수량 또는 단가 변경 시 공급가액 + 부가세 자동 재계산
* @returns { supplyAmount, vat } 계산된 값
*/
export function recalculate(
quantity: number,
unitPrice: number,
taxRate: number = DEFAULT_TAX_RATE,
): { supplyAmount: number; vat: number } {
const supplyAmount = calcSupplyAmount(quantity, unitPrice);
const vat = calcVat(supplyAmount, taxRate);
return { supplyAmount, vat };
}

View File

@@ -0,0 +1,5 @@
export { LineItemsTable } from './LineItemsTable';
export type { LineItemsTableProps } from './LineItemsTable';
export { useLineItems } from './useLineItems';
export { calcSupplyAmount, calcVat, recalculate } from './calculations';
export type { BaseLineItem, CalculatedLineItem, LineItemTotals } from './types';

View File

@@ -0,0 +1,27 @@
/**
* LineItemsTable 공통 타입 정의
*/
/** 품목 테이블의 기본 행 타입 */
export interface BaseLineItem {
id: string;
itemName: string;
quantity: number;
unitPrice: number;
note?: string;
}
/** 공급가액+부가세 계산 결과가 포함된 행 타입 */
export interface CalculatedLineItem extends BaseLineItem {
/** 공급가액 (quantity × unitPrice) */
supplyAmount: number;
/** 부가세 (supplyAmount × taxRate) */
vat: number;
}
/** 합계 데이터 */
export interface LineItemTotals {
supplyAmount: number;
vat: number;
total: number;
}

View File

@@ -0,0 +1,88 @@
/**
* 품목 리스트 관리 훅
* add / remove / update + 자동 계산 로직을 공통화
*/
import { useCallback, useMemo } from 'react';
import { recalculate } from './calculations';
import type { LineItemTotals } from './types';
interface UseLineItemsOptions<T> {
items: T[];
setItems: React.Dispatch<React.SetStateAction<T[]>>;
createEmptyItem: () => T;
/** 공급가액 필드 키 (기본 'supplyAmount') */
supplyKey?: keyof T;
/** 부가세 필드 키 (기본 'vat') */
vatKey?: keyof T;
/** 세율 (기본 0.1) */
taxRate?: number;
/** 최소 유지 행 수 (기본 1) */
minItems?: number;
}
export function useLineItems<T extends { id: string; quantity: number; unitPrice: number }>({
items,
setItems,
createEmptyItem,
supplyKey = 'supplyAmount' as keyof T,
vatKey = 'vat' as keyof T,
taxRate = 0.1,
minItems = 1,
}: UseLineItemsOptions<T>) {
const handleItemChange = useCallback(
(index: number, field: string, value: string | number) => {
setItems((prev) => {
const newItems = [...prev];
const item = { ...newItems[index] };
if (field === 'quantity' || field === 'unitPrice') {
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
(item as any)[field] = numValue;
const qty = field === 'quantity' ? numValue : item.quantity;
const price = field === 'unitPrice' ? numValue : item.unitPrice;
const calc = recalculate(qty, price, taxRate);
(item as any)[supplyKey] = calc.supplyAmount;
(item as any)[vatKey] = calc.vat;
} else {
(item as any)[field] = value;
}
newItems[index] = item;
return newItems;
});
},
[setItems, supplyKey, vatKey, taxRate],
);
const handleAddItem = useCallback(() => {
setItems((prev) => [...prev, createEmptyItem()]);
}, [setItems, createEmptyItem]);
const handleRemoveItem = useCallback(
(index: number) => {
setItems((prev) => {
if (prev.length <= minItems) return prev;
return prev.filter((_, i) => i !== index);
});
},
[setItems, minItems],
);
const totals: LineItemTotals = useMemo(() => {
const totalSupply = items.reduce((sum, item) => sum + ((item as any)[supplyKey] ?? 0), 0);
const totalVat = items.reduce((sum, item) => sum + ((item as any)[vatKey] ?? 0), 0);
return {
supplyAmount: totalSupply,
vat: totalVat,
total: totalSupply + totalVat,
};
}, [items, supplyKey, vatKey]);
return {
handleItemChange,
handleAddItem,
handleRemoveItem,
totals,
};
}