- 클라이언트 직접 API 호출 → Server Actions 방식으로 변경 - RankManagement/actions.ts 신규 생성 - TitleManagement/actions.ts 신규 생성 - API_KEY 환경변수를 서버에서만 사용하도록 변경 (보안 강화) - 기존 lib/api/positions.ts 삭제
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
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';
|
|
import { TitleDialog } from './TitleDialog';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle as AlertTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { toast } from 'sonner';
|
|
import type { Title } from './types';
|
|
import {
|
|
getTitles,
|
|
createTitle,
|
|
updateTitle,
|
|
deleteTitle,
|
|
reorderTitles,
|
|
} from './actions';
|
|
|
|
export function TitleManagement() {
|
|
// 직책 데이터
|
|
const [titles, setTitles] = useState<Title[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// 입력 필드
|
|
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 loadTitles = useCallback(async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const result = await getTitles();
|
|
if (result.success && result.data) {
|
|
setTitles(result.data);
|
|
} else {
|
|
toast.error(result.error || '직책 목록을 불러오는데 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('직책 목록 조회 실패:', error);
|
|
toast.error('직책 목록을 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// 초기 데이터 로드
|
|
useEffect(() => {
|
|
loadTitles();
|
|
}, [loadTitles]);
|
|
|
|
// 직책 추가 (입력 필드에서 직접)
|
|
const handleQuickAdd = async () => {
|
|
if (!newTitleName.trim() || isSubmitting) return;
|
|
|
|
try {
|
|
setIsSubmitting(true);
|
|
const result = await createTitle({ name: newTitleName.trim() });
|
|
if (result.success && result.data) {
|
|
setTitles(prev => [...prev, result.data!]);
|
|
setNewTitleName('');
|
|
toast.success('직책이 추가되었습니다.');
|
|
} else {
|
|
toast.error(result.error || '직책 추가에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('직책 추가 실패:', error);
|
|
toast.error('직책 추가에 실패했습니다.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// 직책 수정 다이얼로그 열기
|
|
const handleEdit = (title: Title) => {
|
|
setSelectedTitle(title);
|
|
setDialogMode('edit');
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
// 직책 삭제 확인
|
|
const handleDelete = (title: Title) => {
|
|
setTitleToDelete(title);
|
|
setDeleteDialogOpen(true);
|
|
};
|
|
|
|
// 삭제 실행
|
|
const confirmDelete = async () => {
|
|
if (!titleToDelete || isSubmitting) return;
|
|
|
|
try {
|
|
setIsSubmitting(true);
|
|
const result = await deleteTitle(titleToDelete.id);
|
|
if (result.success) {
|
|
setTitles(prev => prev.filter(t => t.id !== titleToDelete.id));
|
|
toast.success('직책이 삭제되었습니다.');
|
|
} else {
|
|
toast.error(result.error || '직책 삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('직책 삭제 실패:', error);
|
|
toast.error('직책 삭제에 실패했습니다.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
setDeleteDialogOpen(false);
|
|
setTitleToDelete(null);
|
|
}
|
|
};
|
|
|
|
// 다이얼로그 제출
|
|
const handleDialogSubmit = async (name: string) => {
|
|
if (dialogMode === 'edit' && selectedTitle) {
|
|
try {
|
|
setIsSubmitting(true);
|
|
const result = await updateTitle(selectedTitle.id, { name });
|
|
if (result.success) {
|
|
setTitles(prev => prev.map(t =>
|
|
t.id === selectedTitle.id ? { ...t, name } : t
|
|
));
|
|
toast.success('직책이 수정되었습니다.');
|
|
} else {
|
|
toast.error(result.error || '직책 수정에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('직책 수정 실패:', error);
|
|
toast.error('직책 수정에 실패했습니다.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
setDialogOpen(false);
|
|
};
|
|
|
|
// 드래그 시작
|
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
|
setDraggedItem(index);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
};
|
|
|
|
// 드래그 종료 - 서버에 순서 저장
|
|
const handleDragEnd = async () => {
|
|
if (draggedItem === null) return;
|
|
|
|
setDraggedItem(null);
|
|
|
|
// 순서 변경 API 호출
|
|
try {
|
|
const items = titles.map((title, idx) => ({
|
|
id: title.id,
|
|
sort_order: idx + 1,
|
|
}));
|
|
const result = await reorderTitles(items);
|
|
if (result.success) {
|
|
toast.success('순서가 변경되었습니다.');
|
|
} else {
|
|
toast.error(result.error || '순서 변경에 실패했습니다.');
|
|
loadTitles();
|
|
}
|
|
} catch (error) {
|
|
console.error('순서 변경 실패:', error);
|
|
toast.error('순서 변경에 실패했습니다.');
|
|
// 실패시 원래 순서로 복구
|
|
loadTitles();
|
|
}
|
|
};
|
|
|
|
// 드래그 오버
|
|
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"
|
|
disabled={isSubmitting}
|
|
/>
|
|
<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>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 직책 목록 */}
|
|
<Card>
|
|
<CardContent className="p-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="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"
|
|
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>
|
|
))}
|
|
|
|
{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}
|
|
isLoading={isSubmitting}
|
|
/>
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertTitle>직책 삭제</AlertTitle>
|
|
<AlertDialogDescription>
|
|
"{titleToDelete?.name}" 직책을 삭제하시겠습니까?
|
|
<br />
|
|
<span className="text-destructive">
|
|
이 직책을 사용 중인 사원이 있으면 해당 사원의 직책이 초기화됩니다.
|
|
</span>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<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>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</PageLayout>
|
|
);
|
|
} |