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:
189
src/components/organisms/LineItemsTable/LineItemsTable.tsx
Normal file
189
src/components/organisms/LineItemsTable/LineItemsTable.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공통 품목 테이블 컴포넌트
|
||||
* 매출/매입/세금계산서 등의 품목 입력 테이블에서 공유
|
||||
*
|
||||
* 기본 컬럼: #, 품목명, 수량, 단가, 공급가액, 부가세, 적요, 삭제
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import type { LineItemTotals } from './types';
|
||||
|
||||
export interface LineItemsTableProps<T extends { id: string }> {
|
||||
items: T[];
|
||||
/** 품목명 가져오기 */
|
||||
getItemName: (item: T) => string;
|
||||
/** 수량 가져오기 */
|
||||
getQuantity: (item: T) => number;
|
||||
/** 단가 가져오기 */
|
||||
getUnitPrice: (item: T) => number;
|
||||
/** 공급가액 가져오기 */
|
||||
getSupplyAmount: (item: T) => number;
|
||||
/** 부가세 가져오기 */
|
||||
getVat: (item: T) => number;
|
||||
/** 적요 가져오기 */
|
||||
getNote?: (item: T) => string;
|
||||
/** 필드 변경 핸들러 */
|
||||
onItemChange: (index: number, field: string, value: string | number) => void;
|
||||
/** 행 추가 */
|
||||
onAddItem: () => void;
|
||||
/** 행 삭제 */
|
||||
onRemoveItem: (index: number) => void;
|
||||
/** 합계 */
|
||||
totals: LineItemTotals;
|
||||
/** view 모드 여부 */
|
||||
isViewMode?: boolean;
|
||||
/** 최소 유지 행 수 (삭제 버튼 표시 기준, 기본 1) */
|
||||
minItems?: number;
|
||||
/** 추가 버튼 라벨 */
|
||||
addButtonLabel?: string;
|
||||
/** 적요 컬럼 표시 여부 (기본 true) */
|
||||
showNote?: boolean;
|
||||
/** 테이블 앞뒤에 추가 컬럼 렌더링 */
|
||||
renderExtraHeaders?: () => React.ReactNode;
|
||||
renderExtraCells?: (item: T, index: number) => React.ReactNode;
|
||||
renderExtraTotalCells?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export function LineItemsTable<T extends { id: string }>({
|
||||
items,
|
||||
getItemName,
|
||||
getQuantity,
|
||||
getUnitPrice,
|
||||
getSupplyAmount,
|
||||
getVat,
|
||||
getNote,
|
||||
onItemChange,
|
||||
onAddItem,
|
||||
onRemoveItem,
|
||||
totals,
|
||||
isViewMode = false,
|
||||
minItems = 1,
|
||||
addButtonLabel = '추가',
|
||||
showNote = true,
|
||||
renderExtraHeaders,
|
||||
renderExtraCells,
|
||||
renderExtraTotalCells,
|
||||
}: LineItemsTableProps<T>) {
|
||||
const noteColSpan = showNote ? 2 : 1; // 적요+삭제 or 삭제만
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[100px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[120px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[120px] text-right">공급가액</TableHead>
|
||||
<TableHead className="w-[100px] text-right">부가세</TableHead>
|
||||
{renderExtraHeaders?.()}
|
||||
{showNote && <TableHead>적요</TableHead>}
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={getItemName(item)}
|
||||
onChange={(e) => onItemChange(index, 'itemName', e.target.value)}
|
||||
placeholder="품목명"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<QuantityInput
|
||||
value={getQuantity(item)}
|
||||
onChange={(value) => onItemChange(index, 'quantity', value ?? 0)}
|
||||
disabled={isViewMode}
|
||||
min={1}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={getUnitPrice(item)}
|
||||
onChange={(value) => onItemChange(index, 'unitPrice', value ?? 0)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(getSupplyAmount(item))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(getVat(item))}
|
||||
</TableCell>
|
||||
{renderExtraCells?.(item, index)}
|
||||
{showNote && (
|
||||
<TableCell>
|
||||
<Input
|
||||
value={getNote?.(item) ?? ''}
|
||||
onChange={(e) => onItemChange(index, 'note', e.target.value)}
|
||||
placeholder="적요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{!isViewMode && items.length > minItems && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:text-red-700"
|
||||
onClick={() => onRemoveItem(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<TableRow className="bg-gray-50 font-medium">
|
||||
<TableCell colSpan={4} className="text-right">
|
||||
합계
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(totals.supplyAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(totals.vat)}
|
||||
</TableCell>
|
||||
{renderExtraTotalCells?.()}
|
||||
<TableCell colSpan={noteColSpan}></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 품목 추가 버튼 */}
|
||||
{!isViewMode && (
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" onClick={onAddItem}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{addButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
src/components/organisms/LineItemsTable/calculations.ts
Normal file
33
src/components/organisms/LineItemsTable/calculations.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 품목 테이블 금액 계산 유틸리티
|
||||
*/
|
||||
|
||||
const DEFAULT_TAX_RATE = 0.1;
|
||||
|
||||
/**
|
||||
* 공급가액 계산
|
||||
*/
|
||||
export function calcSupplyAmount(quantity: number, unitPrice: number): number {
|
||||
return quantity * unitPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부가세 계산 (공급가액의 10%, 소수점 내림)
|
||||
*/
|
||||
export function calcVat(supplyAmount: number, taxRate: number = DEFAULT_TAX_RATE): number {
|
||||
return Math.floor(supplyAmount * taxRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수량 또는 단가 변경 시 공급가액 + 부가세 자동 재계산
|
||||
* @returns { supplyAmount, vat } 계산된 값
|
||||
*/
|
||||
export function recalculate(
|
||||
quantity: number,
|
||||
unitPrice: number,
|
||||
taxRate: number = DEFAULT_TAX_RATE,
|
||||
): { supplyAmount: number; vat: number } {
|
||||
const supplyAmount = calcSupplyAmount(quantity, unitPrice);
|
||||
const vat = calcVat(supplyAmount, taxRate);
|
||||
return { supplyAmount, vat };
|
||||
}
|
||||
5
src/components/organisms/LineItemsTable/index.ts
Normal file
5
src/components/organisms/LineItemsTable/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { LineItemsTable } from './LineItemsTable';
|
||||
export type { LineItemsTableProps } from './LineItemsTable';
|
||||
export { useLineItems } from './useLineItems';
|
||||
export { calcSupplyAmount, calcVat, recalculate } from './calculations';
|
||||
export type { BaseLineItem, CalculatedLineItem, LineItemTotals } from './types';
|
||||
27
src/components/organisms/LineItemsTable/types.ts
Normal file
27
src/components/organisms/LineItemsTable/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* LineItemsTable 공통 타입 정의
|
||||
*/
|
||||
|
||||
/** 품목 테이블의 기본 행 타입 */
|
||||
export interface BaseLineItem {
|
||||
id: string;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** 공급가액+부가세 계산 결과가 포함된 행 타입 */
|
||||
export interface CalculatedLineItem extends BaseLineItem {
|
||||
/** 공급가액 (quantity × unitPrice) */
|
||||
supplyAmount: number;
|
||||
/** 부가세 (supplyAmount × taxRate) */
|
||||
vat: number;
|
||||
}
|
||||
|
||||
/** 합계 데이터 */
|
||||
export interface LineItemTotals {
|
||||
supplyAmount: number;
|
||||
vat: number;
|
||||
total: number;
|
||||
}
|
||||
88
src/components/organisms/LineItemsTable/useLineItems.ts
Normal file
88
src/components/organisms/LineItemsTable/useLineItems.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 품목 리스트 관리 훅
|
||||
* add / remove / update + 자동 계산 로직을 공통화
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { recalculate } from './calculations';
|
||||
import type { LineItemTotals } from './types';
|
||||
|
||||
interface UseLineItemsOptions<T> {
|
||||
items: T[];
|
||||
setItems: React.Dispatch<React.SetStateAction<T[]>>;
|
||||
createEmptyItem: () => T;
|
||||
/** 공급가액 필드 키 (기본 'supplyAmount') */
|
||||
supplyKey?: keyof T;
|
||||
/** 부가세 필드 키 (기본 'vat') */
|
||||
vatKey?: keyof T;
|
||||
/** 세율 (기본 0.1) */
|
||||
taxRate?: number;
|
||||
/** 최소 유지 행 수 (기본 1) */
|
||||
minItems?: number;
|
||||
}
|
||||
|
||||
export function useLineItems<T extends { id: string; quantity: number; unitPrice: number }>({
|
||||
items,
|
||||
setItems,
|
||||
createEmptyItem,
|
||||
supplyKey = 'supplyAmount' as keyof T,
|
||||
vatKey = 'vat' as keyof T,
|
||||
taxRate = 0.1,
|
||||
minItems = 1,
|
||||
}: UseLineItemsOptions<T>) {
|
||||
const handleItemChange = useCallback(
|
||||
(index: number, field: string, value: string | number) => {
|
||||
setItems((prev) => {
|
||||
const newItems = [...prev];
|
||||
const item = { ...newItems[index] };
|
||||
|
||||
if (field === 'quantity' || field === 'unitPrice') {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
|
||||
(item as any)[field] = numValue;
|
||||
const qty = field === 'quantity' ? numValue : item.quantity;
|
||||
const price = field === 'unitPrice' ? numValue : item.unitPrice;
|
||||
const calc = recalculate(qty, price, taxRate);
|
||||
(item as any)[supplyKey] = calc.supplyAmount;
|
||||
(item as any)[vatKey] = calc.vat;
|
||||
} else {
|
||||
(item as any)[field] = value;
|
||||
}
|
||||
|
||||
newItems[index] = item;
|
||||
return newItems;
|
||||
});
|
||||
},
|
||||
[setItems, supplyKey, vatKey, taxRate],
|
||||
);
|
||||
|
||||
const handleAddItem = useCallback(() => {
|
||||
setItems((prev) => [...prev, createEmptyItem()]);
|
||||
}, [setItems, createEmptyItem]);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
(index: number) => {
|
||||
setItems((prev) => {
|
||||
if (prev.length <= minItems) return prev;
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
},
|
||||
[setItems, minItems],
|
||||
);
|
||||
|
||||
const totals: LineItemTotals = useMemo(() => {
|
||||
const totalSupply = items.reduce((sum, item) => sum + ((item as any)[supplyKey] ?? 0), 0);
|
||||
const totalVat = items.reduce((sum, item) => sum + ((item as any)[vatKey] ?? 0), 0);
|
||||
return {
|
||||
supplyAmount: totalSupply,
|
||||
vat: totalVat,
|
||||
total: totalSupply + totalVat,
|
||||
};
|
||||
}, [items, supplyKey, vatKey]);
|
||||
|
||||
return {
|
||||
handleItemChange,
|
||||
handleAddItem,
|
||||
handleRemoveItem,
|
||||
totals,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user