refactor(WEB): claudedocs 재정리 및 AuthContext/Zustand/유틸 코드 개선
- claudedocs 폴더 구조 재정리: archive/sessions, guides/migration·mobile·universal-list, refactoring 분류 - 오래된 세션 컨텍스트/체크리스트 문서 정리 (아카이브 이동 또는 삭제) - AuthContext → authStore(Zustand) 전환 시작, RootProvider 간소화 - GenericCRUDDialog 공통 다이얼로그 컴포넌트 추가 - PermissionDialog 삭제 → GenericCRUDDialog로 대체 - RankDialog/TitleDialog GenericCRUDDialog 기반으로 리팩토링 - toast-utils.ts 삭제 (미사용) - fileDownload.ts 개선, excel-download.ts 정리 - menuStore/themeStore Zustand 셀렉터 최적화 - useColumnSettings/useTableColumnStore 기능 보강 - 세금계산서/견적/작업자화면/결재 등 소규모 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* API 에러 토스트 유틸리티
|
||||
* - 개발 중 디버깅을 위해 에러 코드와 메시지를 함께 표시
|
||||
* - 나중에 프로덕션에서 코드 숨기려면 이 파일만 수정하면 됨
|
||||
*/
|
||||
import { toast } from 'sonner';
|
||||
import { ApiError, DuplicateCodeError, getErrorMessage } from './error-handler';
|
||||
|
||||
/**
|
||||
* 디버그 모드 설정
|
||||
* - true: 에러 코드 표시 (개발/테스트)
|
||||
* - false: 메시지만 표시 (프로덕션)
|
||||
*
|
||||
* TODO: 프로덕션 배포 시 false로 변경하거나 환경변수 사용
|
||||
*/
|
||||
const SHOW_ERROR_CODE = true;
|
||||
|
||||
/**
|
||||
* API 에러를 토스트로 표시
|
||||
* - ApiError: [상태코드] 메시지 형식
|
||||
* - DuplicateCodeError: 중복 코드 정보 포함
|
||||
* - 일반 Error: 메시지만 표시
|
||||
*
|
||||
* @param error - 발생한 에러 객체
|
||||
* @param fallbackMessage - 에러 메시지가 없을 때 표시할 기본 메시지
|
||||
*/
|
||||
export function toastApiError(
|
||||
error: unknown,
|
||||
fallbackMessage = '오류가 발생했습니다.'
|
||||
): void {
|
||||
// DuplicateCodeError - 중복 코드 에러 (별도 처리 필요할 수 있음)
|
||||
if (error instanceof DuplicateCodeError) {
|
||||
const message = SHOW_ERROR_CODE
|
||||
? `[${error.status}] ${error.message} (코드: ${error.duplicateCode})`
|
||||
: error.message;
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// ApiError - HTTP 에러
|
||||
if (error instanceof ApiError) {
|
||||
const message = SHOW_ERROR_CODE
|
||||
? `[${error.status}] ${error.message}`
|
||||
: error.message;
|
||||
|
||||
// Validation 에러가 있으면 첫 번째 에러도 표시
|
||||
if (error.errors && SHOW_ERROR_CODE) {
|
||||
const firstErrorField = Object.keys(error.errors)[0];
|
||||
if (firstErrorField) {
|
||||
const firstError = error.errors[firstErrorField][0];
|
||||
toast.error(`${message}\n${firstErrorField}: ${firstError}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 Error
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message || fallbackMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// unknown 타입
|
||||
toast.error(fallbackMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 성공 토스트
|
||||
* - 일관된 성공 메시지 표시
|
||||
*
|
||||
* @param message - 성공 메시지
|
||||
*/
|
||||
export function toastSuccess(message: string): void {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 경고 토스트
|
||||
*
|
||||
* @param message - 경고 메시지
|
||||
*/
|
||||
export function toastWarning(message: string): void {
|
||||
toast.warning(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 정보 토스트
|
||||
*
|
||||
* @param message - 정보 메시지
|
||||
*/
|
||||
export function toastInfo(message: string): void {
|
||||
toast.info(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 포맷팅 (토스트 외 용도)
|
||||
* - 에러 코드 포함 여부는 SHOW_ERROR_CODE 설정 따름
|
||||
*
|
||||
* @param error - 발생한 에러 객체
|
||||
* @param fallbackMessage - 기본 메시지
|
||||
* @returns 포맷팅된 에러 메시지
|
||||
*/
|
||||
export function formatApiError(
|
||||
error: unknown,
|
||||
fallbackMessage = '오류가 발생했습니다.'
|
||||
): string {
|
||||
if (error instanceof ApiError) {
|
||||
return SHOW_ERROR_CODE
|
||||
? `[${error.status}] ${error.message}`
|
||||
: error.message;
|
||||
}
|
||||
return getErrorMessage(error) || fallbackMessage;
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { useMasterDataStore } from '@/stores/masterDataStore';
|
||||
import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
// FCM은 Capacitor 환경에서만 사용 (동적 import로 웹 빌드 에러 방지)
|
||||
|
||||
@@ -87,6 +88,9 @@ export function clearLocalStorageCache(): void {
|
||||
*/
|
||||
export function resetZustandStores(): void {
|
||||
try {
|
||||
// authStore 초기화
|
||||
useAuthStore.getState().resetAllData();
|
||||
|
||||
// masterDataStore 초기화
|
||||
const masterDataStore = useMasterDataStore.getState();
|
||||
masterDataStore.reset();
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
import { generateExportFilename } from '@/lib/utils/export';
|
||||
|
||||
// xlsx는 ~400KB로 무거워서, 실제 사용 시점에 동적 로드
|
||||
async function loadXLSX() {
|
||||
@@ -74,17 +75,13 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
|
||||
/**
|
||||
* 날짜 형식의 파일명 생성
|
||||
* export.ts의 generateExportFilename에 위임
|
||||
*/
|
||||
function generateFilename(baseName: string, appendDate: boolean): string {
|
||||
if (!appendDate) {
|
||||
return `${baseName}.xlsx`;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const timeStr = now.toTimeString().slice(0, 5).replace(/:/g, '');
|
||||
|
||||
return `${baseName}_${dateStr}_${timeStr}.xlsx`;
|
||||
return generateExportFilename(baseName, 'xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,26 @@
|
||||
* 프록시: GET /api/proxy/files/{id}/download
|
||||
*/
|
||||
|
||||
import { downloadBlob } from './export';
|
||||
|
||||
/**
|
||||
* Content-Disposition 헤더에서 파일명 추출
|
||||
*/
|
||||
function extractFilenameFromHeader(response: Response): string | null {
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
if (!contentDisposition) return null;
|
||||
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (!match?.[1]) return null;
|
||||
|
||||
const raw = match[1].replace(/['"]/g, '');
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 ID로 다운로드
|
||||
* @param fileId 파일 ID
|
||||
@@ -19,40 +39,11 @@ export async function downloadFileById(fileId: number, fileName?: string): Promi
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadFileName = fileName
|
||||
?? extractFilenameFromHeader(response)
|
||||
?? `file_${fileId}`;
|
||||
|
||||
// 파일명이 없으면 Content-Disposition 헤더에서 추출 시도
|
||||
let downloadFileName = fileName;
|
||||
if (!downloadFileName) {
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (match && match[1]) {
|
||||
downloadFileName = match[1].replace(/['"]/g, '');
|
||||
// URL 디코딩 (한글 파일명 처리)
|
||||
try {
|
||||
downloadFileName = decodeURIComponent(downloadFileName);
|
||||
} catch {
|
||||
// 디코딩 실패 시 그대로 사용
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 그래도 없으면 기본 파일명
|
||||
if (!downloadFileName) {
|
||||
downloadFileName = `file_${fileId}`;
|
||||
}
|
||||
|
||||
// Blob URL 생성 및 다운로드 트리거
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
downloadBlob(blob, downloadFileName);
|
||||
} catch (error) {
|
||||
console.error('[fileDownload] 다운로드 오류:', error);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user