Files
sam-react-prod/src/components/settings/TitleManagement/index.tsx
kent 258c8e4179 refactor(WEB): 직급/직책 관리 Server Actions 전환
- 클라이언트 직접 API 호출 → Server Actions 방식으로 변경
- RankManagement/actions.ts 신규 생성
- TitleManagement/actions.ts 신규 생성
- API_KEY 환경변수를 서버에서만 사용하도록 변경 (보안 강화)
- 기존 lib/api/positions.ts 삭제
2025-12-30 23:02:52 +09:00

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>
&quot;{titleToDelete?.name}&quot; ?
<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>
);
}