diff --git a/backend/src/main/java/com/srsystem/controller/SystemConfigController.java b/backend/src/main/java/com/srsystem/controller/SystemConfigController.java new file mode 100644 index 0000000..e2b8cfa --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/SystemConfigController.java @@ -0,0 +1,62 @@ +package com.srsystem.controller; + +import com.srsystem.entity.SystemConfig; +import com.srsystem.repository.SystemConfigRepository; +import com.srsystem.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/admin/config") +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +public class SystemConfigController { + + private final SystemConfigRepository repository; + + @GetMapping + public ApiResponse> getAll() { + return ApiResponse.ok(repository.findAll()); + } + + @GetMapping("/{key}") + public ApiResponse getByKey(@PathVariable String key) { + return repository.findByConfigKey(key) + .map(ApiResponse::ok) + .orElse(ApiResponse.error("설정을 찾을 수 없습니다: " + key)); + } + + @PutMapping("/{key}") + public ApiResponse update(@PathVariable String key, @RequestBody Map body) { + String value = body.get("configValue"); + if (value == null) { + return ApiResponse.error("configValue가 필요합니다."); + } + SystemConfig config = repository.findByConfigKey(key).orElse(null); + if (config == null) { + config = SystemConfig.builder().configKey(key).createdAt(LocalDateTime.now()).build(); + } + config.setConfigValue(value); + config.setUpdatedAt(LocalDateTime.now()); + return ApiResponse.ok(repository.save(config)); + } + + @PutMapping("/batch") + public ApiResponse> batchUpdate(@RequestBody Map entries) { + entries.forEach((k, v) -> { + SystemConfig config = repository.findByConfigKey(k).orElse(null); + if (config == null) { + config = SystemConfig.builder().configKey(k).createdAt(LocalDateTime.now()).build(); + } + config.setConfigValue(v); + config.setUpdatedAt(LocalDateTime.now()); + repository.save(config); + }); + return ApiResponse.ok(repository.findAll()); + } +} diff --git a/backend/src/main/java/com/srsystem/entity/SystemConfig.java b/backend/src/main/java/com/srsystem/entity/SystemConfig.java new file mode 100644 index 0000000..8bd34cc --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/SystemConfig.java @@ -0,0 +1,36 @@ +package com.srsystem.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "tb_system_config") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SystemConfig { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_system_config") + @SequenceGenerator(name = "seq_system_config", allocationSize = 50) + @Column(name = "config_id") + private Long configId; + + @Column(name = "config_key", nullable = false, unique = true, length = 100) + private String configKey; + + @Column(name = "config_value", columnDefinition = "TEXT") + private String configValue; + + @Column(name = "description", length = 255) + private String description; + + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/srsystem/repository/SystemConfigRepository.java b/backend/src/main/java/com/srsystem/repository/SystemConfigRepository.java new file mode 100644 index 0000000..13a53c0 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/SystemConfigRepository.java @@ -0,0 +1,9 @@ +package com.srsystem.repository; + +import com.srsystem.entity.SystemConfig; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface SystemConfigRepository extends JpaRepository { + Optional findByConfigKey(String configKey); +} diff --git a/backend/src/main/resources/db/migration/V10__add_system_config.sql b/backend/src/main/resources/db/migration/V10__add_system_config.sql new file mode 100644 index 0000000..365e313 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__add_system_config.sql @@ -0,0 +1,21 @@ +CREATE TABLE tb_system_config ( + config_id BIGSERIAL PRIMARY KEY, + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value TEXT, + description VARCHAR(255), + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +INSERT INTO tb_system_config (config_key, config_value, description) VALUES +('sla_check_interval', '3600000', 'SLA 체크 간격 (ms)'), +('sla_excluded_statuses', 'RESOLVED,CLOSED', 'SLA 제외 티켓 상태'), +('parking_enabled', 'false', 'Parking 모드 활성화 여부'), +('parking_weekday_start', '20:00', 'Parking 시작 시간'), +('parking_weekday_end', '08:00', 'Parking 종료 시간'), +('parking_weekends', 'true', '주말 Parking 적용'), +('jwt_access_expiration', '3600000', 'Access Token 만료 시간 (ms)'), +('jwt_refresh_expiration', '604800000', 'Refresh Token 만료 시간 (ms)'), +('notification_slack_enabled', 'false', 'Slack 알림 활성화'), +('notification_email_enabled', 'false', 'Email 알림 활성화'), +('file_upload_max_size', '10485760', '파일 업로드 최대 크기 (bytes)'); diff --git a/src/app/(main)/settings/accounts/page.tsx b/src/app/(main)/settings/accounts/page.tsx new file mode 100644 index 0000000..1fa95c8 --- /dev/null +++ b/src/app/(main)/settings/accounts/page.tsx @@ -0,0 +1,474 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Users, Plus, Pencil, Trash2, X, Search, AlertTriangle } from 'lucide-react'; +import { useAuthStore } from '@/stores'; +import employeeService from '@/services/employeeService'; +import type { Employee, RoleCode, EmpStatus } from '@/types'; + +const roleLabels: Record = { + ADMIN: '최고관리자', + MANAGER: '본사관리자', + EMPLOYEE: '일반사용자', +}; + +const roleBadgeColor: Record = { + ADMIN: 'bg-purple-100 text-purple-700', + MANAGER: 'bg-indigo-100 text-indigo-700', + EMPLOYEE: 'bg-gray-100 text-gray-700', +}; + +const statusLabels: Record = { + ACTIVE: '재직', + ON_LEAVE: '휴직', + RESIGNED: '퇴사', +}; + +const statusBadgeColor: Record = { + ACTIVE: 'bg-status-resolved-bg text-status-resolved-text', + ON_LEAVE: 'bg-status-pending-bg text-status-pending-text', + RESIGNED: 'bg-status-critical-bg text-status-critical-text', +}; + +const initialColors = ['bg-indigo-500', 'bg-purple-500', 'bg-blue-500', 'bg-green-500', 'bg-orange-500', 'bg-pink-500']; + +function getInitialColor(name: string, index: number) { + return initialColors[index % initialColors.length]; +} + +interface CreateForm { + loginId: string; + name: string; + password: string; + email: string; + roleCode: RoleCode; + empStatus: EmpStatus; +} + +interface EditForm { + name: string; + email: string; + roleCode: RoleCode; + empStatus: EmpStatus; +} + +export default function AccountsPage() { + const user = useAuthStore((s) => s.user); + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + const [searchKeyword, setSearchKeyword] = useState(''); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [targetEmployee, setTargetEmployee] = useState(null); + const [createForm, setCreateForm] = useState({ + loginId: '', name: '', password: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', + }); + const [editForm, setEditForm] = useState({ + name: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', + }); + const [createError, setCreateError] = useState(null); + const [editError, setEditError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const fetchEmployees = async () => { + try { + setLoading(true); + const res = await employeeService.getAll({ size: 200 }); + setEmployees(res.data.content); + } catch { + // silent + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchEmployees(); + }, []); + + if (user?.roleCode !== 'ADMIN') { + return ( +
+
+
+

접근 거부. 관리자 권한이 필요합니다.

+
+
+
+ ); + } + + const filtered = searchKeyword.trim() + ? employees.filter((e) => e.name.toLowerCase().includes(searchKeyword.toLowerCase())) + : employees; + + const openCreate = () => { + setCreateForm({ loginId: '', name: '', password: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE' }); + setCreateError(null); + setShowCreateModal(true); + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!createForm.loginId.trim() || !createForm.name.trim() || !createForm.password.trim()) { + setCreateError('로그인 ID, 이름, 비밀번호는 필수입니다.'); + return; + } + setIsSubmitting(true); + setCreateError(null); + try { + await employeeService.create(createForm); + setShowCreateModal(false); + await fetchEmployees(); + } catch (err: any) { + setCreateError(err.response?.data?.message || '계정 생성에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + const openEdit = (emp: Employee) => { + setTargetEmployee(emp); + setEditForm({ name: emp.name, email: emp.email, roleCode: emp.roleCode, empStatus: emp.empStatus }); + setEditError(null); + setShowEditModal(true); + }; + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editForm.name.trim() || !targetEmployee) return; + setIsSubmitting(true); + setEditError(null); + try { + await employeeService.update(targetEmployee.empId, editForm); + setShowEditModal(false); + await fetchEmployees(); + } catch (err: any) { + setEditError(err.response?.data?.message || '수정에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + const openDelete = (emp: Employee) => { + setTargetEmployee(emp); + setShowDeleteDialog(true); + }; + + const handleDelete = async () => { + if (!targetEmployee) return; + setIsSubmitting(true); + try { + await employeeService.delete(targetEmployee.empId); + setShowDeleteDialog(false); + await fetchEmployees(); + } catch (err: any) { + alert(err.response?.data?.message || '삭제에 실패했습니다.'); + setShowDeleteDialog(false); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +
+

계정 관리

+

직원 계정 생성, 수정, 삭제 (ADMIN 전용)

+
+
+ +
+ + {/* Search */} +
+ + setSearchKeyword(e.target.value)} + className="w-full pl-9 pr-3 py-2 rounded-xl bg-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary transition-all" + placeholder="이름으로 검색..." + /> +
+ + {/* Table */} + {loading ? ( +
로딩 중...
+ ) : ( +
+
+ + + + + + + + + + + + + {filtered.map((emp, idx) => ( + + + + + + + + + ))} + +
이름이메일권한상태가입일액션
+
+
+ {emp.name[0]} +
+ {emp.name} +
+
{emp.email || '-'} + + {roleLabels[emp.roleCode]} + + + + {statusLabels[emp.empStatus]} + + {emp.joinedDate} +
+ + +
+
+
+ {filtered.length === 0 && ( +
검색 결과가 없습니다.
+ )} +
+ )} + + {/* Create Modal */} + {showCreateModal && ( +
setShowCreateModal(false)}> +
e.stopPropagation()}> +
+

새 계정 생성

+ +
+
+
+ + setCreateForm((f) => ({ ...f, loginId: e.target.value }))} + className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="login_id" + /> +
+
+ + setCreateForm((f) => ({ ...f, name: e.target.value }))} + className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="홍길동" + /> +
+
+ + setCreateForm((f) => ({ ...f, password: e.target.value }))} + className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="••••••••" + /> +
+
+ + setCreateForm((f) => ({ ...f, email: e.target.value }))} + className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="email@example.com" + /> +
+
+
+ + +
+
+ + +
+
+ {createError && ( +
+ {createError} +
+ )} +
+ + +
+
+
+
+ )} + + {/* Edit Modal */} + {showEditModal && targetEmployee && ( +
setShowEditModal(false)}> +
e.stopPropagation()}> +
+

