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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { useTheme, useSetTheme } from "@/stores/themeStore";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -21,7 +21,8 @@ interface ThemeSelectProps {
|
||||
}
|
||||
|
||||
export function ThemeSelect({ native = true }: ThemeSelectProps) {
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const theme = useTheme();
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
const currentTheme = themes.find((t) => t.value === theme);
|
||||
const CurrentIcon = currentTheme?.icon || Sun;
|
||||
|
||||
@@ -406,7 +406,10 @@ export function TaxInvoiceManagement() {
|
||||
<TableCell className="text-right text-sm">{formatNumber(item.taxAmount)}</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">{formatNumber(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{RECEIPT_TYPE_LABELS[item.receiptType]}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${item.source === 'manual' ? 'bg-purple-500' : 'bg-blue-500'}`} />
|
||||
{RECEIPT_TYPE_LABELS[item.receiptType]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.documentNumber || '-'}</TableCell>
|
||||
<TableCell className="text-center text-sm">{INVOICE_SOURCE_LABELS[item.source]}</TableCell>
|
||||
@@ -463,12 +466,12 @@ export function TaxInvoiceManagement() {
|
||||
<TableRow>
|
||||
<TableCell colSpan={14} className="py-2">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-yellow-100 border border-yellow-300 rounded" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 bg-purple-500 rounded-full" />
|
||||
<span>수기 세금계산서</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-white border border-gray-300 rounded" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 bg-blue-500 rounded-full" />
|
||||
<span>홈택스 연동 세금계산서</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
deleteApproval,
|
||||
getEmployees,
|
||||
} from './actions';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BasicInfoSection } from './BasicInfoSection';
|
||||
import { ApprovalLineSection } from './ApprovalLineSection';
|
||||
@@ -88,7 +88,7 @@ export function DocumentCreate() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { currentUser } = useAuth();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const { canCreate, canDelete } = usePermission();
|
||||
|
||||
// 수정 모드 / 복제 모드 상태
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions';
|
||||
import { createBiddingFromEstimate } from '../bidding/actions';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog, DeleteConfirmDialog, SaveConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
@@ -49,7 +49,7 @@ export default function EstimateDetailForm({
|
||||
initialData,
|
||||
}: EstimateDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const { currentUser } = useAuth();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMenuStore, type MenuItem } from '@/stores/menuStore';
|
||||
import { useMenuItems, type MenuItem } from '@/stores/menuStore';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
@@ -65,7 +65,7 @@ const CommandMenuSearch = forwardRef<CommandMenuSearchRef>((_, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const router = useRouter();
|
||||
const { menuItems } = useMenuStore();
|
||||
const menuItems = useMenuItems();
|
||||
|
||||
// 외부에서 제어할 수 있도록 ref 노출
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
||||
171
src/components/molecules/GenericCRUDDialog.tsx
Normal file
171
src/components/molecules/GenericCRUDDialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 필드 정의
|
||||
*/
|
||||
export interface CRUDFieldDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'select';
|
||||
placeholder?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface GenericCRUDDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'add' | 'edit';
|
||||
entityName: string;
|
||||
fields: CRUDFieldDefinition[];
|
||||
initialData?: Record<string, string>;
|
||||
onSubmit: (data: Record<string, string>) => void;
|
||||
isLoading?: boolean;
|
||||
addLabel?: string;
|
||||
editLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단순 CRUD 다이얼로그 공통 컴포넌트
|
||||
*
|
||||
* 텍스트 입력 + Select 조합의 단순 폼 다이얼로그를 생성합니다.
|
||||
* RankDialog, TitleDialog 등 동일 패턴의 다이얼로그를 대체합니다.
|
||||
*/
|
||||
export function GenericCRUDDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode,
|
||||
entityName,
|
||||
fields,
|
||||
initialData,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
addLabel = '등록',
|
||||
editLabel = '수정',
|
||||
}: GenericCRUDDialogProps) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && initialData) {
|
||||
setFormData({ ...initialData });
|
||||
} else {
|
||||
const defaults: Record<string, string> = {};
|
||||
fields.forEach((f) => {
|
||||
defaults[f.key] = f.defaultValue ?? '';
|
||||
});
|
||||
setFormData(defaults);
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, initialData, fields]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const firstTextField = fields.find((f) => f.type === 'text');
|
||||
if (firstTextField && !formData[firstTextField.key]?.trim()) return;
|
||||
|
||||
const trimmed: Record<string, string> = {};
|
||||
Object.entries(formData).forEach(([k, v]) => {
|
||||
trimmed[k] = v.trim();
|
||||
});
|
||||
onSubmit(trimmed);
|
||||
|
||||
const defaults: Record<string, string> = {};
|
||||
fields.forEach((f) => {
|
||||
defaults[f.key] = f.defaultValue ?? '';
|
||||
});
|
||||
setFormData(defaults);
|
||||
};
|
||||
|
||||
const title = mode === 'add' ? `${entityName} 추가` : `${entityName} 수정`;
|
||||
const submitText = mode === 'add' ? addLabel : editLabel;
|
||||
|
||||
const firstTextField = fields.find((f) => f.type === 'text');
|
||||
const isSubmitDisabled =
|
||||
isLoading || (firstTextField ? !formData[firstTextField.key]?.trim() : false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{fields.map((field, idx) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`crud-${field.key}`}>{field.label}</Label>
|
||||
{field.type === 'text' ? (
|
||||
<Input
|
||||
id={`crud-${field.key}`}
|
||||
value={formData[field.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
autoFocus={idx === 0}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={formData[field.key] ?? ''}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({ ...prev, [field.key]: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitDisabled}>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,4 +10,7 @@ export { StandardDialog } from "./StandardDialog";
|
||||
export type { StandardDialogProps } from "./StandardDialog";
|
||||
|
||||
export { YearQuarterFilter } from "./YearQuarterFilter";
|
||||
export type { Quarter } from "./YearQuarterFilter";
|
||||
export type { Quarter } from "./YearQuarterFilter";
|
||||
|
||||
export { GenericCRUDDialog } from "./GenericCRUDDialog";
|
||||
export type { GenericCRUDDialogProps, CRUDFieldDefinition } from "./GenericCRUDDialog";
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { useSidebarCollapsed } from '@/stores/menuStore';
|
||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -324,7 +324,7 @@ const PROCESS_STEPS: Record<string, { name: string; isMaterialInput: boolean; is
|
||||
|
||||
export default function WorkerScreen() {
|
||||
// ===== 상태 관리 =====
|
||||
const { sidebarCollapsed } = useMenuStore();
|
||||
const sidebarCollapsed = useSidebarCollapsed();
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<string>('');
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { PermissionDialogProps } from './types';
|
||||
|
||||
/**
|
||||
* 권한 추가/수정 다이얼로그
|
||||
*/
|
||||
export function PermissionDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode,
|
||||
permission,
|
||||
onSubmit
|
||||
}: PermissionDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [status, setStatus] = useState<'active' | 'hidden'>('active');
|
||||
|
||||
// 다이얼로그 열릴 때 초기값 설정
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && permission) {
|
||||
setName(permission.name);
|
||||
setStatus(permission.status);
|
||||
} else {
|
||||
setName('');
|
||||
setStatus('active');
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, permission]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onSubmit({ name: name.trim(), status });
|
||||
setName('');
|
||||
setStatus('active');
|
||||
}
|
||||
};
|
||||
|
||||
const dialogTitle = mode === 'add' ? '권한 등록' : '권한 수정';
|
||||
const submitText = mode === 'add' ? '등록' : '수정';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 권한명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-name">권한명</Label>
|
||||
<Input
|
||||
id="permission-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="권한명을 입력하세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-status">상태</Label>
|
||||
<Select value={status} onValueChange={(value: 'active' | 'hidden') => setStatus(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">공개</SelectItem>
|
||||
<SelectItem value="hidden">숨김</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { GenericCRUDDialog, type CRUDFieldDefinition } from '@/components/molecules/GenericCRUDDialog';
|
||||
import type { RankDialogProps } from './types';
|
||||
|
||||
const RANK_FIELDS: CRUDFieldDefinition[] = [
|
||||
{ key: 'name', label: '직급명', type: 'text', placeholder: '직급명을 입력하세요' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 직급 추가/수정 다이얼로그
|
||||
*/
|
||||
@@ -25,65 +19,21 @@ export function RankDialog({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: RankDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
// 다이얼로그 열릴 때 초기값 설정
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && rank) {
|
||||
setName(rank.name);
|
||||
} else {
|
||||
setName('');
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, rank]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onSubmit(name.trim());
|
||||
setName('');
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === 'add' ? '직급 추가' : '직급 수정';
|
||||
const submitText = mode === 'add' ? '등록' : '수정';
|
||||
const initialData = useMemo(
|
||||
() => (rank ? { name: rank.name } : undefined),
|
||||
[rank]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 직급명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank-name">직급명</Label>
|
||||
<Input
|
||||
id="rank-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="직급명을 입력하세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<GenericCRUDDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
mode={mode}
|
||||
entityName="직급"
|
||||
fields={RANK_FIELDS}
|
||||
initialData={initialData}
|
||||
onSubmit={(data) => onSubmit(data.name)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { GenericCRUDDialog, type CRUDFieldDefinition } from '@/components/molecules/GenericCRUDDialog';
|
||||
import type { TitleDialogProps } from './types';
|
||||
|
||||
const TITLE_FIELDS: CRUDFieldDefinition[] = [
|
||||
{ key: 'name', label: '직책명', type: 'text', placeholder: '직책명을 입력하세요' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 직책 추가/수정 다이얼로그
|
||||
*/
|
||||
@@ -25,66 +19,21 @@ export function TitleDialog({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: TitleDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
// 다이얼로그 열릴 때 초기값 설정
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && title) {
|
||||
setName(title.name);
|
||||
} else {
|
||||
setName('');
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, title]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onSubmit(name.trim());
|
||||
setName('');
|
||||
}
|
||||
};
|
||||
|
||||
const dialogTitle = mode === 'add' ? '직책 추가' : '직책 수정';
|
||||
const submitText = mode === 'add' ? '등록' : '수정';
|
||||
const initialData = useMemo(
|
||||
() => (title ? { name: title.name } : undefined),
|
||||
[title]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 직책명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title-name">직책명</Label>
|
||||
<Input
|
||||
id="title-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="직책명을 입력하세요"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<GenericCRUDDialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
mode={mode}
|
||||
entityName="직책"
|
||||
fields={TITLE_FIELDS}
|
||||
initialData={initialData}
|
||||
onSubmit={(data) => onSubmit(data.name)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
|
||||
import { performFullLogout } from '@/lib/auth/logout';
|
||||
import { useMasterDataStore } from '@/stores/masterDataStore';
|
||||
/**
|
||||
* AuthContext - 하위호환 re-export 심
|
||||
*
|
||||
* 실제 구현은 src/stores/authStore.ts (Zustand)로 이동됨.
|
||||
* 기존 import { useAuth } from '@/contexts/AuthContext' 코드가
|
||||
* 깨지지 않도록 타입과 훅을 re-export.
|
||||
*/
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
import { type ReactNode } from 'react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
// ✅ 추가: 테넌트 타입 (실제 서버 응답 구조)
|
||||
export interface Tenant {
|
||||
id: number; // 테넌트 고유 ID (number)
|
||||
company_name: string; // 회사명
|
||||
business_num: string; // 사업자번호
|
||||
tenant_st_code: string; // 테넌트 상태 코드 (trial, active 등)
|
||||
options?: { // 테넌트 옵션 (선택)
|
||||
company_scale?: string; // 회사 규모
|
||||
industry?: string; // 업종
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ 추가: 권한 타입
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// ✅ 추가: 메뉴 아이템 타입
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
iconName: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// ✅ 수정: User 타입을 실제 서버 응답에 맞게 변경
|
||||
export interface User {
|
||||
userId: string; // 사용자 ID (username 아님)
|
||||
name: string; // 사용자 이름
|
||||
position: string; // 직책
|
||||
roles: Role[]; // 권한 목록 (배열)
|
||||
tenant: Tenant; // ✅ 테넌트 정보 (필수!)
|
||||
menu: MenuItem[]; // 메뉴 목록
|
||||
}
|
||||
|
||||
// ❌ 삭제 예정: 기존 UserRole (더 이상 사용하지 않음)
|
||||
export type UserRole = 'CEO' | 'ProductionManager' | 'Worker' | 'SystemAdmin' | 'Sales';
|
||||
|
||||
// ===== Context 타입 =====
|
||||
|
||||
interface AuthContextType {
|
||||
users: User[];
|
||||
currentUser: User | null;
|
||||
setCurrentUser: (user: User | null) => void;
|
||||
addUser: (user: User) => void;
|
||||
updateUser: (userId: string, updates: Partial<User>) => void;
|
||||
deleteUser: (userId: string) => void;
|
||||
getUserByUserId: (userId: string) => User | undefined;
|
||||
logout: () => Promise<void>; // ✅ 추가: 로그아웃 (완전한 캐시 정리)
|
||||
clearTenantCache: (tenantId: number) => void; // ✅ 추가: 테넌트 캐시 삭제
|
||||
resetAllData: () => void;
|
||||
}
|
||||
|
||||
// ===== 초기 데이터 =====
|
||||
|
||||
const initialUsers: User[] = [
|
||||
{
|
||||
userId: "TestUser1",
|
||||
name: "김대표",
|
||||
position: "대표이사",
|
||||
roles: [
|
||||
{
|
||||
id: 1,
|
||||
name: "ceo",
|
||||
description: "최고경영자"
|
||||
}
|
||||
],
|
||||
tenant: {
|
||||
id: 282,
|
||||
company_name: "(주)테크컴퍼니",
|
||||
business_num: "123-45-67890",
|
||||
tenant_st_code: "trial"
|
||||
},
|
||||
menu: [
|
||||
{
|
||||
id: "13664",
|
||||
label: "시스템 대시보드",
|
||||
iconName: "layout-dashboard",
|
||||
path: "/dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
userId: "TestUser2",
|
||||
name: "박관리",
|
||||
position: "생산관리자",
|
||||
roles: [
|
||||
{
|
||||
id: 2,
|
||||
name: "production_manager",
|
||||
description: "생산관리자"
|
||||
}
|
||||
],
|
||||
tenant: {
|
||||
id: 282,
|
||||
company_name: "(주)테크컴퍼니",
|
||||
business_num: "123-45-67890",
|
||||
tenant_st_code: "trial"
|
||||
},
|
||||
menu: [
|
||||
{
|
||||
id: "13664",
|
||||
label: "시스템 대시보드",
|
||||
iconName: "layout-dashboard",
|
||||
path: "/dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
userId: "TestUser3",
|
||||
name: "드미트리",
|
||||
position: "시스템 관리자",
|
||||
roles: [
|
||||
{
|
||||
id: 19,
|
||||
name: "system_manager",
|
||||
description: "시스템 관리자"
|
||||
}
|
||||
],
|
||||
tenant: {
|
||||
id: 282,
|
||||
company_name: "(주)테크컴퍼니",
|
||||
business_num: "123-45-67890",
|
||||
tenant_st_code: "trial"
|
||||
},
|
||||
menu: [
|
||||
{
|
||||
id: "13664",
|
||||
label: "시스템 대시보드",
|
||||
iconName: "layout-dashboard",
|
||||
path: "/dashboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// ===== Context 생성 =====
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
// ===== Provider 컴포넌트 =====
|
||||
// 타입 re-export
|
||||
export type { Tenant, Role, MenuItem, User, UserRole } from '@/stores/authStore';
|
||||
|
||||
// AuthProvider: 빈 passthrough (미발견 import 안전망)
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// 상태 관리 (SSR-safe: 항상 초기값으로 시작)
|
||||
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
|
||||
// ✅ 추가: 이전 tenant.id 추적 (테넌트 전환 감지용)
|
||||
const previousTenantIdRef = useRef<number | null>(null);
|
||||
|
||||
// localStorage에서 초기 데이터 로드 (클라이언트에서만 실행)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const savedUsers = localStorage.getItem('mes-users');
|
||||
if (savedUsers) {
|
||||
setUsers(JSON.parse(savedUsers));
|
||||
}
|
||||
|
||||
const savedCurrentUser = localStorage.getItem('mes-currentUser');
|
||||
if (savedCurrentUser) {
|
||||
setCurrentUser(JSON.parse(savedCurrentUser));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load auth data from localStorage:', error);
|
||||
// 손상된 데이터 제거
|
||||
localStorage.removeItem('mes-users');
|
||||
localStorage.removeItem('mes-currentUser');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// localStorage 동기화 (상태 변경 시 자동 저장)
|
||||
useEffect(() => {
|
||||
localStorage.setItem('mes-users', JSON.stringify(users));
|
||||
}, [users]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
localStorage.setItem('mes-currentUser', JSON.stringify(currentUser));
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
// ✅ 추가: 테넌트 전환 감지
|
||||
useEffect(() => {
|
||||
const prevTenantId = previousTenantIdRef.current;
|
||||
const currentTenantId = currentUser?.tenant?.id;
|
||||
|
||||
if (prevTenantId && currentTenantId && prevTenantId !== currentTenantId) {
|
||||
clearTenantCache(prevTenantId);
|
||||
}
|
||||
|
||||
previousTenantIdRef.current = currentTenantId || null;
|
||||
}, [currentUser?.tenant?.id]);
|
||||
|
||||
// ✅ 추가: masterDataStore에 현재 테넌트 ID 동기화
|
||||
useEffect(() => {
|
||||
const tenantId = currentUser?.tenant?.id ?? null;
|
||||
useMasterDataStore.getState().setCurrentTenantId(tenantId);
|
||||
}, [currentUser?.tenant?.id]);
|
||||
|
||||
// ✅ 추가: 테넌트별 캐시 삭제 함수 (SSR-safe)
|
||||
const clearTenantCache = (tenantId: number) => {
|
||||
// 서버 환경에서는 실행 안함
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const tenantAwarePrefix = `mes-${tenantId}-`;
|
||||
const pageConfigPrefix = `page_config_${tenantId}_`;
|
||||
|
||||
// localStorage 캐시 삭제
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith(tenantAwarePrefix)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
// sessionStorage 캐시 삭제 (TenantAwareCache + masterDataStore)
|
||||
Object.keys(sessionStorage).forEach(key => {
|
||||
if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ 추가: 로그아웃 함수 (완전한 캐시 정리)
|
||||
const logout = async () => {
|
||||
|
||||
// 1. React 상태 초기화 (UI 즉시 반영)
|
||||
setCurrentUser(null);
|
||||
|
||||
// 2. 완전한 로그아웃 수행 (Zustand, sessionStorage, localStorage, 서버 API)
|
||||
await performFullLogout({
|
||||
skipServerLogout: false, // 서버 API 호출 (HttpOnly 쿠키 삭제)
|
||||
redirectTo: null, // 리다이렉트는 호출하는 곳에서 처리
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
// Context value
|
||||
const value: AuthContextType = {
|
||||
users,
|
||||
currentUser,
|
||||
setCurrentUser,
|
||||
addUser: (user) => setUsers(prev => [...prev, user]),
|
||||
updateUser: (userId, updates) => setUsers(prev =>
|
||||
prev.map(user => user.userId === userId ? { ...user, ...updates } : user)
|
||||
),
|
||||
deleteUser: (userId) => setUsers(prev => prev.filter(user => user.userId !== userId)),
|
||||
getUserByUserId: (userId) => users.find(user => user.userId === userId),
|
||||
logout,
|
||||
clearTenantCache,
|
||||
resetAllData: () => {
|
||||
setUsers(initialUsers);
|
||||
setCurrentUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// ===== Custom Hook =====
|
||||
|
||||
// useAuth: authStore 전체 상태를 반환 (기존 Context 인터페이스 유지)
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
return useAuthStore();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useMemo, ReactNode } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { TenantAwareCache } from '@/lib/cache';
|
||||
import { itemMasterApi } from '@/lib/api/item-master';
|
||||
import { getErrorMessage, ApiError } from '@/lib/api/error-handler';
|
||||
@@ -224,7 +224,7 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
|
||||
const initialItemPages: ItemPage[] = [];
|
||||
|
||||
// ===== Auth & Cache Setup =====
|
||||
const { currentUser } = useAuth();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const tenantId = currentUser?.tenant?.id;
|
||||
|
||||
// ✅ TenantAwareCache 인스턴스 생성 (tenant.id 기반, SSR-safe)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { AuthProvider } from './AuthContext';
|
||||
import { PermissionProvider } from './PermissionContext';
|
||||
import { ItemMasterProvider } from './ItemMasterContext';
|
||||
|
||||
/**
|
||||
* RootProvider - 모든 Context Provider를 통합하는 최상위 Provider
|
||||
*
|
||||
* 현재 사용 중인 Context:
|
||||
* 1. AuthContext - 사용자/인증 (2개 상태)
|
||||
* 현재 사용 중인 Context/Store:
|
||||
* 1. authStore (Zustand) - 사용자/인증 (Provider 불필요)
|
||||
* 2. PermissionContext - 권한 관리 (URL 자동매칭)
|
||||
* 3. ItemMasterContext - 품목관리 (13개 상태)
|
||||
*
|
||||
@@ -19,13 +18,11 @@ import { ItemMasterProvider } from './ItemMasterContext';
|
||||
*/
|
||||
export function RootProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<PermissionProvider>
|
||||
<ItemMasterProvider>
|
||||
{children}
|
||||
</ItemMasterProvider>
|
||||
</PermissionProvider>
|
||||
</AuthProvider>
|
||||
<PermissionProvider>
|
||||
<ItemMasterProvider>
|
||||
{children}
|
||||
</ItemMasterProvider>
|
||||
</PermissionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useTableColumnStore } from '@/stores/useTableColumnStore';
|
||||
import { useTableColumnStore, usePageColumnSettings } from '@/stores/useTableColumnStore';
|
||||
import type { TableColumn } from '@/components/templates/UniversalListPage/types';
|
||||
|
||||
export interface ColumnWithVisibility extends TableColumn {
|
||||
@@ -14,8 +14,10 @@ interface UseColumnSettingsParams {
|
||||
}
|
||||
|
||||
export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: UseColumnSettingsParams) {
|
||||
const store = useTableColumnStore();
|
||||
const settings = store.getPageSettings(pageId);
|
||||
const settings = usePageColumnSettings(pageId);
|
||||
const setColumnWidthAction = useTableColumnStore((s) => s.setColumnWidth);
|
||||
const toggleColumnVisibilityAction = useTableColumnStore((s) => s.toggleColumnVisibility);
|
||||
const resetPageSettingsAction = useTableColumnStore((s) => s.resetPageSettings);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
return columns.filter((col) => !settings.hiddenColumns.includes(col.key));
|
||||
@@ -33,22 +35,22 @@ export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: U
|
||||
|
||||
const setColumnWidth = useCallback(
|
||||
(key: string, width: number) => {
|
||||
store.setColumnWidth(pageId, key, width);
|
||||
setColumnWidthAction(pageId, key, width);
|
||||
},
|
||||
[store, pageId]
|
||||
[setColumnWidthAction, pageId]
|
||||
);
|
||||
|
||||
const toggleColumnVisibility = useCallback(
|
||||
(key: string) => {
|
||||
if (alwaysVisibleKeys.includes(key)) return;
|
||||
store.toggleColumnVisibility(pageId, key);
|
||||
toggleColumnVisibilityAction(pageId, key);
|
||||
},
|
||||
[store, pageId, alwaysVisibleKeys]
|
||||
[toggleColumnVisibilityAction, pageId, alwaysVisibleKeys]
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(() => {
|
||||
store.resetPageSettings(pageId);
|
||||
}, [store, pageId]);
|
||||
resetPageSettingsAction(pageId);
|
||||
}, [resetPageSettingsAction, pageId]);
|
||||
|
||||
const hasHiddenColumns = settings.hiddenColumns.length > 0;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { useMenuStore, useMenuItems, useActiveMenu, useSidebarCollapsed, useMenuHydrated } from '@/stores/menuStore';
|
||||
import type { SerializableMenuItem } from '@/stores/menuStore';
|
||||
import type { MenuItem } from '@/stores/menuStore';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
@@ -43,8 +43,8 @@ import {
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import HeaderFavoritesBar from '@/components/layout/HeaderFavoritesBar';
|
||||
import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layout/CommandMenuSearch';
|
||||
import { useThemeStore } from '@/stores/themeStore';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useTheme, useSetTheme } from '@/stores/themeStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
|
||||
import { stripLocalePrefix } from '@/lib/utils/locale';
|
||||
import { safeJsonParse } from '@/lib/utils';
|
||||
@@ -96,9 +96,16 @@ interface AuthenticatedLayoutProps {
|
||||
}
|
||||
|
||||
export default function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
|
||||
const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { logout } = useAuth();
|
||||
const menuItems = useMenuItems();
|
||||
const activeMenu = useActiveMenu();
|
||||
const sidebarCollapsed = useSidebarCollapsed();
|
||||
const _hasHydrated = useMenuHydrated();
|
||||
const setActiveMenu = useMenuStore((s) => s.setActiveMenu);
|
||||
const setMenuItems = useMenuStore((s) => s.setMenuItems);
|
||||
const toggleSidebar = useMenuStore((s) => s.toggleSidebar);
|
||||
const theme = useTheme();
|
||||
const setTheme = useSetTheme();
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname(); // 현재 경로 추적
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
247
src/stores/authStore.ts
Normal file
247
src/stores/authStore.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Auth Zustand Store
|
||||
*
|
||||
* AuthContext(React Context + useState)에서 마이그레이션.
|
||||
* - persist: custom storage로 기존 localStorage 키(mes-users, mes-currentUser) 유지
|
||||
* - devtools: Redux DevTools 디버깅 지원
|
||||
* - subscribe: 테넌트 전환 감지 + masterDataStore 동기화
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { useMasterDataStore } from '@/stores/masterDataStore';
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
export interface Tenant {
|
||||
id: number;
|
||||
company_name: string;
|
||||
business_num: string;
|
||||
tenant_st_code: string;
|
||||
options?: {
|
||||
company_scale?: string;
|
||||
industry?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
iconName: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
userId: string;
|
||||
name: string;
|
||||
position: string;
|
||||
roles: Role[];
|
||||
tenant: Tenant;
|
||||
menu: MenuItem[];
|
||||
}
|
||||
|
||||
export type UserRole = 'CEO' | 'ProductionManager' | 'Worker' | 'SystemAdmin' | 'Sales';
|
||||
|
||||
// ===== Store 타입 =====
|
||||
|
||||
interface AuthState {
|
||||
// State
|
||||
users: User[];
|
||||
currentUser: User | null;
|
||||
|
||||
// Actions
|
||||
setCurrentUser: (user: User | null) => void;
|
||||
addUser: (user: User) => void;
|
||||
updateUser: (userId: string, updates: Partial<User>) => void;
|
||||
deleteUser: (userId: string) => void;
|
||||
getUserByUserId: (userId: string) => User | undefined;
|
||||
logout: () => Promise<void>;
|
||||
clearTenantCache: (tenantId: number) => void;
|
||||
resetAllData: () => void;
|
||||
}
|
||||
|
||||
// ===== 초기 데이터 =====
|
||||
|
||||
const initialUsers: User[] = [
|
||||
{
|
||||
userId: "TestUser1",
|
||||
name: "김대표",
|
||||
position: "대표이사",
|
||||
roles: [{ id: 1, name: "ceo", description: "최고경영자" }],
|
||||
tenant: {
|
||||
id: 282,
|
||||
company_name: "(주)테크컴퍼니",
|
||||
business_num: "123-45-67890",
|
||||
tenant_st_code: "trial",
|
||||
},
|
||||
menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }],
|
||||
},
|
||||
{
|
||||
userId: "TestUser2",
|
||||
name: "박관리",
|
||||
position: "생산관리자",
|
||||
roles: [{ id: 2, name: "production_manager", description: "생산관리자" }],
|
||||
tenant: {
|
||||
id: 282,
|
||||
company_name: "(주)테크컴퍼니",
|
||||
business_num: "123-45-67890",
|
||||
tenant_st_code: "trial",
|
||||
},
|
||||
menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }],
|
||||
},
|
||||
{
|
||||
userId: "TestUser3",
|
||||
name: "드미트리",
|
||||
position: "시스템 관리자",
|
||||
roles: [{ id: 19, name: "system_manager", description: "시스템 관리자" }],
|
||||
tenant: {
|
||||
id: 282,
|
||||
company_name: "(주)테크컴퍼니",
|
||||
business_num: "123-45-67890",
|
||||
tenant_st_code: "trial",
|
||||
},
|
||||
menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }],
|
||||
},
|
||||
];
|
||||
|
||||
// ===== Custom Storage =====
|
||||
// 기존 코드가 mes-users / mes-currentUser 두 개 키를 사용하므로 호환성 유지
|
||||
|
||||
const authStorage = createJSONStorage<Pick<AuthState, 'users' | 'currentUser'>>(() => ({
|
||||
getItem: (_name: string): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const users = localStorage.getItem('mes-users');
|
||||
const currentUser = localStorage.getItem('mes-currentUser');
|
||||
return JSON.stringify({
|
||||
state: {
|
||||
users: users ? JSON.parse(users) : initialUsers,
|
||||
currentUser: currentUser ? JSON.parse(currentUser) : null,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
localStorage.removeItem('mes-users');
|
||||
localStorage.removeItem('mes-currentUser');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: (_name: string, value: string): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const { users, currentUser } = parsed.state;
|
||||
localStorage.setItem('mes-users', JSON.stringify(users));
|
||||
if (currentUser) {
|
||||
localStorage.setItem('mes-currentUser', JSON.stringify(currentUser));
|
||||
}
|
||||
} catch {
|
||||
// 저장 실패 무시
|
||||
}
|
||||
},
|
||||
removeItem: (_name: string): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('mes-users');
|
||||
localStorage.removeItem('mes-currentUser');
|
||||
},
|
||||
}));
|
||||
|
||||
// ===== Store 생성 =====
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// State
|
||||
users: initialUsers,
|
||||
currentUser: null,
|
||||
|
||||
// Actions
|
||||
setCurrentUser: (user) => set({ currentUser: user }),
|
||||
|
||||
addUser: (user) => set((state) => ({ users: [...state.users, user] })),
|
||||
|
||||
updateUser: (userId, updates) =>
|
||||
set((state) => ({
|
||||
users: state.users.map((u) =>
|
||||
u.userId === userId ? { ...u, ...updates } : u
|
||||
),
|
||||
})),
|
||||
|
||||
deleteUser: (userId) =>
|
||||
set((state) => ({
|
||||
users: state.users.filter((u) => u.userId !== userId),
|
||||
})),
|
||||
|
||||
getUserByUserId: (userId) => get().users.find((u) => u.userId === userId),
|
||||
|
||||
logout: async () => {
|
||||
set({ currentUser: null });
|
||||
const { performFullLogout } = await import('@/lib/auth/logout');
|
||||
await performFullLogout({
|
||||
skipServerLogout: false,
|
||||
redirectTo: null,
|
||||
});
|
||||
},
|
||||
|
||||
clearTenantCache: (tenantId: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const tenantAwarePrefix = `mes-${tenantId}-`;
|
||||
const pageConfigPrefix = `page_config_${tenantId}_`;
|
||||
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
if (key.startsWith(tenantAwarePrefix)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(sessionStorage).forEach((key) => {
|
||||
if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
resetAllData: () => set({ users: initialUsers, currentUser: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-store',
|
||||
storage: authStorage,
|
||||
partialize: (state) => ({
|
||||
users: state.users,
|
||||
currentUser: state.currentUser,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'AuthStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// ===== Subscribe: 테넌트 전환 감지 + masterDataStore 동기화 =====
|
||||
|
||||
let _prevTenantId: number | null = null;
|
||||
|
||||
useAuthStore.subscribe((state) => {
|
||||
const currentTenantId = state.currentUser?.tenant?.id ?? null;
|
||||
|
||||
// 테넌트 전환 감지 (이전값이 있고, 현재값과 다를 때만)
|
||||
if (_prevTenantId && currentTenantId && _prevTenantId !== currentTenantId) {
|
||||
state.clearTenantCache(_prevTenantId);
|
||||
}
|
||||
|
||||
_prevTenantId = currentTenantId;
|
||||
|
||||
// masterDataStore 동기화
|
||||
useMasterDataStore.getState().setCurrentTenantId(currentTenantId);
|
||||
});
|
||||
|
||||
// ===== 셀렉터 훅 =====
|
||||
|
||||
export const useCurrentUser = () => useAuthStore((state) => state.currentUser);
|
||||
export const useAuthLogout = () => useAuthStore((state) => state.logout);
|
||||
@@ -63,4 +63,22 @@ export const useMenuStore = create<MenuState>()(
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
// ===== 셀렉터 훅 =====
|
||||
|
||||
/** 사이드바 접힘 상태만 구독 */
|
||||
export const useSidebarCollapsed = () =>
|
||||
useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
/** 활성 메뉴 ID만 구독 */
|
||||
export const useActiveMenu = () =>
|
||||
useMenuStore((state) => state.activeMenu);
|
||||
|
||||
/** 메뉴 아이템 목록만 구독 */
|
||||
export const useMenuItems = () =>
|
||||
useMenuStore((state) => state.menuItems);
|
||||
|
||||
/** 하이드레이션 완료 여부만 구독 */
|
||||
export const useMenuHydrated = () =>
|
||||
useMenuStore((state) => state._hasHydrated);
|
||||
@@ -41,4 +41,14 @@ export const useThemeStore = create<ThemeState>()(
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
// ===== 셀렉터 훅 =====
|
||||
|
||||
/** 현재 테마만 구독 */
|
||||
export const useTheme = () =>
|
||||
useThemeStore((state) => state.theme);
|
||||
|
||||
/** setTheme 액션만 구독 */
|
||||
export const useSetTheme = () =>
|
||||
useThemeStore((state) => state.setTheme);
|
||||
@@ -99,3 +99,17 @@ export const useTableColumnStore = create<TableColumnState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ===== 셀렉터 훅 =====
|
||||
|
||||
/** 특정 페이지의 컬럼 설정만 구독 */
|
||||
export const usePageColumnSettings = (pageId: string) =>
|
||||
useTableColumnStore((state) => state.pageSettings[pageId] ?? DEFAULT_PAGE_SETTINGS);
|
||||
|
||||
/** 특정 페이지의 숨김 컬럼만 구독 */
|
||||
export const useHiddenColumns = (pageId: string) =>
|
||||
useTableColumnStore((state) => state.pageSettings[pageId]?.hiddenColumns ?? []);
|
||||
|
||||
/** 특정 페이지의 컬럼 너비만 구독 */
|
||||
export const useColumnWidths = (pageId: string) =>
|
||||
useTableColumnStore((state) => state.pageSettings[pageId]?.columnWidths ?? {});
|
||||
|
||||
Reference in New Issue
Block a user