feat(WEB): 직급/직책 관리 API 연동 완료
- RankManagement: 직급 목록/생성/수정/삭제 API 연동 - TitleManagement: 직책 목록/생성/수정/삭제 API 연동 - RankDialog/TitleDialog 폼 유효성 검사 개선 - 정렬 순서, 활성화 상태 관리 기능 추가
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { TitleDialogProps } from './types';
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,8 @@ export function TitleDialog({
|
||||
onOpenChange,
|
||||
mode,
|
||||
title,
|
||||
onSubmit
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: TitleDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
@@ -65,15 +67,19 @@ export function TitleDialog({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="직책명을 입력하세요"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
<Button type="submit" disabled={!name.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Briefcase, Plus, GripVertical, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Briefcase, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -16,28 +16,38 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTitle as AlertTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { Title } from './types';
|
||||
import {
|
||||
fetchTitles,
|
||||
createTitle,
|
||||
updatePosition,
|
||||
deletePosition,
|
||||
reorderPositions,
|
||||
type Position,
|
||||
} from '@/lib/api/positions';
|
||||
|
||||
/**
|
||||
* 기본 직책 데이터 (PDF 52페이지 기준)
|
||||
* Position → Title 변환
|
||||
*/
|
||||
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 },
|
||||
];
|
||||
function positionToTitle(position: Position): Title {
|
||||
return {
|
||||
id: position.id,
|
||||
name: position.name,
|
||||
order: position.sort_order,
|
||||
isActive: position.is_active,
|
||||
createdAt: position.created_at,
|
||||
updatedAt: position.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function TitleManagement() {
|
||||
// 직책 데이터
|
||||
const [titles, setTitles] = useState<Title[]>(defaultTitles);
|
||||
const [titles, setTitles] = useState<Title[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 입력 필드
|
||||
const [newTitleName, setNewTitleName] = useState('');
|
||||
@@ -54,19 +64,41 @@ export function TitleManagement() {
|
||||
// 드래그 상태
|
||||
const [draggedItem, setDraggedItem] = useState<number | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadTitles = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const positions = await fetchTitles();
|
||||
setTitles(positions.map(positionToTitle));
|
||||
} catch (error) {
|
||||
console.error('직책 목록 조회 실패:', error);
|
||||
toast.error('직책 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadTitles();
|
||||
}, [loadTitles]);
|
||||
|
||||
// 직책 추가 (입력 필드에서 직접)
|
||||
const handleQuickAdd = () => {
|
||||
if (!newTitleName.trim()) return;
|
||||
const handleQuickAdd = async () => {
|
||||
if (!newTitleName.trim() || isSubmitting) 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('');
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const newPosition = await createTitle({ name: newTitleName.trim() });
|
||||
setTitles(prev => [...prev, positionToTitle(newPosition)]);
|
||||
setNewTitleName('');
|
||||
toast.success('직책이 추가되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('직책 추가 실패:', error);
|
||||
toast.error('직책 추가에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 직책 수정 다이얼로그 열기
|
||||
@@ -83,20 +115,40 @@ export function TitleManagement() {
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const confirmDelete = () => {
|
||||
if (titleToDelete) {
|
||||
const confirmDelete = async () => {
|
||||
if (!titleToDelete || isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await deletePosition(titleToDelete.id);
|
||||
setTitles(prev => prev.filter(t => t.id !== titleToDelete.id));
|
||||
toast.success('직책이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('직책 삭제 실패:', error);
|
||||
toast.error('직책 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setTitleToDelete(null);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setTitleToDelete(null);
|
||||
};
|
||||
|
||||
// 다이얼로그 제출
|
||||
const handleDialogSubmit = (name: string) => {
|
||||
const handleDialogSubmit = async (name: string) => {
|
||||
if (dialogMode === 'edit' && selectedTitle) {
|
||||
setTitles(prev => prev.map(t =>
|
||||
t.id === selectedTitle.id ? { ...t, name } : t
|
||||
));
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await updatePosition(selectedTitle.id, { name });
|
||||
setTitles(prev => prev.map(t =>
|
||||
t.id === selectedTitle.id ? { ...t, name } : t
|
||||
));
|
||||
toast.success('직책이 수정되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('직책 수정 실패:', error);
|
||||
toast.error('직책 수정에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
@@ -107,9 +159,26 @@ export function TitleManagement() {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = () => {
|
||||
// 드래그 종료 - 서버에 순서 저장
|
||||
const handleDragEnd = async () => {
|
||||
if (draggedItem === null) return;
|
||||
|
||||
setDraggedItem(null);
|
||||
|
||||
// 순서 변경 API 호출
|
||||
try {
|
||||
const items = titles.map((title, idx) => ({
|
||||
id: title.id,
|
||||
sort_order: idx + 1,
|
||||
}));
|
||||
await reorderPositions(items);
|
||||
toast.success('순서가 변경되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('순서 변경 실패:', error);
|
||||
toast.error('순서 변경에 실패했습니다.');
|
||||
// 실패시 원래 순서로 복구
|
||||
loadTitles();
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
@@ -122,7 +191,7 @@ export function TitleManagement() {
|
||||
newTitles.splice(draggedItem, 1);
|
||||
newTitles.splice(index, 0, draggedTitle);
|
||||
|
||||
// 순서 업데이트
|
||||
// 순서 업데이트 (로컬)
|
||||
const reorderedTitles = newTitles.map((title, idx) => ({
|
||||
...title,
|
||||
order: idx + 1
|
||||
@@ -158,9 +227,17 @@ export function TitleManagement() {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="직책명을 입력하세요"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button onClick={handleQuickAdd} disabled={!newTitleName.trim()}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<Button
|
||||
onClick={handleQuickAdd}
|
||||
disabled={!newTitleName.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
@@ -170,59 +247,68 @@ export function TitleManagement() {
|
||||
{/* 직책 목록 */}
|
||||
<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" />
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<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="text-sm text-muted-foreground w-8">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* 직책명 */}
|
||||
<span className="flex-1 font-medium">{title.name}</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 className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(title)}
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<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"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
|
||||
{titles.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground">
|
||||
등록된 직책이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{titles.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground">
|
||||
등록된 직책이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -239,15 +325,16 @@ export function TitleManagement() {
|
||||
mode={dialogMode}
|
||||
title={selectedTitle}
|
||||
onSubmit={handleDialogSubmit}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>직책 삭제</AlertDialogTitle>
|
||||
<AlertTitle>직책 삭제</AlertTitle>
|
||||
<AlertDialogDescription>
|
||||
"{titleToDelete?.name}" 직책을 삭제하시겠습니까?
|
||||
"{titleToDelete?.name}" 직책을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
이 직책을 사용 중인 사원이 있으면 해당 사원의 직책이 초기화됩니다.
|
||||
@@ -255,11 +342,15 @@ export function TitleManagement() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isSubmitting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isSubmitting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Title {
|
||||
id: number;
|
||||
name: string;
|
||||
order: number;
|
||||
isActive?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -15,4 +16,5 @@ export interface TitleDialogProps {
|
||||
mode: 'add' | 'edit';
|
||||
title?: Title;
|
||||
onSubmit: (name: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user