계정 수정 — {targetEmployee.name}

+ +
+
+
+ + setEditForm((f) => ({ ...f, name: e.target.value }))} + className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+ + setEditForm((f) => ({ ...f, email: e.target.value }))} + className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+
+ + +
+
+ + +
+
+ {editError && ( +
+ {editError} +
+ )} +
+ + +
+
+
+
+ )} + + {/* Delete Confirmation */} + {showDeleteDialog && targetEmployee && ( +
setShowDeleteDialog(false)}> +
e.stopPropagation()}> +
+
+ +
+
+

계정 삭제

+

이 작업은 되돌릴 수 없습니다

+
+
+
+

+ {targetEmployee.name} 계정을 삭제하시겠습니까? +

+

소프트 삭제 처리되며 시스템 접근이 차단됩니다.

+
+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/src/app/(main)/settings/audit/page.tsx b/src/app/(main)/settings/audit/page.tsx new file mode 100644 index 0000000..b827e7b --- /dev/null +++ b/src/app/(main)/settings/audit/page.tsx @@ -0,0 +1,410 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { + Shield, + Bell, + UserPlus, + Clock, + Filter, + Search, + AlertTriangle, + Activity, + RefreshCw, +} from 'lucide-react'; +import { useAuthStore } from '@/stores'; +import apiClient from '@/services/apiClient'; + +/* ─── Types ─── */ + +interface Notification { + id: number; + title: string; + message: string; + type: string; + isRead: string; + createdAt: string; +} + +interface EmployeeEntry { + empId: number; + name: string; + loginId: string; + roleCode: string; + empStatus: string; + createdAt?: string; +} + +interface AuditEntry { + id: string; + time: string; + type: 'SLA' | 'ACCOUNT' | 'SYSTEM'; + title: string; + message: string; +} + +/* ─── Helpers ─── */ + +function formatDateTime(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return iso; + } +} + +function formatDateOnly(iso: string): string { + try { + const d = new Date(iso); + return d.toISOString().slice(0, 10); + } catch { + return ''; + } +} + +const typeConfig: Record< + AuditEntry['type'], + { label: string; bg: string; text: string; icon: React.ReactNode } +> = { + SLA: { + label: 'SLA', + bg: 'bg-status-critical-bg', + text: 'text-status-critical-text', + icon: , + }, + ACCOUNT: { + label: '계정', + bg: 'bg-blue-50', + text: 'text-blue-600', + icon: , + }, + SYSTEM: { + label: '시스템', + bg: 'bg-purple-50', + text: 'text-purple-600', + icon: , + }, +}; + +/* ─── Component ─── */ + +export default function AuditLogPage() { + const user = useAuthStore((s) => s.user); + const [notifications, setNotifications] = useState([]); + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + + // Filters + const [dateFilter, setDateFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState('ALL'); + + /* ─── Data Fetching ─── */ + + useEffect(() => { + if (user?.roleCode !== 'ADMIN') return; + + const fetchAll = async () => { + setLoading(true); + try { + const [notifRes, empRes] = await Promise.allSettled([ + apiClient.get('/notifications') as Promise, + apiClient.get('/employees', { params: { size: 200 } }) as Promise, + ]); + + if (notifRes.status === 'fulfilled' && notifRes.value?.success) { + setNotifications(notifRes.value.data ?? []); + } + if (empRes.status === 'fulfilled' && empRes.value?.success) { + setEmployees(empRes.value.data?.content ?? empRes.value.data ?? []); + } + } catch { + // silent + } finally { + setLoading(false); + } + }; + + fetchAll(); + }, [user]); + + /* ─── Merge into timeline ─── */ + + const auditEntries = useMemo(() => { + const entries: AuditEntry[] = []; + + // Map notifications → audit entries + notifications.forEach((n) => { + let type: AuditEntry['type'] = 'SYSTEM'; + if (n.type?.includes('SLA') || n.title?.includes('SLA')) type = 'SLA'; + else if (n.type?.includes('NOTIF') || n.type?.includes('TICKET')) type = 'SYSTEM'; + else type = 'SYSTEM'; + + entries.push({ + id: `notif-${n.id}`, + time: n.createdAt, + type, + title: n.title || '시스템 알림', + message: n.message || '', + }); + }); + + // Map employee creations (use createdAt if available, else synthetic) + employees.forEach((emp) => { + if (emp.createdAt) { + entries.push({ + id: `emp-${emp.empId}`, + time: emp.createdAt, + type: 'ACCOUNT', + title: `계정 생성 — ${emp.name}`, + message: `로그인 ID: ${emp.loginId} / 역할: ${emp.roleCode}`, + }); + } + }); + + // Sort newest first + entries.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()); + return entries; + }, [notifications, employees]); + + /* ─── Filtering ─── */ + + const filtered = useMemo(() => { + return auditEntries.filter((e) => { + if (dateFilter && formatDateOnly(e.time) !== dateFilter) return false; + if (typeFilter !== 'ALL' && e.type !== typeFilter) return false; + return true; + }); + }, [auditEntries, dateFilter, typeFilter]); + + /* ─── 403 Gate ─── */ + + if (user?.roleCode !== 'ADMIN') { + return ( +
+
+
+ +

+ 접근 권한 없음 +

+

+ 이 페이지는 관리자(ADMIN)만 접근할 수 있습니다. +

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

+ 감사 로그 +

+

+ 시스템 알림 및 계정 활동을 시간순으로 조회합니다. +

+
+
+ +
+ + {/* Filters */} +
+
+
+ + 필터 +
+ +
+ {/* Date filter */} + setDateFilter(e.target.value)} + className="px-3 py-1.5 rounded-lg bg-gray-50 border border-border text-xs text-text-main focus:outline-none focus:ring-2 focus:ring-[#4F46E5]" + /> + + {/* Type filter */} +
+ + {(Object.keys(typeConfig) as AuditEntry['type'][]).map((t) => ( + + ))} +
+ + {(dateFilter || typeFilter !== 'ALL') && ( + + )} +
+
+
+ + {/* Loading */} + {loading && auditEntries.length === 0 && ( +
+ +

데이터를 불러오는 중…

+
+ )} + + {/* Empty */} + {!loading && filtered.length === 0 && ( +
+ +

+ 기록이 없습니다 +

+

+ {dateFilter || typeFilter !== 'ALL' + ? '선택한 조건에 맞는 감사 기록이 없습니다.' + : '감사 로그 데이터가 아직 없습니다.'} +

+
+ )} + + {/* Timeline */} + {filtered.length > 0 && ( +
+ {/* Vertical line */} +
+ +
+ {filtered.map((entry) => { + const cfg = typeConfig[entry.type]; + return ( +
+ {/* Dot */} +
+
+
+ + {/* Card */} +
+
+ {/* Meta */} +
+ + {cfg.icon} + {cfg.label} + + + {formatDateTime(entry.time)} + +
+ + {/* Content */} +
+

+ {entry.title} +

+ {entry.message && ( +

+ {entry.message} +

+ )} +
+
+
+
+ ); + })} +
+
+ )} + + {/* Stats footer */} + {auditEntries.length > 0 && ( +
+ + 총 {auditEntries.length}건 + {filtered.length !== auditEntries.length && ( + + {' '} + · 필터 결과{' '} + {filtered.length}건 + + )} + + 최신순 정렬 +
+ )} +
+
+ ); +} diff --git a/src/app/(main)/settings/notification/page.tsx b/src/app/(main)/settings/notification/page.tsx new file mode 100644 index 0000000..fc277be --- /dev/null +++ b/src/app/(main)/settings/notification/page.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useAuthStore } from '@/stores'; +import apiClient from '@/services/apiClient'; +import type { Notification } from '@/types'; +import { + Bell, + BellRing, + Mail, + MessageSquare, + Link as LinkIcon, + ShieldAlert, + Ticket, + FileText, + CheckCircle, + Wrench, + Lock, +} from 'lucide-react'; + +// ============================================ +// Types +// ============================================ + +interface NotificationChannelSetting { + type: string; + label: string; + icon: React.ReactNode; + slack: boolean; + email: boolean; +} + +// ============================================ +// Static notification type config +// ============================================ + +const NOTIFICATION_TYPES: NotificationChannelSetting[] = [ + { type: 'SLA_WARNING', label: 'SLA 경고', icon: , slack: true, email: true }, + { type: 'TICKET_ASSIGNED', label: '티켓 배정', icon: , slack: true, email: false }, + { type: 'REPORT_SUBMITTED', label: '보고서 제출', icon: , slack: false, email: true }, + { type: 'REPORT_APPROVED', label: '보고서 결재', icon: , slack: false, email: true }, + { type: 'EQUIPMENT_MAINTENANCE', label: '장비 유지보수', icon: , slack: true, email: true }, +]; + +// ============================================ +// Helpers +// ============================================ + +function formatRelativeTime(dateStr: string): string { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return '방금 전'; + if (diffMin < 60) return `${diffMin}분 전`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}시간 전`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}일 전`; + return date.toLocaleDateString('ko-KR'); +} + +function getTypeLabel(type: string): string { + return NOTIFICATION_TYPES.find((t) => t.type === type)?.label ?? type; +} + +function getTypeIcon(type: string): React.ReactNode { + return NOTIFICATION_TYPES.find((t) => t.type === type)?.icon ?? ; +} + +// ============================================ +// Page Component +// ============================================ + +export default function NotificationSettingsPage() { + const { user } = useAuthStore(); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [channels, setChannels] = useState(NOTIFICATION_TYPES); + const [webhookUrl, setWebhookUrl] = useState(''); + + // Role guard + if (user?.roleCode !== 'ADMIN') { + return ( +
+
+ +

