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:
유병철
2026-02-23 17:17:13 +09:00
parent 6c3572e568
commit 07374c826c
75 changed files with 1704 additions and 1376 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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();
// 수정 모드 / 복제 모드 상태

View File

@@ -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';

View File

@@ -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, () => ({

View 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>
);
}

View File

@@ -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";

View File

@@ -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>('');

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}