fix: TypeScript 타입 오류 수정 및 설정 페이지 추가

- BOMItem Omit 타입 시그니처 통일 (useTemplateManagement, SectionsTab, ItemMasterContext)
- HeadersInit → Record<string, string> 타입 변경
- Zustand useShallow 마이그레이션 (zustand/react/shallow)
- DataTable, ListPageTemplate 제네릭 타입 제약 추가
- 설정 관리 페이지 추가 (직급, 직책, 휴가정책, 근무일정, 권한)
- HR 관리 페이지 추가 (급여, 휴가)
- 단가관리 페이지 리팩토링

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-09 18:07:47 +09:00
parent 48dbba0e5f
commit ded0bc2439
98 changed files with 10608 additions and 1204 deletions

View File

@@ -0,0 +1,84 @@
'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 type { TitleDialogProps } from './types';
/**
* 직책 추가/수정 다이얼로그
*/
export function TitleDialog({
isOpen,
onOpenChange,
mode,
title,
onSubmit
}: 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' ? '등록' : '수정';
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
/>
</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

@@ -0,0 +1,270 @@
'use client';
import { useState } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Briefcase, Plus, GripVertical, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { TitleDialog } from './TitleDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Title } from './types';
/**
* 기본 직책 데이터 (PDF 52페이지 기준)
*/
const defaultTitles: Title[] = [
{ id: 1, name: '없음(기본)', order: 1 },
{ id: 2, name: '팀장', order: 2 },
{ id: 3, name: '파트장', order: 3 },
{ id: 4, name: '실장', order: 4 },
{ id: 5, name: '부서장', order: 5 },
{ id: 6, name: '본부장', order: 6 },
{ id: 7, name: '센터장', order: 7 },
{ id: 8, name: '매니저', order: 8 },
{ id: 9, name: '리더', order: 9 },
];
export function TitleManagement() {
// 직책 데이터
const [titles, setTitles] = useState<Title[]>(defaultTitles);
// 입력 필드
const [newTitleName, setNewTitleName] = useState('');
// 다이얼로그 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
const [selectedTitle, setSelectedTitle] = useState<Title | undefined>();
// 삭제 확인 다이얼로그
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [titleToDelete, setTitleToDelete] = useState<Title | null>(null);
// 드래그 상태
const [draggedItem, setDraggedItem] = useState<number | null>(null);
// 직책 추가 (입력 필드에서 직접)
const handleQuickAdd = () => {
if (!newTitleName.trim()) return;
const newId = Math.max(...titles.map(t => t.id), 0) + 1;
const newOrder = Math.max(...titles.map(t => t.order), 0) + 1;
setTitles(prev => [...prev, {
id: newId,
name: newTitleName.trim(),
order: newOrder,
}]);
setNewTitleName('');
};
// 직책 수정 다이얼로그 열기
const handleEdit = (title: Title) => {
setSelectedTitle(title);
setDialogMode('edit');
setDialogOpen(true);
};
// 직책 삭제 확인
const handleDelete = (title: Title) => {
setTitleToDelete(title);
setDeleteDialogOpen(true);
};
// 삭제 실행
const confirmDelete = () => {
if (titleToDelete) {
setTitles(prev => prev.filter(t => t.id !== titleToDelete.id));
}
setDeleteDialogOpen(false);
setTitleToDelete(null);
};
// 다이얼로그 제출
const handleDialogSubmit = (name: string) => {
if (dialogMode === 'edit' && selectedTitle) {
setTitles(prev => prev.map(t =>
t.id === selectedTitle.id ? { ...t, name } : t
));
}
setDialogOpen(false);
};
// 드래그 시작
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedItem(index);
e.dataTransfer.effectAllowed = 'move';
};
// 드래그 종료
const handleDragEnd = () => {
setDraggedItem(null);
};
// 드래그 오버
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedItem === null || draggedItem === index) return;
const newTitles = [...titles];
const draggedTitle = newTitles[draggedItem];
newTitles.splice(draggedItem, 1);
newTitles.splice(index, 0, draggedTitle);
// 순서 업데이트
const reorderedTitles = newTitles.map((title, idx) => ({
...title,
order: idx + 1
}));
setTitles(reorderedTitles);
setDraggedItem(index);
};
// 키보드로 추가 (한글 IME 조합 중에는 무시)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
handleQuickAdd();
}
};
return (
<PageLayout>
<PageHeader
title="직책관리"
description="사원의 직책을 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
icon={Briefcase}
/>
<div className="space-y-4">
{/* 직책 추가 입력 영역 */}
<Card>
<CardContent className="p-4">
<div className="flex gap-2">
<Input
value={newTitleName}
onChange={(e) => setNewTitleName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="직책명을 입력하세요"
className="flex-1"
/>
<Button onClick={handleQuickAdd} disabled={!newTitleName.trim()}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 직책 목록 */}
<Card>
<CardContent className="p-0">
<div className="divide-y">
{titles.map((title, index) => (
<div
key={title.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
className={`flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-move ${
draggedItem === index ? 'opacity-50 bg-muted' : ''
}`}
>
{/* 드래그 핸들 */}
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0" />
{/* 순서 번호 */}
<span className="text-sm text-muted-foreground w-8">
{index + 1}
</span>
{/* 직책명 */}
<span className="flex-1 font-medium">{title.name}</span>
{/* 액션 버튼 */}
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(title)}
className="h-8 w-8 p-0"
>
<Pencil className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(title)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</div>
</div>
))}
{titles.length === 0 && (
<div className="px-4 py-8 text-center text-muted-foreground">
.
</div>
)}
</div>
</CardContent>
</Card>
{/* 안내 문구 */}
<p className="text-sm text-muted-foreground">
.
</p>
</div>
{/* 수정 다이얼로그 */}
<TitleDialog
isOpen={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
title={selectedTitle}
onSubmit={handleDialogSubmit}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{titleToDelete?.name}" ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,18 @@
/**
* 직책 타입 정의
*/
export interface Title {
id: number;
name: string;
order: number;
createdAt?: string;
updatedAt?: string;
}
export interface TitleDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
mode: 'add' | 'edit';
title?: Title;
onSubmit: (name: string) => void;
}