접근 권한이 없습니다

+

ADMIN 권한만 이 페이지에 접근할 수 있습니다.

+
+
+ ); + } + + // Fetch notifications + useEffect(() => { + let cancelled = false; + async function fetchNotifications() { + try { + const res = await apiClient.get<{ content: Notification[] }>('/notifications?size=20&sort=createdAt,desc'); + if (!cancelled && res?.content) { + setNotifications(res.content); + } + } catch { + // silent fail — show empty + } finally { + if (!cancelled) setLoading(false); + } + } + fetchNotifications(); + return () => { cancelled = true; }; + }, []); + + // Mark as read + async function handleMarkRead(notificationId: number) { + try { + await apiClient.put(`/notifications/${notificationId}/read`); + setNotifications((prev) => + prev.map((n) => (n.notificationId === notificationId ? { ...n, isRead: true } : n)), + ); + } catch { + // silent + } + } + + // Toggle channel + function toggleChannel(type: string, channel: 'slack' | 'email') { + setChannels((prev) => + prev.map((c) => (c.type === type ? { ...c, [channel]: !c[channel] } : c)), + ); + } + + return ( +
+
+ {/* Header */} +
+
+ +
+
+

알림 설정

+

알림 채널 및 템플릿 관리 (ADMIN 전용)

