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:
84
src/components/settings/RankManagement/RankDialog.tsx
Normal file
84
src/components/settings/RankManagement/RankDialog.tsx
Normal 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 { RankDialogProps } from './types';
|
||||
|
||||
/**
|
||||
* 직급 추가/수정 다이얼로그
|
||||
*/
|
||||
export function RankDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode,
|
||||
rank,
|
||||
onSubmit
|
||||
}: 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' ? '등록' : '수정';
|
||||
|
||||
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)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
272
src/components/settings/RankManagement/index.tsx
Normal file
272
src/components/settings/RankManagement/index.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Award, 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 { RankDialog } from './RankDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Rank } from './types';
|
||||
|
||||
/**
|
||||
* 기본 직급 데이터 (PDF 51페이지 기준)
|
||||
*/
|
||||
const defaultRanks: Rank[] = [
|
||||
{ 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 },
|
||||
{ id: 10, name: '사장', order: 10 },
|
||||
{ id: 11, name: '회장', order: 11 },
|
||||
];
|
||||
|
||||
export function RankManagement() {
|
||||
// 직급 데이터
|
||||
const [ranks, setRanks] = useState<Rank[]>(defaultRanks);
|
||||
|
||||
// 입력 필드
|
||||
const [newRankName, setNewRankName] = useState('');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
|
||||
const [selectedRank, setSelectedRank] = useState<Rank | undefined>();
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [rankToDelete, setRankToDelete] = useState<Rank | null>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [draggedItem, setDraggedItem] = useState<number | null>(null);
|
||||
|
||||
// 직급 추가 (입력 필드에서 직접)
|
||||
const handleQuickAdd = () => {
|
||||
if (!newRankName.trim()) return;
|
||||
|
||||
const newId = Math.max(...ranks.map(r => r.id), 0) + 1;
|
||||
const newOrder = Math.max(...ranks.map(r => r.order), 0) + 1;
|
||||
|
||||
setRanks(prev => [...prev, {
|
||||
id: newId,
|
||||
name: newRankName.trim(),
|
||||
order: newOrder,
|
||||
}]);
|
||||
setNewRankName('');
|
||||
};
|
||||
|
||||
// 직급 수정 다이얼로그 열기
|
||||
const handleEdit = (rank: Rank) => {
|
||||
setSelectedRank(rank);
|
||||
setDialogMode('edit');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 직급 삭제 확인
|
||||
const handleDelete = (rank: Rank) => {
|
||||
setRankToDelete(rank);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const confirmDelete = () => {
|
||||
if (rankToDelete) {
|
||||
setRanks(prev => prev.filter(r => r.id !== rankToDelete.id));
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setRankToDelete(null);
|
||||
};
|
||||
|
||||
// 다이얼로그 제출
|
||||
const handleDialogSubmit = (name: string) => {
|
||||
if (dialogMode === 'edit' && selectedRank) {
|
||||
setRanks(prev => prev.map(r =>
|
||||
r.id === selectedRank.id ? { ...r, name } : r
|
||||
));
|
||||
}
|
||||
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 newRanks = [...ranks];
|
||||
const draggedRank = newRanks[draggedItem];
|
||||
newRanks.splice(draggedItem, 1);
|
||||
newRanks.splice(index, 0, draggedRank);
|
||||
|
||||
// 순서 업데이트
|
||||
const reorderedRanks = newRanks.map((rank, idx) => ({
|
||||
...rank,
|
||||
order: idx + 1
|
||||
}));
|
||||
|
||||
setRanks(reorderedRanks);
|
||||
setDraggedItem(index);
|
||||
};
|
||||
|
||||
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
handleQuickAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="직급관리"
|
||||
description="사원의 직급을 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
|
||||
icon={Award}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 직급 추가 입력 영역 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newRankName}
|
||||
onChange={(e) => setNewRankName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="직급명을 입력하세요"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleQuickAdd} disabled={!newRankName.trim()}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 직급 목록 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{ranks.map((rank, index) => (
|
||||
<div
|
||||
key={rank.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">{rank.name}</span>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(rank)}
|
||||
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(rank)}
|
||||
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>
|
||||
))}
|
||||
|
||||
{ranks.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>
|
||||
|
||||
{/* 수정 다이얼로그 */}
|
||||
<RankDialog
|
||||
isOpen={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode={dialogMode}
|
||||
rank={selectedRank}
|
||||
onSubmit={handleDialogSubmit}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>직급 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{rankToDelete?.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>
|
||||
);
|
||||
}
|
||||
18
src/components/settings/RankManagement/types.ts
Normal file
18
src/components/settings/RankManagement/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 직급 타입 정의
|
||||
*/
|
||||
export interface Rank {
|
||||
id: number;
|
||||
name: string;
|
||||
order: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RankDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'add' | 'edit';
|
||||
rank?: Rank;
|
||||
onSubmit: (name: string) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user