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:
@@ -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 품목을 삭제하시겠습니까?"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(', ')}. 수정 화면에서 다시 업로드해 주세요.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
// 폭 합계 업데이트 헬퍼
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user