+
+
+ + {/* 1. 알림 유형별 설정 */} +
+
+

알림 유형별 채널 설정

+

각 알림 유형별로 Slack / Email 발송 여부를 설정합니다.

+
+
+ {channels.map((ch) => ( +
+
+ {ch.icon} +
+

{ch.label}

+

{ch.type}

+
+
+
+ {/* Slack toggle */} + + {/* Email toggle */} + +
+
+ ))} +
+
+ + {/* 2. Slack Webhook URL */} +
+

Slack Webhook URL

+

Slack 알림을 받을 웹훅 URL을 입력합니다. (현재 환경변수에서 읽음)

+
+ setWebhookUrl(e.target.value)} + placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" + className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-gray-50" + /> + +
+
+ + {/* 3. 최근 알림 내역 */} +
+
+
+

최근 알림 내역

+

최근 20개 알림 목록

+
+
+ + {loading ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
표시할 알림이 없습니다.
+ ) : ( +
+ + + + + + + + + + + {notifications.map((n) => ( + !n.isRead && handleMarkRead(n.notificationId)} + className={`transition-colors ${!n.isRead ? 'bg-indigo-50/40 hover:bg-indigo-50 cursor-pointer' : 'hover:bg-gray-50/60'}`} + > + + + + + + ))} + +
유형제목시간상태
+
+ {getTypeIcon(n.type)} + {getTypeLabel(n.type)} +
+
+
+ + {n.title} + + {n.linkUrl && ( + + )} +
+

