diff --git a/src/components/settings/RankManagement/RankDialog.tsx b/src/components/settings/RankManagement/RankDialog.tsx index 19b468b0..c4bd2883 100644 --- a/src/components/settings/RankManagement/RankDialog.tsx +++ b/src/components/settings/RankManagement/RankDialog.tsx @@ -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 { RankDialogProps } from './types'; /** @@ -21,7 +22,8 @@ export function RankDialog({ onOpenChange, mode, rank, - onSubmit + onSubmit, + isLoading = false, }: RankDialogProps) { const [name, setName] = useState(''); @@ -70,10 +72,13 @@ export function RankDialog({ - - diff --git a/src/components/settings/RankManagement/index.tsx b/src/components/settings/RankManagement/index.tsx index ebdd13f5..0c949fe1 100644 --- a/src/components/settings/RankManagement/index.tsx +++ b/src/components/settings/RankManagement/index.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useEffect, 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 { Award, 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'; @@ -18,28 +18,36 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { toast } from 'sonner'; import type { Rank } from './types'; +import { + fetchRanks, + createRank, + updatePosition, + deletePosition, + reorderPositions, + type Position, +} from '@/lib/api/positions'; /** - * 기본 직급 데이터 (PDF 51페이지 기준) + * Position → Rank 변환 */ -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 }, -]; +function positionToRank(position: Position): Rank { + 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 RankManagement() { // 직급 데이터 - const [ranks, setRanks] = useState(defaultRanks); + const [ranks, setRanks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); // 입력 필드 const [newRankName, setNewRankName] = useState(''); @@ -56,19 +64,41 @@ export function RankManagement() { // 드래그 상태 const [draggedItem, setDraggedItem] = useState(null); + // 데이터 로드 + const loadRanks = useCallback(async () => { + try { + setIsLoading(true); + const positions = await fetchRanks(); + setRanks(positions.map(positionToRank)); + } catch (error) { + console.error('직급 목록 조회 실패:', error); + toast.error('직급 목록을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }, []); + + // 초기 데이터 로드 + useEffect(() => { + loadRanks(); + }, [loadRanks]); + // 직급 추가 (입력 필드에서 직접) - const handleQuickAdd = () => { - if (!newRankName.trim()) return; + const handleQuickAdd = async () => { + if (!newRankName.trim() || isSubmitting) 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(''); + try { + setIsSubmitting(true); + const newPosition = await createRank({ name: newRankName.trim() }); + setRanks(prev => [...prev, positionToRank(newPosition)]); + setNewRankName(''); + toast.success('직급이 추가되었습니다.'); + } catch (error) { + console.error('직급 추가 실패:', error); + toast.error('직급 추가에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } }; // 직급 수정 다이얼로그 열기 @@ -85,20 +115,40 @@ export function RankManagement() { }; // 삭제 실행 - const confirmDelete = () => { - if (rankToDelete) { + const confirmDelete = async () => { + if (!rankToDelete || isSubmitting) return; + + try { + setIsSubmitting(true); + await deletePosition(rankToDelete.id); setRanks(prev => prev.filter(r => r.id !== rankToDelete.id)); + toast.success('직급이 삭제되었습니다.'); + } catch (error) { + console.error('직급 삭제 실패:', error); + toast.error('직급 삭제에 실패했습니다.'); + } finally { + setIsSubmitting(false); + setDeleteDialogOpen(false); + setRankToDelete(null); } - setDeleteDialogOpen(false); - setRankToDelete(null); }; // 다이얼로그 제출 - const handleDialogSubmit = (name: string) => { + const handleDialogSubmit = async (name: string) => { if (dialogMode === 'edit' && selectedRank) { - setRanks(prev => prev.map(r => - r.id === selectedRank.id ? { ...r, name } : r - )); + try { + setIsSubmitting(true); + await updatePosition(selectedRank.id, { name }); + setRanks(prev => prev.map(r => + r.id === selectedRank.id ? { ...r, name } : r + )); + toast.success('직급이 수정되었습니다.'); + } catch (error) { + console.error('직급 수정 실패:', error); + toast.error('직급 수정에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } } setDialogOpen(false); }; @@ -109,9 +159,26 @@ export function RankManagement() { e.dataTransfer.effectAllowed = 'move'; }; - // 드래그 종료 - const handleDragEnd = () => { + // 드래그 종료 - 서버에 순서 저장 + const handleDragEnd = async () => { + if (draggedItem === null) return; + setDraggedItem(null); + + // 순서 변경 API 호출 + try { + const items = ranks.map((rank, idx) => ({ + id: rank.id, + sort_order: idx + 1, + })); + await reorderPositions(items); + toast.success('순서가 변경되었습니다.'); + } catch (error) { + console.error('순서 변경 실패:', error); + toast.error('순서 변경에 실패했습니다.'); + // 실패시 원래 순서로 복구 + loadRanks(); + } }; // 드래그 오버 @@ -124,7 +191,7 @@ export function RankManagement() { newRanks.splice(draggedItem, 1); newRanks.splice(index, 0, draggedRank); - // 순서 업데이트 + // 순서 업데이트 (로컬) const reorderedRanks = newRanks.map((rank, idx) => ({ ...rank, order: idx + 1 @@ -160,9 +227,17 @@ export function RankManagement() { onKeyDown={handleKeyDown} placeholder="직급명을 입력하세요" className="flex-1" + disabled={isSubmitting} /> - @@ -172,59 +247,68 @@ export function RankManagement() { {/* 직급 목록 */} -
- {ranks.map((rank, index) => ( -
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' : '' - }`} - > - {/* 드래그 핸들 */} - + {isLoading ? ( +
+ + 로딩 중... +
+ ) : ( +
+ {ranks.map((rank, index) => ( +
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' : '' + }`} + > + {/* 드래그 핸들 */} + - {/* 순서 번호 */} - - {index + 1} - + {/* 순서 번호 */} + + {index + 1} + - {/* 직급명 */} - {rank.name} + {/* 직급명 */} + {rank.name} - {/* 액션 버튼 */} -
- - + {/* 액션 버튼 */} +
+ + +
-
- ))} + ))} - {ranks.length === 0 && ( -
- 등록된 직급이 없습니다. -
- )} -
+ {ranks.length === 0 && ( +
+ 등록된 직급이 없습니다. +
+ )} +
+ )} @@ -241,6 +325,7 @@ export function RankManagement() { mode={dialogMode} rank={selectedRank} onSubmit={handleDialogSubmit} + isLoading={isSubmitting} /> {/* 삭제 확인 다이얼로그 */} @@ -249,7 +334,7 @@ export function RankManagement() { 직급 삭제 - "{rankToDelete?.name}" 직급을 삭제하시겠습니까? + "{rankToDelete?.name}" 직급을 삭제하시겠습니까?
이 직급을 사용 중인 사원이 있으면 해당 사원의 직급이 초기화됩니다. @@ -257,11 +342,15 @@ export function RankManagement() {
- 취소 + 취소 + {isSubmitting ? ( + + ) : null} 삭제 @@ -269,4 +358,4 @@ export function RankManagement() { ); -} \ No newline at end of file +} diff --git a/src/components/settings/RankManagement/types.ts b/src/components/settings/RankManagement/types.ts index d5fff92a..d59a5261 100644 --- a/src/components/settings/RankManagement/types.ts +++ b/src/components/settings/RankManagement/types.ts @@ -5,6 +5,7 @@ export interface Rank { id: number; name: string; order: number; + isActive?: boolean; createdAt?: string; updatedAt?: string; } @@ -15,4 +16,5 @@ export interface RankDialogProps { mode: 'add' | 'edit'; rank?: Rank; onSubmit: (name: string) => void; + isLoading?: boolean; } \ No newline at end of file diff --git a/src/components/settings/TitleManagement/TitleDialog.tsx b/src/components/settings/TitleManagement/TitleDialog.tsx index 8aaf118c..7329156f 100644 --- a/src/components/settings/TitleManagement/TitleDialog.tsx +++ b/src/components/settings/TitleManagement/TitleDialog.tsx @@ -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} />
- - diff --git a/src/components/settings/TitleManagement/index.tsx b/src/components/settings/TitleManagement/index.tsx index 8b835231..c8dbfb7d 100644 --- a/src/components/settings/TitleManagement/index.tsx +++ b/src/components/settings/TitleManagement/index.tsx @@ -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(defaultTitles); + const [titles, setTitles] = useState([]); + 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(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} /> - @@ -170,59 +247,68 @@ export function TitleManagement() { {/* 직책 목록 */} -
- {titles.map((title, index) => ( -
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' : '' - }`} - > - {/* 드래그 핸들 */} - + {isLoading ? ( +
+ + 로딩 중... +
+ ) : ( +
+ {titles.map((title, index) => ( +
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' : '' + }`} + > + {/* 드래그 핸들 */} + - {/* 순서 번호 */} - - {index + 1} - + {/* 순서 번호 */} + + {index + 1} + - {/* 직책명 */} - {title.name} + {/* 직책명 */} + {title.name} - {/* 액션 버튼 */} -
- - + {/* 액션 버튼 */} +
+ + +
-
- ))} + ))} - {titles.length === 0 && ( -
- 등록된 직책이 없습니다. -
- )} -
+ {titles.length === 0 && ( +
+ 등록된 직책이 없습니다. +
+ )} +
+ )} @@ -239,15 +325,16 @@ export function TitleManagement() { mode={dialogMode} title={selectedTitle} onSubmit={handleDialogSubmit} + isLoading={isSubmitting} /> {/* 삭제 확인 다이얼로그 */} - 직책 삭제 + 직책 삭제 - "{titleToDelete?.name}" 직책을 삭제하시겠습니까? + "{titleToDelete?.name}" 직책을 삭제하시겠습니까?
이 직책을 사용 중인 사원이 있으면 해당 사원의 직책이 초기화됩니다. @@ -255,11 +342,15 @@ export function TitleManagement() {
- 취소 + 취소 + {isSubmitting ? ( + + ) : null} 삭제 diff --git a/src/components/settings/TitleManagement/types.ts b/src/components/settings/TitleManagement/types.ts index 1a9b8fe4..277186b8 100644 --- a/src/components/settings/TitleManagement/types.ts +++ b/src/components/settings/TitleManagement/types.ts @@ -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; } \ No newline at end of file