refactor(WEB): 프론트엔드 대규모 코드 정리 및 리팩토링

- 미사용 코드 삭제: ThemeContext, itemStore, utils/date.ts, utils/formatAmount.ts
- 유틸리티 이동: date, formatAmount → src/lib/utils/ (중앙 집중화)
- 다수 page.tsx 클라이언트 컴포넌트 패턴 통일
- DateRangeSelector 리팩토링 및 date-range-picker UI 컴포넌트 추가
- ThemeSelect/themeStore Zustand 직접 연동으로 전환
- 건설/회계/영업/품목/출하 등 전반적 컴포넌트 개선
- UniversalListPage, IntegratedListTemplateV2 타입 확장
- 프론트엔드 종합 리뷰 문서 및 개선 체크리스트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-19 16:30:07 +09:00
parent b8dcb69e47
commit a2c3e4c41e
136 changed files with 1987 additions and 896 deletions

View File

@@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Trash2, Package, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { BOMItem } from '@/contexts/ItemMasterContext';
interface BOMManagementSectionProps {
@@ -109,11 +110,17 @@ export function BOMManagementSection({
handleClose();
};
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const handleDelete = (id: number) => {
if (confirm('이 BOM 품목을 삭제하시겠습니까?')) {
onDeleteItem(id);
toast.success('BOM 품목이 삭제되었습니다');
}
setDeleteTargetId(id);
};
const handleDeleteConfirm = () => {
if (deleteTargetId === null) return;
onDeleteItem(deleteTargetId);
toast.success('BOM 품목이 삭제되었습니다');
setDeleteTargetId(null);
};
return (
@@ -288,6 +295,14 @@ export function BOMManagementSection({
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={deleteTargetId !== null}
onOpenChange={(open) => !open && setDeleteTargetId(null)}
onConfirm={handleDeleteConfirm}
title="BOM 품목 삭제"
description="이 BOM 품목을 삭제하시겠습니까?"
/>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { deleteItemFile, ItemFileType } from '@/lib/api/items';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { BendingDetail } from '@/types/item';
import { ItemType } from '@/types/item';
import { toast } from 'sonner';
/**
* 파일 정보 타입 (API 응답)
@@ -217,7 +218,7 @@ export function useFileHandling({
await downloadFileById(fileId, fileName);
} catch (error) {
console.error('[useFileHandling] 다운로드 실패:', error);
alert('파일 다운로드에 실패했습니다.');
toast.error('파일 다운로드에 실패했습니다.');
}
};
@@ -247,7 +248,7 @@ export function useFileHandling({
if (!fileId) {
console.error('[useFileHandling] 파일 ID를 찾을 수 없습니다:', fileType);
alert('파일 ID를 찾을 수 없습니다.');
toast.error('파일 ID를 찾을 수 없습니다.');
return;
}
@@ -276,10 +277,10 @@ export function useFileHandling({
setExistingCertificationFileId(null);
}
alert('파일이 삭제되었습니다.');
toast.success('파일이 삭제되었습니다.');
} catch (error) {
console.error('[useFileHandling] 파일 삭제 실패:', error);
alert('파일 삭제에 실패했습니다.');
toast.error('파일 삭제에 실패했습니다.');
} finally {
setIsDeletingFile(null);
}

View File

@@ -35,6 +35,7 @@ import type { ItemFieldResponse } from '@/types/item-master-api';
import { uploadItemFile, ItemFileType, checkItemCodeDuplicate, DuplicateCheckResult } from '@/lib/api/items';
import { DuplicateCodeError } from '@/lib/api/error-handler';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { toast } from 'sonner';
/**
* 메인 DynamicItemForm 컴포넌트
@@ -400,7 +401,7 @@ export default function DynamicItemForm({
if (fileUploadErrors.length > 0) {
console.warn('[DynamicItemForm] 일부 파일 업로드 실패:', fileUploadErrors.join(', '));
// 품목은 저장되었으므로 경고만 표시하고 진행
alert(`품목이 저장되었습니다.\n\n일부 파일 업로드에 실패했습니다: ${fileUploadErrors.join(', ')}\n수정 화면에서 다시 업로드해 주세요.`);
toast.warning(`품목이 저장되었습니다. 일부 파일 업로드에 실패했습니다: ${fileUploadErrors.join(', ')}. 수정 화면에서 다시 업로드해 주세요.`);
}
}

View File

@@ -32,6 +32,7 @@ import { downloadFileById } from '@/lib/utils/fileDownload';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
interface ItemDetailClientProps {
item: ItemMaster;
@@ -66,7 +67,7 @@ async function handleFileDownload(fileId: number | undefined, fileName?: string)
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ItemDetailClient] 다운로드 실패:', error);
alert('파일 다운로드에 실패했습니다.');
toast.error('파일 다운로드에 실패했습니다.');
}
}

View File

@@ -12,6 +12,7 @@ import type { BendingDetail } from '@/types/item';
import type { UseFormSetValue } from 'react-hook-form';
import type { CreateItemFormData } from '@/lib/utils/validation';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { toast } from 'sonner';
export interface BendingDiagramSectionProps {
selectedPartType: string;
@@ -63,7 +64,7 @@ export default function BendingDiagramSection({
// 기존 파일 다운로드 핸들러
const handleDownloadExistingFile = async () => {
if (!existingBendingDiagramFileId) {
alert('파일 ID가 없습니다.');
toast.error('파일 ID가 없습니다.');
return;
}
@@ -72,7 +73,7 @@ export default function BendingDiagramSection({
await downloadFileById(existingBendingDiagramFileId, fileName);
} catch (error) {
console.error('[BendingDiagramSection] 파일 다운로드 실패:', error);
alert('파일 다운로드에 실패했습니다.');
toast.error('파일 다운로드에 실패했습니다.');
}
};
// 폭 합계 업데이트 헬퍼

View File

@@ -33,6 +33,7 @@ import BendingDiagramSection from './BendingDiagramSection';
import BOMSection from './BOMSection';
import { MaterialForm, ProductForm, ProductCertificationSection, PartForm } from './forms';
import { useItemFormState } from './hooks';
import { toast } from 'sonner';
export default function ItemForm({ mode, initialData, onSubmit }: ItemFormProps) {
const router = useRouter();
@@ -177,7 +178,7 @@ export default function ItemForm({ mode, initialData, onSubmit }: ItemFormProps)
router.push('/production/screen-production');
router.refresh();
} catch {
alert('품목 저장에 실패했습니다.');
toast.error('품목 저장에 실패했습니다.');
} finally {
setIsSubmitting(false);
}

View File

@@ -32,6 +32,7 @@ import {
type StatCard,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
// Debounce 훅
function useDebounce<T>(value: T, delay: number): T {
@@ -182,7 +183,7 @@ export default function ItemListClient() {
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('품목 삭제 실패:', error);
alert(error instanceof Error ? error.message : '품목 삭제에 실패했습니다.');
toast.error(error instanceof Error ? error.message : '품목 삭제에 실패했습니다.');
} finally {
setDeleteDialogOpen(false);
setItemToDelete(null);
@@ -225,10 +226,10 @@ export default function ItemListClient() {
}
if (successCount > 0) {
alert(`${successCount}개 품목이 삭제되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`);
toast.success(`${successCount}개 품목이 삭제되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`);
refresh();
} else {
alert('품목 삭제에 실패했습니다.');
toast.error('품목 삭제에 실패했습니다.');
}
};
@@ -316,22 +317,22 @@ export default function ItemListClient() {
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}` : ''}`);
toast.error('업로드 오류', { description: `${errorMessages}${result.errors.length > 5 ? ` (${result.errors.length - 5})` : ''}` });
return;
}
if (result.data.length === 0) {
alert('업로드할 데이터가 없습니다.');
toast.warning('업로드할 데이터가 없습니다.');
return;
}
// TODO: 실제 API 호출로 데이터 저장
// 지금은 파싱 결과만 확인
alert(`${result.data.length}건의 데이터가 파싱되었습니다.\n(실제 등록 기능은 추후 구현 예정)`);
toast.info(`${result.data.length}건의 데이터가 파싱되었습니다. (실제 등록 기능은 추후 구현 예정)`);
} catch (error) {
console.error('[Excel Upload] 오류:', error);
alert('파일 업로드에 실패했습니다.');
toast.error('파일 업로드에 실패했습니다.');
} finally {
// input 초기화 (같은 파일 재선택 가능하도록)
if (fileInputRef.current) {

View File

@@ -1,11 +1,17 @@
import type { Dispatch, SetStateAction } from 'react';
import { useState, useCallback, type Dispatch, type SetStateAction } from 'react';
import type { ItemPage, ItemSection, ItemField, BOMItem } from '@/contexts/ItemMasterContext';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Edit, Trash2, Link, Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
type HierarchyConfirmAction =
| { type: 'deletePage'; pageId: number }
| { type: 'unlinkSection'; pageId: number; sectionId: number }
| { type: 'unlinkField'; pageId: string; sectionId: string; fieldId: string };
import { DraggableSection, DraggableField } from '../../components';
import { BOMManagementSection } from '@/components/items/BOMManagementSection';
@@ -121,6 +127,37 @@ export function HierarchyTab({
updateBOMItem,
deleteBOMItem,
}: HierarchyTabProps) {
const [confirmAction, setConfirmAction] = useState<HierarchyConfirmAction | null>(null);
const handleConfirmAction = useCallback(() => {
if (!confirmAction) return;
switch (confirmAction.type) {
case 'deletePage':
deleteItemPage(confirmAction.pageId);
if (selectedPageId === confirmAction.pageId) {
setSelectedPageId(itemPages[0]?.id || null);
}
toast.success('섹션이 삭제되었습니다');
break;
case 'unlinkSection':
unlinkSection(confirmAction.pageId, confirmAction.sectionId);
break;
case 'unlinkField':
deleteField(confirmAction.pageId, confirmAction.sectionId, confirmAction.fieldId);
toast.success('항목 연결이 해제되었습니다');
break;
}
setConfirmAction(null);
}, [confirmAction, deleteItemPage, selectedPageId, setSelectedPageId, itemPages, unlinkSection, deleteField]);
const confirmDialogConfig = confirmAction
? {
deletePage: { title: '섹션 삭제', description: '이 섹션과 모든 하위섹션, 항목을 삭제하시겠습니까?' },
unlinkSection: { title: '섹션 연결 해제', description: '이 섹션을 페이지에서 연결 해제하시겠습니까?\n(섹션 데이터는 섹션 탭에 유지됩니다)' },
unlinkField: { title: '항목 연결 해제', description: '이 항목을 섹션에서 연결 해제하시겠습니까?\n(항목 데이터는 항목 탭에 유지됩니다)' },
}[confirmAction.type]
: { title: '', description: '' };
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 섹션 목록 */}
@@ -211,13 +248,7 @@ export function HierarchyTab({
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
if (confirm('이 섹션과 모든 하위섹션, 항목을 삭제하시겠습니까?')) {
deleteItemPage(page.id);
if (selectedPageId === page.id) {
setSelectedPageId(itemPages[0]?.id || null);
}
toast.success('섹션이 삭제되었습니다');
}
setConfirmAction({ type: 'deletePage', pageId: page.id });
}}
title="삭제"
>
@@ -256,7 +287,7 @@ export function HierarchyTab({
// Modern API 시도 (브라우저 환경 체크)
if (typeof window !== 'undefined' && window.navigator.clipboard && window.navigator.clipboard.writeText) {
window.navigator.clipboard.writeText(text)
.then(() => alert('경로가 클립보드에 복사되었습니다'))
.then(() => toast.success('경로가 클립보드에 복사되었습니다'))
.catch(() => {
// Fallback 방식
const textArea = document.createElement('textarea');
@@ -267,9 +298,9 @@ export function HierarchyTab({
textArea.select();
try {
document.execCommand('copy');
alert('경로가 클립보드에 복사되었습니다');
toast.success('경로가 클립보드에 복사되었습니다');
} catch {
alert('복사에 실패했습니다');
toast.error('복사에 실패했습니다');
}
document.body.removeChild(textArea);
});
@@ -283,9 +314,9 @@ export function HierarchyTab({
textArea.select();
try {
document.execCommand('copy');
alert('경로가 클립보드에 복사되었습니다');
toast.success('경로가 클립보드에 복사되었습니다');
} catch {
alert('복사에 실패했습니다');
toast.error('복사에 실패했습니다');
}
document.body.removeChild(textArea);
}
@@ -353,9 +384,7 @@ export function HierarchyTab({
moveSection(dragIndex, hoverIndex);
}}
onDelete={() => {
if (confirm('이 섹션을 페이지에서 연결 해제하시겠습니까?\n(섹션 데이터는 섹션 탭에 유지됩니다)')) {
unlinkSection(selectedPage.id, section.id);
}
setConfirmAction({ type: 'unlinkSection', pageId: selectedPage.id, sectionId: section.id });
}}
onEditTitle={handleEditSectionTitle}
editingSectionId={editingSectionId}
@@ -447,10 +476,7 @@ export function HierarchyTab({
index={fieldIndex}
moveField={(dragFieldId, hoverFieldId) => moveField(section.id, dragFieldId, hoverFieldId)}
onDelete={() => {
if (confirm('이 항목을 섹션에서 연결 해제하시겠습니까?\n(항목 데이터는 항목 탭에 유지됩니다)')) {
deleteField(String(selectedPage.id), String(section.id), String(field.id));
toast.success('항목 연결이 해제되었습니다');
}
setConfirmAction({ type: 'unlinkField', pageId: String(selectedPage.id), sectionId: String(section.id), fieldId: String(field.id) });
}}
onEdit={() => handleEditField(String(section.id), field)}
/>
@@ -493,6 +519,15 @@ export function HierarchyTab({
)}
</CardContent>
</Card>
<ConfirmDialog
open={confirmAction !== null}
onOpenChange={(open) => !open && setConfirmAction(null)}
onConfirm={handleConfirmAction}
title={confirmDialogConfig.title}
description={confirmDialogConfig.description}
variant="destructive"
/>
</div>
);
}