{n.message}

+
+ {formatRelativeTime(n.createdAt)} + + {n.isRead ? ( + + 읽음 + + ) : ( + + 안 읽음 + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/(main)/settings/system/page.tsx b/src/app/(main)/settings/system/page.tsx new file mode 100644 index 0000000..ceb9238 --- /dev/null +++ b/src/app/(main)/settings/system/page.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Shield, Clock, Moon, Key, Bell, Upload, Save, Loader2 } from 'lucide-react'; +import { useAuthStore } from '@/stores'; +import apiClient from '@/services/apiClient'; +import type { SystemConfig } from '@/types'; + +interface ConfigSection { + title: string; + icon: React.ReactNode; + fields: { + label: string; + key: string; + type: 'number' | 'text' | 'time' | 'toggle'; + unit?: string; + }[]; +} + +const SECTIONS: ConfigSection[] = [ + { + title: 'SLA 설정', + icon: , + fields: [ + { label: '체크 간격', key: 'sla_check_interval', type: 'number', unit: 'ms' }, + { label: '제외 상태', key: 'sla_excluded_statuses', type: 'text' }, + ], + }, + { + title: 'Parking 모드', + icon: , + fields: [ + { label: '활성화', key: 'parking_enabled', type: 'toggle' }, + { label: '평일 시작', key: 'parking_weekday_start', type: 'time' }, + { label: '평일 종료', key: 'parking_weekday_end', type: 'time' }, + { label: '주말 적용', key: 'parking_weekends', type: 'toggle' }, + ], + }, + { + title: 'JWT 설정', + icon: , + fields: [ + { label: 'Access 만료', key: 'jwt_access_expiration', type: 'number', unit: 'ms' }, + { label: 'Refresh 만료', key: 'jwt_refresh_expiration', type: 'number', unit: 'ms' }, + ], + }, + { + title: '알림 설정', + icon: , + fields: [ + { label: 'Slack 활성화', key: 'notification_slack_enabled', type: 'toggle' }, + { label: 'Email 활성화', key: 'notification_email_enabled', type: 'toggle' }, + ], + }, + { + title: '파일 업로드', + icon: , + fields: [ + { label: '최대 크기', key: 'file_upload_max_size', type: 'number', unit: 'bytes' }, + ], + }, +]; + +function ToggleButton({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +export default function SystemSettingsPage() { + const user = useAuthStore((s) => s.user); + const [configs, setConfigs] = useState([]); + const [values, setValues] = useState>({}); + const [changed, setChanged] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + apiClient.get('/admin/config') + .then((res: any) => { + if (res.success) { + setConfigs(res.data); + const map: Record = {}; + res.data.forEach((c: SystemConfig) => { map[c.configKey] = c.configValue; }); + setValues(map); + } + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const handleChange = (key: string, val: string) => { + setValues((prev) => ({ ...prev, [key]: val })); + setChanged((prev) => new Set(prev).add(key)); + }; + + const handleToggle = (key: string) => { + const current = values[key] === 'true'; + handleChange(key, String(!current)); + }; + + const handleSave = async () => { + setSaving(true); + const payload: Record = {}; + changed.forEach((k) => { payload[k] = values[k]; }); + try { + const res: any = await apiClient.put('/admin/config/batch', payload); + if (res.success) { + setChanged(new Set()); + } + } catch (e) { + console.error('Save error:', e); + } finally { + setSaving(false); + } + }; + + const getField = (key: string) => configs.find((c) => c.configKey === key); + + if (user?.roleCode !== 'ADMIN') { + return ( +
+
+ +

접근 거부

+

시스템 설정은 관리자(ADMIN)만 접근할 수 있습니다.

+
+
+ ); + } + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+
+ +
+

System Settings

+

Manage system-wide configuration

+
+
+ +
+ {SECTIONS.map((section) => ( +
+
+ {section.icon} +

{section.title}

+
+
+ {section.fields.map((field) => { + const config = getField(field.key); + const val = values[field.key] ?? ''; + const isChanged = changed.has(field.key); + + return ( +
+
+ + {config?.description && ( +

{config.description}

+ )} +
+
+ {field.type === 'toggle' ? ( + handleToggle(field.key)} /> + ) : field.type === 'time' ? ( + handleChange(field.key, e.target.value)} + className="px-3 py-1.5 border border-border rounded-lg text-sm w-32 focus:outline-none focus:ring-2 focus:ring-primary/30" + /> + ) : ( +
+ handleChange(field.key, e.target.value)} + className={`px-3 py-1.5 border rounded-lg text-sm w-40 focus:outline-none focus:ring-2 focus:ring-primary/30 ${ + isChanged ? 'border-primary bg-primary/5' : 'border-border' + }`} + /> + {field.unit && ( + {field.unit} + )} +
+ )} + {isChanged && ( + + )} +
+
+ ); + })} +
+
+ ))} +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 22d263a..e6e6dee 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -34,6 +34,7 @@ const Sidebar = ({ isCollapsed }: { isCollapsed?: boolean }) => { const pathname = usePathname(); const router = useRouter(); const [isItamOpen, setIsItamOpen] = useState(pathname.startsWith('/itam')); + const [isAdminOpen, setIsAdminOpen] = useState(pathname.startsWith('/settings')); const user = useAuthStore((s) => s.user); const role = user?.roleCode; const isMgmt = role === 'MANAGER' || role === 'ADMIN'; @@ -209,19 +210,49 @@ const Sidebar = ({ isCollapsed }: { isCollapsed?: boolean }) => { )} {role === 'ADMIN' && ( - - - {!isCollapsed && Settings} - + <> + + {isAdminOpen && !isCollapsed && ( +
+ {[ + { name: 'Codes', href: '/settings/codes', icon: Settings }, + { name: 'Accounts', href: '/settings/accounts', icon: Users }, + { name: 'System', href: '/settings/system', icon: Monitor }, + { name: 'Notification', href: '/settings/notification', icon: FileText }, + { name: 'Audit Log', href: '/settings/audit', icon: History }, + ].map((sub) => { + const SubIcon = sub.icon; + const isActive = pathname === sub.href; + return ( + + + {sub.name} + + ); + })} +
+ )} + )} ; }