From 8fbd3d3d2e5a90f1fd82cb160636a1eae2b0cc86 Mon Sep 17 00:00:00 2001 From: pray4skylark Date: Fri, 2 Oct 2026 11:32:37 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=EA=B2=AC=20=EA=B4=80=EB=A6=AC=20UX=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94,=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B3=B4=EA=B0=95,=20=EC=8A=A4=EB=A7=88=ED=8A=B8?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=B0=98=EC=98=81=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DispatchHistoryService.java | 30 +- .../com/srsystem/service/ReportService.java | 17 +- .../com/srsystem/service/TicketService.java | 23 + src/app/(main)/settings/audit/page.tsx | 27 +- src/app/(main)/settings/page.tsx | 508 ++++++++++++++++++ .../layout/NotificationDropdown.tsx | 39 +- src/services/commonCodeService.ts | 48 +- 7 files changed, 670 insertions(+), 22 deletions(-) create mode 100644 src/app/(main)/settings/page.tsx diff --git a/backend/src/main/java/com/srsystem/service/DispatchHistoryService.java b/backend/src/main/java/com/srsystem/service/DispatchHistoryService.java index 1cd7b37..eb760f3 100644 --- a/backend/src/main/java/com/srsystem/service/DispatchHistoryService.java +++ b/backend/src/main/java/com/srsystem/service/DispatchHistoryService.java @@ -26,6 +26,7 @@ public class DispatchHistoryService { private final DispatchHistoryRepository dispatchHistoryRepository; private final EmployeeRepository employeeRepository; private final ProjectRepository projectRepository; + private final NotificationPublisher notificationPublisher; public List findByEmployeeId(Long empId) { employeeRepository.findById(empId) @@ -78,7 +79,22 @@ public class DispatchHistoryService { .startDate(request.startDate() != null ? request.startDate() : LocalDate.now()) .build(); - return dispatchHistoryRepository.save(history); + DispatchHistory saved = dispatchHistoryRepository.save(history); + + // Update employee dispatch status to DISPATCHED + employee.setDispatchStatus(com.srsystem.entity.enums.DispatchStatus.DISPATCHED); + employeeRepository.save(employee); + + // Send notification + notificationPublisher.publish(new com.srsystem.dto.NotificationEvent( + "DISPATCH_ASSIGNED", + "신규 파견 발령: " + project.getProjectName(), + String.format("'%s' 프로젝트에 %s 역할로 파견 등록되었습니다.", project.getProjectName(), request.roleTitle()), + "/dispatch", + employee.getEmpId() + )); + + return saved; } @Transactional @@ -86,6 +102,16 @@ public class DispatchHistoryService { DispatchHistory history = dispatchHistoryRepository.findById(dispatchId) .orElseThrow(() -> new BusinessException(ErrorCode.DISPATCH_NOT_FOUND)); history.setEndDate(LocalDate.now()); - return dispatchHistoryRepository.save(history); + DispatchHistory saved = dispatchHistoryRepository.save(history); + + // Check active dispatches remaining + List active = dispatchHistoryRepository.findByEmployee_EmpIdAndEndDateIsNull(history.getEmployee().getEmpId()); + if (active.isEmpty()) { + Employee emp = history.getEmployee(); + emp.setDispatchStatus(com.srsystem.entity.enums.DispatchStatus.WAITING); + employeeRepository.save(emp); + } + + return saved; } } diff --git a/backend/src/main/java/com/srsystem/service/ReportService.java b/backend/src/main/java/com/srsystem/service/ReportService.java index 6d1b6ff..829f081 100644 --- a/backend/src/main/java/com/srsystem/service/ReportService.java +++ b/backend/src/main/java/com/srsystem/service/ReportService.java @@ -159,13 +159,20 @@ public class ReportService { Report updated = reportRepository.save(report); - // Send notification if rejected - if (request.status() == ReportStatus.REJECTED) { + // Send notification for approval or rejection + if (request.status() == ReportStatus.REJECTED || request.status() == ReportStatus.APPROVED) { + String type = request.status() == ReportStatus.APPROVED ? "REPORT_APPROVED" : "REPORT_REJECTED"; + String title = request.status() == ReportStatus.APPROVED ? "업무 보고서 승인 완료" : "업무 보고서 반려 안내"; + String message = request.status() == ReportStatus.APPROVED + ? String.format("%s 일자 보고서가 승인되었습니다.", report.getTargetDate()) + : String.format("%s 일자 보고서가 반려되었습니다. 사유: %s", report.getTargetDate(), request.reviewComment()); + Notification notification = Notification.builder() .user(report.getUser()) - .type("REPORT_REJECTED") - .title("Daily report rejected") - .message(String.format("Your report for %s was rejected. Reason: %s", report.getTargetDate(), request.reviewComment())) + .type(type) + .title(title) + .message(message) + .linkUrl("/work-reports") .isRead('N') .build(); notificationRepository.save(notification); diff --git a/backend/src/main/java/com/srsystem/service/TicketService.java b/backend/src/main/java/com/srsystem/service/TicketService.java index 5277915..d73b640 100644 --- a/backend/src/main/java/com/srsystem/service/TicketService.java +++ b/backend/src/main/java/com/srsystem/service/TicketService.java @@ -33,6 +33,7 @@ public class TicketService { private final TicketCommentRepository ticketCommentRepository; private final EmployeeRepository employeeRepository; private final ProjectRepository projectRepository; + private final com.srsystem.listener.NotificationPublisher notificationPublisher; public Page getTickets(TicketStatus status, String category, String priority, String keyword, Pageable pageable, Long requesterId) { Page tickets; @@ -132,6 +133,16 @@ public class TicketService { } Ticket updated = ticketRepository.save(ticket); + + // Send notification to assignee + notificationPublisher.publish(new com.srsystem.dto.NotificationEvent( + "TICKET_ASSIGNED", + "SR 티켓 담당 지정: #" + updated.getTicketId(), + String.format("'%s' 티켓 담당자로 지정되었습니다.", updated.getTitle()), + "/issues", + assignee.getEmpId() + )); + return toIssueTicket(updated); } @@ -149,6 +160,18 @@ public class TicketService { ticket.setResolutionNote(resolutionNote); Ticket updated = ticketRepository.save(ticket); + + // Send notification to requester + if (updated.getRequester() != null) { + notificationPublisher.publish(new com.srsystem.dto.NotificationEvent( + "TICKET_RESOLVED", + "SR 티켓 처리 완료: #" + updated.getTicketId(), + String.format("요청하신 '%s' 티켓이 처리 완료되었습니다.", updated.getTitle()), + "/issues", + updated.getRequester().getEmpId() + )); + } + return toIssueTicket(updated); } diff --git a/src/app/(main)/settings/audit/page.tsx b/src/app/(main)/settings/audit/page.tsx index 3f0c259..9c85074 100644 --- a/src/app/(main)/settings/audit/page.tsx +++ b/src/app/(main)/settings/audit/page.tsx @@ -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}` : ''), }); }); diff --git a/src/app/(main)/settings/page.tsx b/src/app/(main)/settings/page.tsx new file mode 100644 index 0000000..51c5236 --- /dev/null +++ b/src/app/(main)/settings/page.tsx @@ -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([]); + const [selectedGroup, setSelectedGroup] = useState(''); + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(true); + const [codesLoading, setCodesLoading] = useState(false); + const [error, setError] = useState(null); + + // Group Modal State + const [groupModalOpen, setGroupModalOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [groupForm, setGroupForm] = useState({ groupCode: '', groupName: '', description: '', sortOrder: 1 }); + + // Code Modal State + const [codeModalOpen, setCodeModalOpen] = useState(false); + const [editingCode, setEditingCode] = useState(null); + const [codeForm, setCodeForm] = useState({ + 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 ( +
+
+ +

접근 권한 제한

+

시스템 설정 페이지는 관리자(ADMIN) 전용 메뉴입니다.

+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+

+ + 시스템 설정 & 공통 코드 관리 +

+

시스템에서 사용되는 분류 코드 및 그룹을 관리합니다.

+
+
+ + {/* Content Layout */} +
+ {/* Left: Code Groups */} +
+
+

코드 그룹 목록

+ +
+ + {loading ? ( +
+ +
+ ) : groups.length === 0 ? ( +
+ 등록된 그룹이 없습니다. +
+ ) : ( +
+ {groups.map((g) => ( +
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' + }`} + > +
+

{g.groupName}

+

{g.groupCode}

+
+
+ + +
+
+ ))} +
+ )} +
+ + {/* Right: Codes in selected group */} +
+
+
+

+ {selectedGroup ? `'${selectedGroup}' 세부 코드` : '세부 코드'} +

+

선택된 그룹의 공통 코드 항목들입니다.

+
+ {selectedGroup && ( + + )} +
+ + {codesLoading ? ( +
+ +
+ ) : !selectedGroup ? ( +
+ 왼쪽에서 그룹을 선택해 주세요. +
+ ) : codes.length === 0 ? ( +
+ 등록된 상세 코드가 없습니다. +
+ ) : ( +
+ + + + + + + + + + + + + {codes.map((c) => ( + + + + + + + + + ))} + +
코드 값코드 명옵션 1순서사용 여부작업
{c.codeValue}{c.codeName}{c.optionVal1 || '-'}{c.sortOrder} + + {c.isActive} + + +
+ + +
+
+
+ )} +
+
+
+ + {/* Group Modal */} + {groupModalOpen && ( +
+
+
+

+ {editingGroup ? '코드 그룹 수정' : '코드 그룹 추가'} +

+ +
+ +
+
+ + 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 + /> +
+
+ + setGroupForm({ ...groupForm, groupName: e.target.value })} + className="w-full px-3 py-2 border border-border rounded-xl text-sm" + required + /> +
+
+ +