파견 관리 UX 고도화, 비동기 알림 트리거 연동 보강, 스마트 라우팅 반영 작업

This commit is contained in:
pray4skylark
2026-10-02 11:32:37 +09:00
parent db5ac3317d
commit 8fbd3d3d2e
7 changed files with 670 additions and 22 deletions
+18 -9
View File
@@ -37,11 +37,12 @@ interface EmployeeEntry {
interface RealAuditLog {
id: number;
actionType: string;
actorName: string;
targetName: string;
reason: string;
ipAddress: string;
action?: string;
actionType?: string;
actorName?: string;
targetName?: string;
reason?: string;
ipAddress?: string;
createdAt: string;
}
@@ -161,18 +162,26 @@ export default function AuditLogPage() {
// Map real audit logs from DB
realAuditLogs.forEach((log) => {
const act = log.action || log.actionType || 'VIEW_PHONE';
let type: AuditEntry['type'] = 'PRIVACY';
let title = `로그: ${log.actionType}`;
if (log.actionType === 'VIEW_PHONE') {
const actor = log.actorName || '사용자';
const target = log.targetName || '직원';
let title = `비상 연락처 열람 — [${actor}] → [${target}]`;
if (act === 'VIEW_PHONE') {
type = 'PRIVACY';
title = `비상 연락처 열람 — [${log.actorName}] → [${log.targetName}]`;
title = `비상 연락처 열람 — [${actor}] → [${target}]`;
} else {
title = `감사 로그 (${act}) — [${actor}]`;
}
entries.push({
id: `db-audit-${log.id}`,
time: log.createdAt,
type,
title,
message: log.reason ? `열람 사유: ${log.reason} ${log.ipAddress ? `(IP: ${log.ipAddress})` : ''}` : `IP: ${log.ipAddress}`,
message: log.reason ? `열람 사유: ${log.reason} ${log.ipAddress ? `(IP: ${log.ipAddress})` : ''}` : (log.ipAddress ? `IP: ${log.ipAddress}` : ''),
});
});
+508
View File
@@ -0,0 +1,508 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Settings, Plus, Edit2, Trash2, Shield, AlertCircle, Save, X, RefreshCw } from 'lucide-react';
import { useAuthStore } from '@/stores';
import commonCodeService, { CommonCodeGroupItem, CommonCodeRequest } from '@/services/commonCodeService';
import type { CommonCode } from '@/types';
export default function SettingsPage() {
const user = useAuthStore((s) => s.user);
const [groups, setGroups] = useState<CommonCodeGroupItem[]>([]);
const [selectedGroup, setSelectedGroup] = useState<string>('');
const [codes, setCodes] = useState<CommonCode[]>([]);
const [loading, setLoading] = useState(true);
const [codesLoading, setCodesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Group Modal State
const [groupModalOpen, setGroupModalOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<CommonCodeGroupItem | null>(null);
const [groupForm, setGroupForm] = useState<CommonCodeGroupItem>({ groupCode: '', groupName: '', description: '', sortOrder: 1 });
// Code Modal State
const [codeModalOpen, setCodeModalOpen] = useState(false);
const [editingCode, setEditingCode] = useState<CommonCode | null>(null);
const [codeForm, setCodeForm] = useState<CommonCodeRequest>({
groupCode: '',
codeValue: '',
codeName: '',
optionVal1: '',
optionVal2: '',
sortOrder: 1,
isActive: 'Y',
});
useEffect(() => {
fetchGroups();
}, []);
useEffect(() => {
if (selectedGroup) {
fetchCodes(selectedGroup);
} else {
setCodes([]);
}
}, [selectedGroup]);
const fetchGroups = async () => {
try {
setLoading(true);
const res = await commonCodeService.getGroups();
if (res.success && res.data) {
setGroups(res.data);
if (res.data.length > 0 && !selectedGroup) {
setSelectedGroup(res.data[0].groupCode);
}
}
} catch (err) {
setError('공통 코드 그룹 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const fetchCodes = async (groupCode: string) => {
try {
setCodesLoading(true);
const res = await commonCodeService.getByGroup(groupCode);
if (res.success && res.data) {
setCodes(res.data);
}
} catch (err) {
console.error(err);
} finally {
setCodesLoading(false);
}
};
// Group Actions
const handleOpenGroupModal = (group?: CommonCodeGroupItem) => {
if (group) {
setEditingGroup(group);
setGroupForm({ ...group });
} else {
setEditingGroup(null);
setGroupForm({ groupCode: '', groupName: '', description: '', sortOrder: groups.length + 1 });
}
setGroupModalOpen(true);
};
const handleSaveGroup = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingGroup) {
await commonCodeService.updateGroup(editingGroup.groupCode, groupForm);
} else {
await commonCodeService.createGroup(groupForm);
}
setGroupModalOpen(false);
fetchGroups();
} catch (err: any) {
alert(err.response?.data?.message || '그룹 저장에 실패했습니다.');
}
};
const handleDeleteGroup = async (groupCode: string) => {
if (!confirm(`'${groupCode}' 그룹을 삭제하시겠습니까? (하위 코드가 없어야 삭제 가능합니다)`)) return;
try {
await commonCodeService.deleteGroup(groupCode);
if (selectedGroup === groupCode) setSelectedGroup('');
fetchGroups();
} catch (err: any) {
alert(err.response?.data?.message || '그룹 삭제에 실패했습니다.');
}
};
// Code Actions
const handleOpenCodeModal = (code?: CommonCode) => {
if (code) {
setEditingCode(code);
setCodeForm({
groupCode: code.groupCode,
codeValue: code.codeValue,
codeName: code.codeName,
optionVal1: code.optionVal1 || '',
optionVal2: code.optionVal2 || '',
sortOrder: code.sortOrder,
isActive: code.isActive,
});
} else {
setEditingCode(null);
setCodeForm({
groupCode: selectedGroup,
codeValue: '',
codeName: '',
optionVal1: '',
optionVal2: '',
sortOrder: codes.length + 1,
isActive: 'Y',
});
}
setCodeModalOpen(true);
};
const handleSaveCode = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingCode) {
await commonCodeService.updateCode(editingCode.codeId, codeForm);
} else {
await commonCodeService.createCode(codeForm);
}
setCodeModalOpen(false);
fetchCodes(selectedGroup);
} catch (err: any) {
alert(err.response?.data?.message || '코드 저장에 실패했습니다.');
}
};
const handleDeleteCode = async (codeId: number) => {
if (!confirm('해당 공통 코드를 삭제하시겠습니까?')) return;
try {
await commonCodeService.deleteCode(codeId);
fetchCodes(selectedGroup);
} catch (err: any) {
alert(err.response?.data?.message || '코드 삭제에 실패했습니다.');
}
};
if (!user || user.roleCode !== 'ADMIN') {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto text-center py-20">
<AlertCircle className="w-12 h-12 text-error mx-auto mb-4" />
<h2 className="text-xl font-semibold text-text-main mb-2">접근 권한 제한</h2>
<p className="text-text-muted text-sm">시스템 설정 페이지는 관리자(ADMIN) 전용 메뉴입니다.</p>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="font-outfit text-2xl font-semibold text-text-main flex items-center gap-2">
<Settings className="w-6 h-6 text-primary" />
시스템 설정 & 공통 코드 관리
</h1>
<p className="text-text-muted text-sm mt-1">시스템에서 사용되는 분류 코드 및 그룹을 관리합니다.</p>
</div>
</div>
{/* Content Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Code Groups */}
<div className="bg-surface border border-border rounded-2xl p-5 shadow-soft flex flex-col h-[650px]">
<div className="flex items-center justify-between mb-4">
<h2 className="font-outfit text-base font-semibold text-text-main">코드 그룹 목록</h2>
<button
onClick={() => handleOpenGroupModal()}
className="p-1.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
title="그룹 추가"
>
<Plus className="w-4 h-4" />
</button>
</div>
{loading ? (
<div className="flex-1 flex items-center justify-center">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
</div>
) : groups.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
등록된 그룹이 없습니다.
</div>
) : (
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{groups.map((g) => (
<div
key={g.groupCode}
onClick={() => setSelectedGroup(g.groupCode)}
className={`p-3 rounded-xl border transition-all cursor-pointer flex items-center justify-between ${
selectedGroup === g.groupCode
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border hover:bg-gray-50'
}`}
>
<div>
<p className="font-semibold text-sm text-text-main">{g.groupName}</p>
<p className="text-xs text-text-muted font-mono">{g.groupCode}</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
handleOpenGroupModal(g);
}}
className="p-1 text-text-muted hover:text-primary rounded-md hover:bg-gray-200"
title="수정"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteGroup(g.groupCode);
}}
className="p-1 text-text-muted hover:text-error rounded-md hover:bg-gray-200"
title="삭제"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Right: Codes in selected group */}
<div className="lg:col-span-2 bg-surface border border-border rounded-2xl p-5 shadow-soft flex flex-col h-[650px]">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border">
<div>
<h2 className="font-outfit text-base font-semibold text-text-main">
{selectedGroup ? `'${selectedGroup}' 세부 코드` : '세부 코드'}
</h2>
<p className="text-xs text-text-muted">선택된 그룹의 공통 코드 항목들입니다.</p>
</div>
{selectedGroup && (
<button
onClick={() => handleOpenCodeModal()}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary text-white rounded-xl text-xs font-semibold hover:bg-primary/90 transition-colors shadow-soft"
>
<Plus className="w-4 h-4" />
코드 추가
</button>
)}
</div>
{codesLoading ? (
<div className="flex-1 flex items-center justify-center">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
</div>
) : !selectedGroup ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
왼쪽에서 그룹을 선택해 주세요.
</div>
) : codes.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
등록된 상세 코드가 없습니다.
</div>
) : (
<div className="flex-1 overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 text-xs font-semibold text-text-muted uppercase border-b border-border">
<th className="py-2.5 px-3">코드 값</th>
<th className="py-2.5 px-3">코드 명</th>
<th className="py-2.5 px-3">옵션 1</th>
<th className="py-2.5 px-3">순서</th>
<th className="py-2.5 px-3">사용 여부</th>
<th className="py-2.5 px-3 text-right">작업</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm">
{codes.map((c) => (
<tr key={c.codeId} className="hover:bg-gray-50/80 transition-colors">
<td className="py-3 px-3 font-mono font-medium text-text-main">{c.codeValue}</td>
<td className="py-3 px-3 font-semibold text-text-main">{c.codeName}</td>
<td className="py-3 px-3 text-text-muted text-xs">{c.optionVal1 || '-'}</td>
<td className="py-3 px-3 text-text-muted">{c.sortOrder}</td>
<td className="py-3 px-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-bold ${
c.isActive === 'Y'
? 'bg-emerald-100 text-emerald-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{c.isActive}
</span>
</td>
<td className="py-3 px-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleOpenCodeModal(c)}
className="p-1 text-text-muted hover:text-primary rounded-md hover:bg-gray-200"
title="수정"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeleteCode(c.codeId)}
className="p-1 text-text-muted hover:text-error rounded-md hover:bg-gray-200"
title="삭제"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* Group Modal */}
{groupModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-surface border border-border rounded-2xl shadow-soft w-full max-w-md p-6 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-3">
<h3 className="font-outfit text-lg font-semibold text-text-main">
{editingGroup ? '코드 그룹 수정' : '코드 그룹 추가'}
</h3>
<button onClick={() => setGroupModalOpen(false)} className="text-text-muted hover:text-text-main">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveGroup} className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1">그룹 코드 *</label>
<input
type="text"
disabled={!!editingGroup}
value={groupForm.groupCode}
onChange={(e) => setGroupForm({ ...groupForm, groupCode: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 border border-border rounded-xl text-sm uppercase disabled:bg-gray-100"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1">그룹 명 *</label>
<input
type="text"
value={groupForm.groupName}
onChange={(e) => setGroupForm({ ...groupForm, groupName: e.target.value })}
className="w-full px-3 py-2 border border-border rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1">설명</label>
<textarea
value={groupForm.description || ''}
onChange={(e) => setGroupForm({ ...groupForm, description: e.target.value })}
className="w-full px-3 py-2 border border-border rounded-xl text-sm"
rows={2}
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setGroupModalOpen(false)}
className="flex-1 py-2 border border-border rounded-xl text-sm text-text-muted"
>
취소
</button>
<button type="submit" className="flex-1 py-2 bg-primary text-white rounded-xl text-sm font-bold">
저장
</button>
</div>
</form>
</div>
</div>
)}
{/* Code Modal */}
{codeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-surface border border-border rounded-2xl shadow-soft w-full max-w-md p-6 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-3">
<h3 className="font-outfit text-lg font-semibold text-text-main">
{editingCode ? '공통 코드 수정' : '공통 코드 추가'}
</h3>
<button onClick={() => setCodeModalOpen(false)} className="text-text-muted hover:text-text-main">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveCode} className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1">그룹 코드</label>
<input
type="text"
disabled
value={codeForm.groupCode}
className="w-full px-3 py-2 border border-border rounded-xl text-sm bg-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1">코드 값 *</label>
<input
type="text"
disabled={!!editingCode}
value={codeForm.codeValue}
onChange={(e) => setCodeForm({ ...codeForm, codeValue: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 border border-border rounded-xl text-sm uppercase disabled:bg-gray-100"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1">코드 명 *</label>
<input
type="text"
value={codeForm.codeName}
onChange={(e) => setCodeForm({ ...codeForm, codeName: e.target.value })}
className="w-full px-3 py-2 border border-border rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1">옵션 값 1</label>
<input
type="text"
value={codeForm.optionVal1 || ''}
onChange={(e) => setCodeForm({ ...codeForm, optionVal1: e.target.value })}
className="w-full px-3 py-2 border border-border rounded-xl text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1">정렬 순서</label>
<input
type="number"
value={codeForm.sortOrder || 1}
onChange={(e) => setCodeForm({ ...codeForm, sortOrder: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-border rounded-xl text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1">사용 여부</label>
<select
value={codeForm.isActive || 'Y'}
onChange={(e) => setCodeForm({ ...codeForm, isActive: e.target.value as 'Y' | 'N' })}
className="w-full px-3 py-2 border border-border rounded-xl text-sm"
>
<option value="Y">Y (사용)</option>
<option value="N">N (미사용)</option>
</select>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setCodeModalOpen(false)}
className="flex-1 py-2 border border-border rounded-xl text-sm text-text-muted"
>
취소
</button>
<button type="submit" className="flex-1 py-2 bg-primary text-white rounded-xl text-sm font-bold">
저장
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}