feat(WEB): 리스트 페이지 UI 레이아웃 표준화

- 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들]
- beforeTableContent → headerActions + createButton 마이그레이션
- DateRangeSelector extraActions prop 활용하여 검색창 통합
- PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가
- 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리
- 건설 관련 페이지 검색 영역 정리
- 부모 메뉴 리다이렉트 컴포넌트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-26 22:04:36 +09:00
parent ff93ab7fa2
commit 1f6b592b9f
65 changed files with 1974 additions and 503 deletions

View File

@@ -17,7 +17,8 @@ import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
import { Search, Plus, Edit, Trash2, Package, Download, FileDown, Upload } from 'lucide-react';
import { downloadExcel, downloadSelectedExcel, downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download';
import { useItemList } from '@/hooks/useItemList';
import { handleApiError } from '@/lib/api/error-handler';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -237,6 +238,120 @@ export default function ItemListClient() {
}
};
// 엑셀 다운로드용 컬럼 정의
const excelColumns: ExcelColumn<ItemMaster>[] = [
{ header: '품목코드', key: 'itemCode', width: 15 },
{ header: '품목유형', key: 'itemType', width: 10, transform: (v) => ITEM_TYPE_LABELS[v as keyof typeof ITEM_TYPE_LABELS] || String(v) },
{ header: '품목명', key: 'itemName', width: 30 },
{ header: '규격', key: 'specification', width: 20 },
{ header: '단위', key: 'unit', width: 8 },
{ header: '대분류', key: 'category1', width: 12 },
{ header: '중분류', key: 'category2', width: 12 },
{ header: '소분류', key: 'category3', width: 12 },
{ header: '구매단가', key: 'purchasePrice', width: 12 },
{ header: '판매단가', key: 'salesPrice', width: 12 },
{ header: '활성상태', key: 'isActive', width: 10, transform: (v) => v ? '활성' : '비활성' },
];
// 전체 엑셀 다운로드
const handleExcelDownload = () => {
if (items.length === 0) {
alert('다운로드할 데이터가 없습니다.');
return;
}
downloadExcel({
data: items,
columns: excelColumns,
filename: '품목목록',
sheetName: '품목',
});
};
// 선택 항목 엑셀 다운로드
const handleSelectedExcelDownload = (selectedIds: string[]) => {
if (selectedIds.length === 0) {
alert('선택된 항목이 없습니다.');
return;
}
downloadSelectedExcel({
data: items,
columns: excelColumns,
selectedIds,
idField: 'id',
filename: '품목목록_선택',
sheetName: '품목',
});
};
// 업로드용 템플릿 컬럼 정의
const templateColumns: TemplateColumn[] = [
{ header: '품목코드', key: 'itemCode', required: true, type: 'text', sampleValue: 'KD-FG-001', description: '고유 코드', width: 15 },
{ header: '품목유형', key: 'itemType', required: true, type: 'select', options: ['FG', 'PT', 'SM', 'RM', 'CS'], sampleValue: 'FG', description: 'FG:제품/PT:부품/SM:부자재/RM:원자재/CS:소모품', width: 12 },
{ header: '품목명', key: 'itemName', required: true, type: 'text', sampleValue: '스크린도어 본체', width: 25 },
{ header: '규격', key: 'specification', type: 'text', sampleValue: '1800x2100', width: 15 },
{ header: '단위', key: 'unit', required: true, type: 'select', options: ['EA', 'SET', 'KG', 'M', 'M2', 'BOX'], sampleValue: 'EA', width: 10 },
{ header: '대분류', key: 'category1', type: 'text', sampleValue: '스크린도어', width: 12 },
{ header: '중분류', key: 'category2', type: 'text', sampleValue: '본체류', width: 12 },
{ header: '소분류', key: 'category3', type: 'text', sampleValue: '프레임', width: 12 },
{ header: '구매단가', key: 'purchasePrice', type: 'number', sampleValue: 150000, width: 12 },
{ header: '판매단가', key: 'salesPrice', type: 'number', sampleValue: 200000, width: 12 },
{ header: '활성상태', key: 'isActive', type: 'boolean', sampleValue: 'Y', description: 'Y:활성/N:비활성', width: 10 },
];
// 양식 다운로드
const handleTemplateDownload = () => {
downloadExcelTemplate({
columns: templateColumns,
filename: '품목등록_양식',
sheetName: '품목등록',
includeSampleRow: true,
includeGuideRow: true,
});
};
// 파일 업로드 input ref
const fileInputRef = useRef<HTMLInputElement>(null);
// 양식 업로드 핸들러
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const result = await parseExcelFile(file, {
columns: templateColumns,
skipRows: 2, // 헤더 + 안내 행 스킵
});
if (!result.success || result.errors.length > 0) {
const errorMessages = result.errors.slice(0, 5).map(
(err) => `${err.row}행: ${err.message}`
).join('\n');
alert(`업로드 오류:\n${errorMessages}${result.errors.length > 5 ? `\n... 외 ${result.errors.length - 5}` : ''}`);
return;
}
if (result.data.length === 0) {
alert('업로드할 데이터가 없습니다.');
return;
}
// TODO: 실제 API 호출로 데이터 저장
// 지금은 파싱 결과만 확인
console.log('[Excel Upload] 파싱 결과:', result.data);
alert(`${result.data.length}건의 데이터가 파싱되었습니다.\n(실제 등록 기능은 추후 구현 예정)`);
} catch (error) {
console.error('[Excel Upload] 오류:', error);
alert('파일 업로드에 실패했습니다.');
} finally {
// input 초기화 (같은 파일 재선택 가능하도록)
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// 탭 옵션 (품목 유형별)
const tabs: TabOption[] = [
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
@@ -301,6 +416,56 @@ export default function ItemListClient() {
icon: Plus,
},
// 헤더 액션 (엑셀 다운로드)
headerActions: ({ selectedItems }) => (
<div className="flex items-center gap-2">
{/* 양식 다운로드 버튼 - 추후 활성화
<Button
variant="outline"
size="sm"
onClick={handleTemplateDownload}
className="gap-2"
>
<FileDown className="h-4 w-4" />
양식 다운로드
</Button>
*/}
{/* 양식 업로드 버튼 - 추후 활성화
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
<Upload className="h-4 w-4" />
양식 업로드
</Button>
*/}
{/* 엑셀 데이터 다운로드 버튼 */}
{selectedItems.size > 0 ? (
<Button
variant="outline"
size="sm"
onClick={() => handleSelectedExcelDownload(Array.from(selectedItems))}
className="gap-2"
>
<Download className="h-4 w-4" />
({selectedItems.size})
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleExcelDownload}
className="gap-2"
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
),
// API 액션 (일괄 삭제 포함)
actions: {
getList: async () => ({ success: true, data: items }),
@@ -488,6 +653,15 @@ export default function ItemListClient() {
externalIsLoading={isLoading}
/>
{/* 숨겨진 파일 업로드 input */}
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={handleFileUpload}
className="hidden"
/>
{/* 개별 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}