Files
sam-react-prod/src/components/pricing/PricingListClient.tsx
유병철 17c16028b1 feat(WEB): 리스트 페이지 권한 시스템 통합 및 중복 권한 로직 제거
- PermissionContext 기능 확장 (권한 조회 액션 추가)
- usePermission 훅 개선
- 회계 모듈 권한 통합: 매입/매출/입금/지출/채권/거래처/어음/일보/부실채권
- 인사 모듈 권한 통합: 근태/카드/급여 관리
- 전자결재 권한 통합: 기안함/결재함
- 게시판/품목/단가/팝업/구독 리스트 권한 적용
- UniversalListPage 권한 연동
- 각 컴포넌트 중복 권한 체크 코드 제거 (-828줄)
- 권한 검증 QA 체크리스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:46:48 +09:00

389 lines
13 KiB
TypeScript

/**
* 단가 목록 클라이언트 컴포넌트
*
* IntegratedListTemplateV2 공통 템플릿 활용
*/
'use client';
import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import {
DollarSign,
Package,
AlertCircle,
CheckCircle2,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
UniversalListPage,
type UniversalListConfig,
type TabOption,
type TableColumn,
type StatCard,
type SelectionHandlers,
type RowClickHandlers,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import type { PricingListItem, ItemType } from './types';
import { ITEM_TYPE_LABELS, ITEM_TYPE_COLORS } from './types';
interface PricingListClientProps {
initialData: PricingListItem[];
}
export function PricingListClient({
initialData,
}: PricingListClientProps) {
const router = useRouter();
const [data] = useState<PricingListItem[]>(initialData);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState('all');
const pageSize = 20;
// 탭 필터 함수 (UniversalListPage 내부에서 사용)
const tabFilter = (item: PricingListItem, tab: string) => {
if (tab === 'all') return true;
return item.itemType === tab;
};
// 검색 필터 함수 (UniversalListPage 내부에서 사용)
const searchFilter = (item: PricingListItem, search: string) => {
const searchLower = search.toLowerCase();
return (
(item.itemCode?.toLowerCase().includes(searchLower) ?? false) ||
(item.itemName?.toLowerCase().includes(searchLower) ?? false) ||
(item.specification?.toLowerCase().includes(searchLower) ?? false)
);
};
// 탭별 데이터 수 계산 (통계용)
const filteredData = useMemo(() => {
let result = [...data];
// 탭 필터
if (activeTab !== 'all') {
result = result.filter(item => item.itemType === activeTab);
}
// 검색 필터
if (searchTerm) {
const search = searchTerm.toLowerCase();
result = result.filter(item =>
(item.itemCode?.toLowerCase().includes(search) ?? false) ||
(item.itemName?.toLowerCase().includes(search) ?? false) ||
(item.specification?.toLowerCase().includes(search) ?? false)
);
}
return result;
}, [data, activeTab, searchTerm]);
// 통계 계산
const totalStats = useMemo(() => {
const totalAll = data.length;
const totalFG = data.filter(d => d.itemType === 'FG').length;
const totalPT = data.filter(d => d.itemType === 'PT').length;
const totalSM = data.filter(d => d.itemType === 'SM').length;
const totalRM = data.filter(d => d.itemType === 'RM').length;
const totalCS = data.filter(d => d.itemType === 'CS').length;
const registered = data.filter(d => d.status !== 'not_registered').length;
const notRegistered = totalAll - registered;
const finalized = data.filter(d => d.isFinal).length;
return { totalAll, totalFG, totalPT, totalSM, totalRM, totalCS, registered, notRegistered, finalized };
}, [data]);
// 금액 포맷팅
const formatPrice = (price?: number) => {
if (price === undefined || price === null) return '-';
return `${price.toLocaleString()}`;
};
// 품목 유형 Badge 렌더링
const renderItemTypeBadge = (type: string) => {
const colors = ITEM_TYPE_COLORS[type as ItemType];
const label = ITEM_TYPE_LABELS[type as ItemType] || type;
if (!colors) {
return <Badge variant="outline">{label}</Badge>;
}
return (
<Badge
variant="outline"
className={`${colors.bg} ${colors.text} ${colors.border}`}
>
{label}
</Badge>
);
};
// 상태 Badge 렌더링
const renderStatusBadge = (item: PricingListItem) => {
if (item.status === 'not_registered') {
return <Badge variant="outline" className="bg-gray-50 text-gray-700"></Badge>;
}
if (item.isFinal) {
return <Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200"></Badge>;
}
if (item.status === 'active') {
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200"></Badge>;
}
if (item.status === 'inactive') {
return <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200"></Badge>;
}
return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200"></Badge>;
};
// 마진율 Badge 렌더링
const renderMarginBadge = (marginRate?: number) => {
if (marginRate === undefined || marginRate === null || marginRate === 0) {
return <span className="text-muted-foreground">-</span>;
}
const colorClass =
marginRate >= 30 ? 'bg-green-50 text-green-700 border-green-200' :
marginRate >= 20 ? 'bg-blue-50 text-blue-700 border-blue-200' :
marginRate >= 10 ? 'bg-orange-50 text-orange-700 border-orange-200' :
'bg-red-50 text-red-700 border-red-200';
return (
<Badge variant="outline" className={colorClass}>
{marginRate.toFixed(1)}%
</Badge>
);
};
// 네비게이션 핸들러
const handleRegister = (item: PricingListItem) => {
// item_type_code는 품목 정보에서 자동으로 가져오므로 URL에 포함하지 않음
const params = new URLSearchParams();
params.set('mode', 'new');
if (item.itemId) params.set('itemId', item.itemId);
if (item.itemCode) params.set('itemCode', item.itemCode);
router.push(`/sales/pricing-management?${params.toString()}`);
};
const handleEdit = (item: PricingListItem) => {
router.push(`/sales/pricing-management/${item.id}?mode=edit`);
};
const handleHistory = (item: PricingListItem) => {
// TODO: 이력 다이얼로그 열기
console.log('이력 조회:', item.id);
};
// 탭 옵션
const tabs: TabOption[] = [
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
{ value: 'FG', label: '제품', count: totalStats.totalFG, color: 'purple' },
{ value: 'PT', label: '부품', count: totalStats.totalPT, color: 'orange' },
{ value: 'SM', label: '부자재', count: totalStats.totalSM, color: 'green' },
{ value: 'RM', label: '원자재', count: totalStats.totalRM, color: 'blue' },
{ value: 'CS', label: '소모품', count: totalStats.totalCS, color: 'gray' },
];
// 통계 카드
const stats: StatCard[] = [
{ label: '전체 품목', value: totalStats.totalAll, icon: Package, iconColor: 'text-blue-600' },
{ label: '단가 등록', value: totalStats.registered, icon: DollarSign, iconColor: 'text-green-600' },
{ label: '미등록', value: totalStats.notRegistered, icon: AlertCircle, iconColor: 'text-orange-600' },
{ label: '확정', value: totalStats.finalized, icon: CheckCircle2, iconColor: 'text-purple-600' },
];
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
{ key: 'itemType', label: '품목유형', className: 'min-w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
{ key: 'specification', label: '규격', className: 'min-w-[100px]', hideOnMobile: true },
{ key: 'unit', label: '단위', className: 'min-w-[60px]', hideOnMobile: true },
{ key: 'purchasePrice', label: '매입단가', className: 'min-w-[100px] text-right', hideOnTablet: true },
{ key: 'processingCost', label: '가공비', className: 'min-w-[80px] text-right', hideOnTablet: true },
{ key: 'salesPrice', label: '판매단가', className: 'min-w-[100px] text-right' },
{ key: 'marginRate', label: '마진율', className: 'min-w-[80px] text-right', hideOnMobile: true },
{ key: 'effectiveDate', label: '적용일', className: 'min-w-[100px]', hideOnMobile: true },
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
];
// 테이블 행 렌더링
const renderTableRow = (
item: PricingListItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<PricingListItem>
) => {
const { isSelected, onToggle } = handlers;
// 행 클릭 핸들러: 등록되지 않은 항목은 등록, 등록된 항목은 수정
const handleRowClick = () => {
if (item.status === 'not_registered') {
handleRegister(item);
} else {
handleEdit(item);
}
};
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={handleRowClick}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-muted-foreground text-center">
{globalIndex}
</TableCell>
<TableCell>{renderItemTypeBadge(item.itemType)}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{item.itemCode}
</code>
</TableCell>
<TableCell>
<span className="font-medium truncate max-w-[200px] block">
{item.itemName}
</span>
</TableCell>
<TableCell className="text-muted-foreground hidden md:table-cell">
{item.specification || '-'}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant="secondary">{item.unit || '-'}</Badge>
</TableCell>
<TableCell className="text-right font-mono hidden lg:table-cell">
{formatPrice(item.purchasePrice)}
</TableCell>
<TableCell className="text-right font-mono hidden lg:table-cell">
{formatPrice(item.processingCost)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatPrice(item.salesPrice)}
</TableCell>
<TableCell className="text-right hidden md:table-cell">
{renderMarginBadge(item.marginRate)}
</TableCell>
<TableCell className="hidden md:table-cell">
{item.effectiveDate
? new Date(item.effectiveDate).toLocaleDateString('ko-KR')
: '-'}
</TableCell>
<TableCell>{renderStatusBadge(item)}</TableCell>
</TableRow>
);
};
// 모바일 카드 렌더링
const renderMobileCard = (
item: PricingListItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<PricingListItem>
) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
key={item.id}
id={item.id}
title={item.itemName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{item.itemCode}
</code>
{renderItemTypeBadge(item.itemType)}
</div>
}
statusBadge={renderStatusBadge(item)}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => item.status !== 'not_registered' ? handleEdit(item) : handleRegister(item)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{item.specification && (
<InfoField label="규격" value={item.specification} />
)}
{item.unit && (
<InfoField label="단위" value={item.unit} />
)}
<InfoField label="판매단가" value={formatPrice(item.salesPrice)} />
<InfoField
label="마진율"
value={item.marginRate ? `${item.marginRate.toFixed(1)}%` : '-'}
/>
</div>
}
/>
);
};
// 헤더 액션 (함수로 정의)
const headerActions = () => (
<Button
variant="outline"
onClick={() => {
// TODO: API 연동 시 품목 마스터 동기화 로직 구현
console.log('품목 마스터 동기화');
}}
className="ml-auto gap-2"
>
<RefreshCw className="h-4 w-4" />
</Button>
);
// UniversalListPage 설정
const pricingConfig: UniversalListConfig<PricingListItem> = {
title: '단가 목록',
description: '품목별 매입단가, 판매단가 및 마진을 관리합니다',
icon: DollarSign,
basePath: '/sales/pricing-management',
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: data,
totalCount: data.length,
}),
},
columns: tableColumns,
headerActions,
stats,
tabs,
searchPlaceholder: '품목코드, 품목명, 규격 검색...',
itemsPerPage: pageSize,
clientSideFiltering: true,
tabFilter,
searchFilter,
renderTableRow,
renderMobileCard,
};
return (
<UniversalListPage<PricingListItem>
config={pricingConfig}
initialData={data}
initialTotalCount={data.length}
onTabChange={setActiveTab}
onSearchChange={setSearchTerm}
/>
);
}
export default PricingListClient;