Implement multi-language support in audit log, system notices, collection management, and common code management pages
- Integrated multi-language functionality across the audit log, system notices, collection management, and common code management components, enhancing accessibility for diverse users. - Updated UI elements to utilize translation keys, ensuring that all text is dynamically translated based on user preferences. - Improved error handling messages to be localized, providing a better user experience in case of issues. These changes significantly enhance the usability and internationalization of the management features, making the application more inclusive.
This commit is contained in:
@@ -64,37 +64,252 @@ import {
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Company } from "@/types/company";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
// 다국어 키 목록
|
||||
const LANG_KEYS = [
|
||||
"audit.title",
|
||||
"audit.description",
|
||||
"audit.button.refresh",
|
||||
"audit.stats.totalChanges30d",
|
||||
"audit.stats.resourceTypes",
|
||||
"audit.stats.activeUsers",
|
||||
"audit.stats.todayChanges",
|
||||
"audit.stats.countSuffix",
|
||||
"audit.stats.typeSuffix",
|
||||
"audit.stats.userSuffix",
|
||||
"audit.filter.searchLabel",
|
||||
"audit.filter.searchPlaceholder",
|
||||
"audit.filter.typeLabel",
|
||||
"audit.filter.actionLabel",
|
||||
"audit.filter.companyLabel",
|
||||
"audit.filter.userLabel",
|
||||
"audit.filter.dateFromLabel",
|
||||
"audit.filter.dateToLabel",
|
||||
"audit.filter.all",
|
||||
"audit.filter.allCompanies",
|
||||
"audit.filter.companySearchPlaceholder",
|
||||
"audit.filter.companyNotFound",
|
||||
"audit.filter.userSearchPlaceholder",
|
||||
"audit.filter.userNotFound",
|
||||
"audit.filter.apply",
|
||||
"audit.list.title",
|
||||
"audit.list.empty",
|
||||
"audit.list.countSuffix",
|
||||
"audit.detail.title",
|
||||
"audit.detail.user",
|
||||
"audit.detail.company",
|
||||
"audit.detail.resourceType",
|
||||
"audit.detail.action",
|
||||
"audit.detail.resourceName",
|
||||
"audit.detail.tableName",
|
||||
"audit.detail.ipAddress",
|
||||
"audit.detail.summary",
|
||||
"audit.detail.changes",
|
||||
"audit.detail.apiPath",
|
||||
"audit.changes.field",
|
||||
"audit.changes.before",
|
||||
"audit.changes.after",
|
||||
"audit.changes.securityHidden",
|
||||
"audit.dateGroup.today",
|
||||
"audit.dateGroup.yesterday",
|
||||
"audit.fieldValue.empty",
|
||||
"audit.fieldValue.yes",
|
||||
"audit.fieldValue.no",
|
||||
"audit.resource.menu",
|
||||
"audit.resource.screen",
|
||||
"audit.resource.screenLayout",
|
||||
"audit.resource.flow",
|
||||
"audit.resource.flowStep",
|
||||
"audit.resource.nodeFlow",
|
||||
"audit.resource.user",
|
||||
"audit.resource.role",
|
||||
"audit.resource.company",
|
||||
"audit.resource.codeCategory",
|
||||
"audit.resource.code",
|
||||
"audit.resource.data",
|
||||
"audit.resource.table",
|
||||
"audit.resource.numberingRule",
|
||||
"audit.action.create",
|
||||
"audit.action.update",
|
||||
"audit.action.delete",
|
||||
"audit.action.copy",
|
||||
"audit.action.login",
|
||||
"audit.action.statusChange",
|
||||
"audit.action.batchCreate",
|
||||
"audit.action.batchUpdate",
|
||||
"audit.action.batchDelete",
|
||||
"audit.field.status",
|
||||
"audit.field.menuUrl",
|
||||
"audit.field.menuNameKor",
|
||||
"audit.field.menuNameEng",
|
||||
"audit.field.screenName",
|
||||
"audit.field.tableName",
|
||||
"audit.field.description",
|
||||
"audit.field.isActive",
|
||||
"audit.field.userName",
|
||||
"audit.field.userId",
|
||||
"audit.field.deptName",
|
||||
"audit.field.authName",
|
||||
"audit.field.authCode",
|
||||
"audit.field.companyCode",
|
||||
"audit.field.companyName",
|
||||
"audit.field.name",
|
||||
"audit.field.userPassword",
|
||||
"audit.field.prefix",
|
||||
"audit.field.ruleName",
|
||||
"audit.field.stepName",
|
||||
"audit.field.stepOrder",
|
||||
"audit.field.sourceScreenId",
|
||||
"audit.field.targetCompanyCode",
|
||||
"audit.field.mainScreenName",
|
||||
"audit.field.screenCode",
|
||||
"audit.field.menuObjid",
|
||||
"audit.field.deleteReason",
|
||||
"audit.field.force",
|
||||
"audit.field.deletedMenus",
|
||||
"audit.field.failedMenuIds",
|
||||
"audit.field.deletedCount",
|
||||
"audit.field.items",
|
||||
] as const;
|
||||
|
||||
// 한국어 기본 텍스트
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"audit.title": "통합 변경 이력",
|
||||
"audit.description": "시스템 전체 변경 사항을 추적합니다",
|
||||
"audit.button.refresh": "새로고침",
|
||||
"audit.stats.totalChanges30d": "최근 30일 총 변경",
|
||||
"audit.stats.resourceTypes": "리소스 유형",
|
||||
"audit.stats.activeUsers": "활동 사용자",
|
||||
"audit.stats.todayChanges": "오늘 변경",
|
||||
"audit.stats.countSuffix": "건",
|
||||
"audit.stats.typeSuffix": "종",
|
||||
"audit.stats.userSuffix": "명",
|
||||
"audit.filter.searchLabel": "검색어",
|
||||
"audit.filter.searchPlaceholder": "이름, 요약, 사용자...",
|
||||
"audit.filter.typeLabel": "유형",
|
||||
"audit.filter.actionLabel": "동작",
|
||||
"audit.filter.companyLabel": "회사",
|
||||
"audit.filter.userLabel": "사용자",
|
||||
"audit.filter.dateFromLabel": "시작일",
|
||||
"audit.filter.dateToLabel": "종료일",
|
||||
"audit.filter.all": "전체",
|
||||
"audit.filter.allCompanies": "전체 회사",
|
||||
"audit.filter.companySearchPlaceholder": "회사 검색...",
|
||||
"audit.filter.companyNotFound": "회사를 찾을 수 없습니다",
|
||||
"audit.filter.userSearchPlaceholder": "사용자 검색...",
|
||||
"audit.filter.userNotFound": "사용자를 찾을 수 없습니다",
|
||||
"audit.filter.apply": "필터 적용",
|
||||
"audit.list.title": "변경 이력",
|
||||
"audit.list.empty": "변경 이력이 없습니다",
|
||||
"audit.list.countSuffix": "건",
|
||||
"audit.detail.title": "변경 상세 정보",
|
||||
"audit.detail.user": "사용자",
|
||||
"audit.detail.company": "회사",
|
||||
"audit.detail.resourceType": "리소스 유형",
|
||||
"audit.detail.action": "동작",
|
||||
"audit.detail.resourceName": "리소스명",
|
||||
"audit.detail.tableName": "테이블명",
|
||||
"audit.detail.ipAddress": "IP 주소",
|
||||
"audit.detail.summary": "요약",
|
||||
"audit.detail.changes": "변경 내역",
|
||||
"audit.detail.apiPath": "API 경로",
|
||||
"audit.changes.field": "항목",
|
||||
"audit.changes.before": "변경 전",
|
||||
"audit.changes.after": "변경 후",
|
||||
"audit.changes.securityHidden": "(보안 항목 - 값 비공개)",
|
||||
"audit.dateGroup.today": "오늘",
|
||||
"audit.dateGroup.yesterday": "어제",
|
||||
"audit.fieldValue.empty": "(없음)",
|
||||
"audit.fieldValue.yes": "예",
|
||||
"audit.fieldValue.no": "아니오",
|
||||
"audit.resource.menu": "메뉴",
|
||||
"audit.resource.screen": "화면",
|
||||
"audit.resource.screenLayout": "레이아웃",
|
||||
"audit.resource.flow": "플로우",
|
||||
"audit.resource.flowStep": "플로우 스텝",
|
||||
"audit.resource.nodeFlow": "플로우 제어",
|
||||
"audit.resource.user": "사용자",
|
||||
"audit.resource.role": "권한",
|
||||
"audit.resource.company": "회사",
|
||||
"audit.resource.codeCategory": "코드 카테고리",
|
||||
"audit.resource.code": "코드",
|
||||
"audit.resource.data": "데이터",
|
||||
"audit.resource.table": "테이블",
|
||||
"audit.resource.numberingRule": "채번 규칙",
|
||||
"audit.action.create": "생성",
|
||||
"audit.action.update": "수정",
|
||||
"audit.action.delete": "삭제",
|
||||
"audit.action.copy": "복사",
|
||||
"audit.action.login": "로그인",
|
||||
"audit.action.statusChange": "상태변경",
|
||||
"audit.action.batchCreate": "배치생성",
|
||||
"audit.action.batchUpdate": "배치수정",
|
||||
"audit.action.batchDelete": "배치삭제",
|
||||
"audit.field.status": "상태",
|
||||
"audit.field.menuUrl": "메뉴 URL",
|
||||
"audit.field.menuNameKor": "메뉴명",
|
||||
"audit.field.menuNameEng": "메뉴명(영)",
|
||||
"audit.field.screenName": "화면명",
|
||||
"audit.field.tableName": "테이블명",
|
||||
"audit.field.description": "설명",
|
||||
"audit.field.isActive": "활성 여부",
|
||||
"audit.field.userName": "사용자명",
|
||||
"audit.field.userId": "사용자 ID",
|
||||
"audit.field.deptName": "부서명",
|
||||
"audit.field.authName": "권한명",
|
||||
"audit.field.authCode": "권한코드",
|
||||
"audit.field.companyCode": "회사코드",
|
||||
"audit.field.companyName": "회사명",
|
||||
"audit.field.name": "이름",
|
||||
"audit.field.userPassword": "비밀번호",
|
||||
"audit.field.prefix": "접두사",
|
||||
"audit.field.ruleName": "규칙명",
|
||||
"audit.field.stepName": "스텝명",
|
||||
"audit.field.stepOrder": "스텝 순서",
|
||||
"audit.field.sourceScreenId": "원본 화면 ID",
|
||||
"audit.field.targetCompanyCode": "대상 회사코드",
|
||||
"audit.field.mainScreenName": "메인 화면명",
|
||||
"audit.field.screenCode": "화면코드",
|
||||
"audit.field.menuObjid": "메뉴 ID",
|
||||
"audit.field.deleteReason": "삭제 사유",
|
||||
"audit.field.force": "강제 삭제",
|
||||
"audit.field.deletedMenus": "삭제된 메뉴",
|
||||
"audit.field.failedMenuIds": "실패한 메뉴",
|
||||
"audit.field.deletedCount": "삭제 건수",
|
||||
"audit.field.items": "항목 수",
|
||||
};
|
||||
|
||||
const RESOURCE_TYPE_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; icon: React.ElementType; color: string }
|
||||
{ langKey: string; icon: React.ElementType; color: string }
|
||||
> = {
|
||||
MENU: { label: "메뉴", icon: Layout, color: "bg-primary/10 text-primary" },
|
||||
SCREEN: { label: "화면", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||
SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||
FLOW: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||
FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||
MENU: { langKey: "audit.resource.menu", icon: Layout, color: "bg-primary/10 text-primary" },
|
||||
SCREEN: { langKey: "audit.resource.screen", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||
SCREEN_LAYOUT: { langKey: "audit.resource.screenLayout", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||
FLOW: { langKey: "audit.resource.flow", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||
FLOW_STEP: { langKey: "audit.resource.flowStep", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||
NODE_FLOW: { langKey: "audit.resource.nodeFlow", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
||||
USER: { langKey: "audit.resource.user", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { langKey: "audit.resource.role", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
COMPANY: { langKey: "audit.resource.company", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||
CODE_CATEGORY: { langKey: "audit.resource.codeCategory", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE: { langKey: "audit.resource.code", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
DATA: { langKey: "audit.resource.data", icon: Database, color: "bg-muted text-foreground" },
|
||||
TABLE: { langKey: "audit.resource.table", icon: Database, color: "bg-muted text-foreground" },
|
||||
NUMBERING_RULE: { langKey: "audit.resource.numberingRule", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||
};
|
||||
|
||||
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
CREATE: { label: "생성", color: "bg-emerald-100 text-emerald-700" },
|
||||
UPDATE: { label: "수정", color: "bg-primary/10 text-primary" },
|
||||
DELETE: { label: "삭제", color: "bg-destructive/10 text-destructive" },
|
||||
COPY: { label: "복사", color: "bg-violet-100 text-violet-700" },
|
||||
LOGIN: { label: "로그인", color: "bg-muted text-foreground" },
|
||||
STATUS_CHANGE: { label: "상태변경", color: "bg-amber-100 text-amber-700" },
|
||||
BATCH_CREATE: { label: "배치생성", color: "bg-emerald-100 text-emerald-700" },
|
||||
BATCH_UPDATE: { label: "배치수정", color: "bg-primary/10 text-primary" },
|
||||
BATCH_DELETE: { label: "배치삭제", color: "bg-destructive/10 text-destructive" },
|
||||
const ACTION_CONFIG: Record<string, { langKey: string; color: string }> = {
|
||||
CREATE: { langKey: "audit.action.create", color: "bg-emerald-100 text-emerald-700" },
|
||||
UPDATE: { langKey: "audit.action.update", color: "bg-primary/10 text-primary" },
|
||||
DELETE: { langKey: "audit.action.delete", color: "bg-destructive/10 text-destructive" },
|
||||
COPY: { langKey: "audit.action.copy", color: "bg-violet-100 text-violet-700" },
|
||||
LOGIN: { langKey: "audit.action.login", color: "bg-muted text-foreground" },
|
||||
STATUS_CHANGE: { langKey: "audit.action.statusChange", color: "bg-amber-100 text-amber-700" },
|
||||
BATCH_CREATE: { langKey: "audit.action.batchCreate", color: "bg-emerald-100 text-emerald-700" },
|
||||
BATCH_UPDATE: { langKey: "audit.action.batchUpdate", color: "bg-primary/10 text-primary" },
|
||||
BATCH_DELETE: { langKey: "audit.action.batchDelete", color: "bg-destructive/10 text-destructive" },
|
||||
};
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
@@ -118,59 +333,59 @@ function formatTime(dateStr: string): string {
|
||||
}
|
||||
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
status: "상태",
|
||||
menuUrl: "메뉴 URL",
|
||||
menu_url: "메뉴 URL",
|
||||
menuNameKor: "메뉴명",
|
||||
menu_name_kor: "메뉴명",
|
||||
menuNameEng: "메뉴명(영)",
|
||||
menu_name_eng: "메뉴명(영)",
|
||||
screenName: "화면명",
|
||||
screen_name: "화면명",
|
||||
tableName: "테이블명",
|
||||
table_name: "테이블명",
|
||||
description: "설명",
|
||||
isActive: "활성 여부",
|
||||
is_active: "활성 여부",
|
||||
userName: "사용자명",
|
||||
user_name: "사용자명",
|
||||
userId: "사용자 ID",
|
||||
user_id: "사용자 ID",
|
||||
deptName: "부서명",
|
||||
dept_name: "부서명",
|
||||
authName: "권한명",
|
||||
authCode: "권한코드",
|
||||
companyCode: "회사코드",
|
||||
company_code: "회사코드",
|
||||
company_name: "회사명",
|
||||
name: "이름",
|
||||
user_password: "비밀번호",
|
||||
prefix: "접두사",
|
||||
ruleName: "규칙명",
|
||||
stepName: "스텝명",
|
||||
stepOrder: "스텝 순서",
|
||||
sourceScreenId: "원본 화면 ID",
|
||||
targetCompanyCode: "대상 회사코드",
|
||||
mainScreenName: "메인 화면명",
|
||||
screenCode: "화면코드",
|
||||
menuObjid: "메뉴 ID",
|
||||
deleteReason: "삭제 사유",
|
||||
force: "강제 삭제",
|
||||
deletedMenus: "삭제된 메뉴",
|
||||
failedMenuIds: "실패한 메뉴",
|
||||
deletedCount: "삭제 건수",
|
||||
items: "항목 수",
|
||||
status: "audit.field.status",
|
||||
menuUrl: "audit.field.menuUrl",
|
||||
menu_url: "audit.field.menuUrl",
|
||||
menuNameKor: "audit.field.menuNameKor",
|
||||
menu_name_kor: "audit.field.menuNameKor",
|
||||
menuNameEng: "audit.field.menuNameEng",
|
||||
menu_name_eng: "audit.field.menuNameEng",
|
||||
screenName: "audit.field.screenName",
|
||||
screen_name: "audit.field.screenName",
|
||||
tableName: "audit.field.tableName",
|
||||
table_name: "audit.field.tableName",
|
||||
description: "audit.field.description",
|
||||
isActive: "audit.field.isActive",
|
||||
is_active: "audit.field.isActive",
|
||||
userName: "audit.field.userName",
|
||||
user_name: "audit.field.userName",
|
||||
userId: "audit.field.userId",
|
||||
user_id: "audit.field.userId",
|
||||
deptName: "audit.field.deptName",
|
||||
dept_name: "audit.field.deptName",
|
||||
authName: "audit.field.authName",
|
||||
authCode: "audit.field.authCode",
|
||||
companyCode: "audit.field.companyCode",
|
||||
company_code: "audit.field.companyCode",
|
||||
company_name: "audit.field.companyName",
|
||||
name: "audit.field.name",
|
||||
user_password: "audit.field.userPassword",
|
||||
prefix: "audit.field.prefix",
|
||||
ruleName: "audit.field.ruleName",
|
||||
stepName: "audit.field.stepName",
|
||||
stepOrder: "audit.field.stepOrder",
|
||||
sourceScreenId: "audit.field.sourceScreenId",
|
||||
targetCompanyCode: "audit.field.targetCompanyCode",
|
||||
mainScreenName: "audit.field.mainScreenName",
|
||||
screenCode: "audit.field.screenCode",
|
||||
menuObjid: "audit.field.menuObjid",
|
||||
deleteReason: "audit.field.deleteReason",
|
||||
force: "audit.field.force",
|
||||
deletedMenus: "audit.field.deletedMenus",
|
||||
failedMenuIds: "audit.field.failedMenuIds",
|
||||
deletedCount: "audit.field.deletedCount",
|
||||
items: "audit.field.items",
|
||||
};
|
||||
|
||||
function formatFieldValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "(없음)";
|
||||
if (typeof value === "boolean") return value ? "예" : "아니오";
|
||||
if (Array.isArray(value)) return value.length > 0 ? `${value.length}건` : "(없음)";
|
||||
function formatFieldValue(value: unknown, t: (key: string, params?: Record<string, string | number>) => string): string {
|
||||
if (value === null || value === undefined) return t("audit.fieldValue.empty");
|
||||
if (typeof value === "boolean") return value ? t("audit.fieldValue.yes") : t("audit.fieldValue.no");
|
||||
if (Array.isArray(value)) return value.length > 0 ? `${value.length}${t("audit.list.countSuffix")}` : t("audit.fieldValue.empty");
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderChanges(changes: Record<string, unknown>) {
|
||||
function renderChanges(changes: Record<string, unknown>, t: (key: string, params?: Record<string, string | number>) => string) {
|
||||
const before = (changes.before as Record<string, unknown>) || {};
|
||||
const after = (changes.after as Record<string, unknown>) || {};
|
||||
const fields = (changes.fields as string[]) || [];
|
||||
@@ -186,9 +401,9 @@ function renderChanges(changes: Record<string, unknown>) {
|
||||
const rows = Array.from(allKeys)
|
||||
.filter((key) => key !== "deletedMenus" && key !== "failedMenuIds")
|
||||
.map((key) => ({
|
||||
field: FIELD_NAME_MAP[key] || key,
|
||||
beforeVal: key in before ? formatFieldValue(before[key]) : null,
|
||||
afterVal: key in after ? formatFieldValue(after[key]) : null,
|
||||
field: FIELD_NAME_MAP[key] ? t(FIELD_NAME_MAP[key]) : key,
|
||||
beforeVal: key in before ? formatFieldValue(before[key], t) : null,
|
||||
afterVal: key in after ? formatFieldValue(after[key], t) : null,
|
||||
isSensitive: fields.includes(key) && !(key in before) && !(key in after),
|
||||
}));
|
||||
|
||||
@@ -200,15 +415,15 @@ function renderChanges(changes: Record<string, unknown>) {
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/50">
|
||||
<th className="px-3 py-1.5 text-left font-medium">항목</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">{t("audit.changes.field")}</th>
|
||||
{hasBefore && (
|
||||
<th className="px-3 py-1.5 text-left font-medium text-destructive">
|
||||
변경 전
|
||||
{t("audit.changes.before")}
|
||||
</th>
|
||||
)}
|
||||
{hasAfter && (
|
||||
<th className="px-3 py-1.5 text-left font-medium text-primary">
|
||||
변경 후
|
||||
{t("audit.changes.after")}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
@@ -226,7 +441,7 @@ function renderChanges(changes: Record<string, unknown>) {
|
||||
}
|
||||
className="px-3 py-1.5 italic text-amber-600"
|
||||
>
|
||||
(보안 항목 - 값 비공개)
|
||||
{t("audit.changes.securityHidden")}
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
@@ -262,14 +477,14 @@ function renderChanges(changes: Record<string, unknown>) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateGroup(dateStr: string): string {
|
||||
function formatDateGroup(dateStr: string, t: (key: string) => string): string {
|
||||
const d = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (d.toDateString() === today.toDateString()) return "오늘";
|
||||
if (d.toDateString() === yesterday.toDateString()) return "어제";
|
||||
if (d.toDateString() === today.toDateString()) return t("audit.dateGroup.today");
|
||||
if (d.toDateString() === yesterday.toDateString()) return t("audit.dateGroup.yesterday");
|
||||
|
||||
return d.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
@@ -292,6 +507,7 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
|
||||
export default function AuditLogPage() {
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.companyCode === "*" || user?.company_code === "*";
|
||||
const { t } = usePageMultiLang({ keys: LANG_KEYS, defaults: DEFAULT_TEXTS, menuCode: "admin.auditLog" });
|
||||
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -397,9 +613,9 @@ export default function AuditLogPage() {
|
||||
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">통합 변경 이력</h1>
|
||||
<h1 className="text-2xl font-bold">{t("audit.title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
시스템 전체 변경 사항을 추적합니다
|
||||
{t("audit.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -412,7 +628,7 @@ export default function AuditLogPage() {
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
{t("audit.button.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -420,27 +636,27 @@ export default function AuditLogPage() {
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">최근 30일 총 변경</p>
|
||||
<p className="text-muted-foreground text-xs">{t("audit.stats.totalChanges30d")}</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{stats.dailyCounts.reduce((s, d) => s + d.count, 0).toLocaleString()}건
|
||||
{stats.dailyCounts.reduce((s, d) => s + d.count, 0).toLocaleString()}{t("audit.stats.countSuffix")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">리소스 유형</p>
|
||||
<p className="text-2xl font-bold">{stats.resourceTypeCounts.length}종</p>
|
||||
<p className="text-muted-foreground text-xs">{t("audit.stats.resourceTypes")}</p>
|
||||
<p className="text-2xl font-bold">{stats.resourceTypeCounts.length}{t("audit.stats.typeSuffix")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">활동 사용자</p>
|
||||
<p className="text-2xl font-bold">{stats.topUsers.length}명</p>
|
||||
<p className="text-muted-foreground text-xs">{t("audit.stats.activeUsers")}</p>
|
||||
<p className="text-2xl font-bold">{stats.topUsers.length}{t("audit.stats.userSuffix")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs">오늘 변경</p>
|
||||
<p className="text-muted-foreground text-xs">{t("audit.stats.todayChanges")}</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{(
|
||||
stats.dailyCounts.find(
|
||||
@@ -448,7 +664,7 @@ export default function AuditLogPage() {
|
||||
new Date(d.date).toDateString() ===
|
||||
new Date().toDateString()
|
||||
)?.count || 0
|
||||
).toLocaleString()}건
|
||||
).toLocaleString()}{t("audit.stats.countSuffix")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -462,11 +678,11 @@ export default function AuditLogPage() {
|
||||
className="flex flex-col gap-3 sm:flex-wrap sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="w-full sm:min-w-[120px] sm:flex-1">
|
||||
<label className="text-xs font-medium">검색어</label>
|
||||
<label className="text-xs font-medium">{t("audit.filter.searchLabel")}</label>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="이름, 요약, 사용자..."
|
||||
placeholder={t("audit.filter.searchPlaceholder")}
|
||||
value={filters.search || ""}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
className="h-9 pl-8 text-sm"
|
||||
@@ -475,7 +691,7 @@ export default function AuditLogPage() {
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-[130px]">
|
||||
<label className="text-xs font-medium">유형</label>
|
||||
<label className="text-xs font-medium">{t("audit.filter.typeLabel")}</label>
|
||||
<Select
|
||||
value={filters.resourceType || "all"}
|
||||
onValueChange={(v) =>
|
||||
@@ -486,10 +702,10 @@ export default function AuditLogPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="all">{t("audit.filter.all")}</SelectItem>
|
||||
{Object.entries(RESOURCE_TYPE_CONFIG).map(([key, cfg]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{cfg.label}
|
||||
{t(cfg.langKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -497,7 +713,7 @@ export default function AuditLogPage() {
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-[120px]">
|
||||
<label className="text-xs font-medium">동작</label>
|
||||
<label className="text-xs font-medium">{t("audit.filter.actionLabel")}</label>
|
||||
<Select
|
||||
value={filters.action || "all"}
|
||||
onValueChange={(v) =>
|
||||
@@ -508,10 +724,10 @@ export default function AuditLogPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="all">{t("audit.filter.all")}</SelectItem>
|
||||
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{cfg.label}
|
||||
{t(cfg.langKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -520,7 +736,7 @@ export default function AuditLogPage() {
|
||||
|
||||
{isSuperAdmin && (
|
||||
<div className="w-full sm:w-[160px]">
|
||||
<label className="text-xs font-medium">회사</label>
|
||||
<label className="text-xs font-medium">{t("audit.filter.companyLabel")}</label>
|
||||
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -532,7 +748,7 @@ export default function AuditLogPage() {
|
||||
{filters.companyCode
|
||||
? companies.find((c) => c.company_code === filters.companyCode)
|
||||
?.company_name || filters.companyCode
|
||||
: "전체 회사"}
|
||||
: t("audit.filter.allCompanies")}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -542,10 +758,10 @@ export default function AuditLogPage() {
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="회사 검색..." className="text-xs" />
|
||||
<CommandInput placeholder={t("audit.filter.companySearchPlaceholder")} className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs">
|
||||
회사를 찾을 수 없습니다
|
||||
{t("audit.filter.companyNotFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -562,7 +778,7 @@ export default function AuditLogPage() {
|
||||
!filters.companyCode ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
전체 회사
|
||||
{t("audit.filter.allCompanies")}
|
||||
</CommandItem>
|
||||
{companies.map((company) => (
|
||||
<CommandItem
|
||||
@@ -604,7 +820,7 @@ export default function AuditLogPage() {
|
||||
)}
|
||||
|
||||
<div className="w-full sm:w-[160px]">
|
||||
<label className="text-xs font-medium">사용자</label>
|
||||
<label className="text-xs font-medium">{t("audit.filter.userLabel")}</label>
|
||||
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -616,7 +832,7 @@ export default function AuditLogPage() {
|
||||
{filters.userId
|
||||
? auditUsers.find((u) => u.user_id === filters.userId)
|
||||
?.user_name || filters.userId
|
||||
: "전체"}
|
||||
: t("audit.filter.all")}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -626,10 +842,10 @@ export default function AuditLogPage() {
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="사용자 검색..." className="text-xs" />
|
||||
<CommandInput placeholder={t("audit.filter.userSearchPlaceholder")} className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs">
|
||||
사용자를 찾을 수 없습니다
|
||||
{t("audit.filter.userNotFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -646,7 +862,7 @@ export default function AuditLogPage() {
|
||||
!filters.userId ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
전체
|
||||
{t("audit.filter.all")}
|
||||
</CommandItem>
|
||||
{auditUsers.map((u) => (
|
||||
<CommandItem
|
||||
@@ -672,7 +888,7 @@ export default function AuditLogPage() {
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{u.user_name}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{u.user_id} ({u.count}건)
|
||||
{u.user_id} ({u.count}{t("audit.list.countSuffix")})
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
@@ -685,7 +901,7 @@ export default function AuditLogPage() {
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-[130px]">
|
||||
<label className="text-xs font-medium">시작일</label>
|
||||
<label className="text-xs font-medium">{t("audit.filter.dateFromLabel")}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateFrom || ""}
|
||||
@@ -695,7 +911,7 @@ export default function AuditLogPage() {
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-[130px]">
|
||||
<label className="text-xs font-medium">종료일</label>
|
||||
<label className="text-xs font-medium">{t("audit.filter.dateToLabel")}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateTo || ""}
|
||||
@@ -706,7 +922,7 @@ export default function AuditLogPage() {
|
||||
|
||||
<Button type="submit" size="sm" className="h-9 w-full sm:w-auto">
|
||||
<Filter className="mr-1 h-4 w-4" />
|
||||
필터 적용
|
||||
{t("audit.filter.apply")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -716,7 +932,7 @@ export default function AuditLogPage() {
|
||||
<CardHeader className="border-b px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
변경 이력 ({total.toLocaleString()}건)
|
||||
{t("audit.list.title")} ({total.toLocaleString()}{t("audit.list.countSuffix")})
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Button
|
||||
@@ -762,7 +978,7 @@ export default function AuditLogPage() {
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Clock className="text-muted-foreground mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
변경 이력이 없습니다
|
||||
{t("audit.list.empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -771,10 +987,10 @@ export default function AuditLogPage() {
|
||||
<div key={dateKey}>
|
||||
<div className="bg-muted/50 sticky top-0 z-10 border-b px-4 py-2">
|
||||
<span className="text-xs font-semibold">
|
||||
{formatDateGroup(items[0].created_at)}
|
||||
{formatDateGroup(items[0].created_at, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
{items.length}건
|
||||
{items.length}{t("audit.list.countSuffix")}
|
||||
</span>
|
||||
</div>
|
||||
{items.map((entry) => {
|
||||
@@ -805,13 +1021,13 @@ export default function AuditLogPage() {
|
||||
variant="secondary"
|
||||
className={`text-[10px] ${rtConfig.color}`}
|
||||
>
|
||||
{rtConfig.label}
|
||||
{t(rtConfig.langKey)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-[10px] ${actConfig.color}`}
|
||||
>
|
||||
{actConfig.label}
|
||||
{t(actConfig.langKey)}
|
||||
</Badge>
|
||||
{entry.company_code && entry.company_code !== "*" && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
@@ -840,7 +1056,7 @@ export default function AuditLogPage() {
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
변경 상세 정보
|
||||
{t("audit.detail.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{selectedEntry &&
|
||||
@@ -852,7 +1068,7 @@ export default function AuditLogPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
사용자
|
||||
{t("audit.detail.user")}
|
||||
</label>
|
||||
<p className="font-medium">
|
||||
{selectedEntry.user_name || selectedEntry.user_id}
|
||||
@@ -860,7 +1076,7 @@ export default function AuditLogPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
회사
|
||||
{t("audit.detail.company")}
|
||||
</label>
|
||||
<p className="font-medium">
|
||||
{selectedEntry.company_name || selectedEntry.company_code}
|
||||
@@ -868,24 +1084,26 @@ export default function AuditLogPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
리소스 유형
|
||||
{t("audit.detail.resourceType")}
|
||||
</label>
|
||||
<p className="font-medium">
|
||||
{RESOURCE_TYPE_CONFIG[selectedEntry.resource_type]?.label ||
|
||||
selectedEntry.resource_type}
|
||||
{RESOURCE_TYPE_CONFIG[selectedEntry.resource_type]
|
||||
? t(RESOURCE_TYPE_CONFIG[selectedEntry.resource_type].langKey)
|
||||
: selectedEntry.resource_type}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">동작</label>
|
||||
<label className="text-muted-foreground text-xs">{t("audit.detail.action")}</label>
|
||||
<p className="font-medium">
|
||||
{ACTION_CONFIG[selectedEntry.action]?.label ||
|
||||
selectedEntry.action}
|
||||
{ACTION_CONFIG[selectedEntry.action]
|
||||
? t(ACTION_CONFIG[selectedEntry.action].langKey)
|
||||
: selectedEntry.action}
|
||||
</p>
|
||||
</div>
|
||||
{selectedEntry.resource_name && (
|
||||
<div className="col-span-2">
|
||||
<label className="text-muted-foreground text-xs">
|
||||
리소스명
|
||||
{t("audit.detail.resourceName")}
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.resource_name}</p>
|
||||
</div>
|
||||
@@ -893,7 +1111,7 @@ export default function AuditLogPage() {
|
||||
{selectedEntry.table_name && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
테이블명
|
||||
{t("audit.detail.tableName")}
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.table_name}</p>
|
||||
</div>
|
||||
@@ -901,7 +1119,7 @@ export default function AuditLogPage() {
|
||||
{selectedEntry.ip_address && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
IP 주소
|
||||
{t("audit.detail.ipAddress")}
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.ip_address}</p>
|
||||
</div>
|
||||
@@ -910,7 +1128,7 @@ export default function AuditLogPage() {
|
||||
|
||||
{selectedEntry.summary && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">요약</label>
|
||||
<label className="text-muted-foreground text-xs">{t("audit.detail.summary")}</label>
|
||||
<p className="bg-muted rounded p-2 text-xs">
|
||||
{selectedEntry.summary}
|
||||
</p>
|
||||
@@ -920,11 +1138,12 @@ export default function AuditLogPage() {
|
||||
{selectedEntry.changes && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
변경 내역
|
||||
{t("audit.detail.changes")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
{renderChanges(
|
||||
selectedEntry.changes as Record<string, unknown>
|
||||
selectedEntry.changes as Record<string, unknown>,
|
||||
t
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -933,7 +1152,7 @@ export default function AuditLogPage() {
|
||||
{selectedEntry.request_path && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
API 경로
|
||||
{t("audit.detail.apiPath")}
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{selectedEntry.request_path}
|
||||
|
||||
@@ -33,11 +33,128 @@ import {
|
||||
deleteSystemNotice,
|
||||
} from "@/lib/api/systemNotice";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
function getPriorityLabel(priority: number): { label: string; variant: "default" | "secondary" | "destructive" | "outline" } {
|
||||
if (priority >= 3) return { label: "높음", variant: "destructive" };
|
||||
if (priority === 2) return { label: "보통", variant: "default" };
|
||||
return { label: "낮음", variant: "secondary" };
|
||||
// 다국어 키 목록
|
||||
const LANG_KEYS = [
|
||||
"notice.page.title",
|
||||
"notice.page.description",
|
||||
"notice.error.title",
|
||||
"notice.error.closeLabel",
|
||||
"notice.error.loadFailed",
|
||||
"notice.error.saveFailed",
|
||||
"notice.error.deleteFailed",
|
||||
"notice.filter.statusPlaceholder",
|
||||
"notice.filter.all",
|
||||
"notice.filter.active",
|
||||
"notice.filter.inactive",
|
||||
"notice.filter.searchPlaceholder",
|
||||
"notice.list.total",
|
||||
"notice.list.countSuffix",
|
||||
"notice.list.refreshLabel",
|
||||
"notice.list.empty",
|
||||
"notice.button.create",
|
||||
"notice.button.editLabel",
|
||||
"notice.button.deleteLabel",
|
||||
"notice.column.title",
|
||||
"notice.column.status",
|
||||
"notice.column.priority",
|
||||
"notice.column.author",
|
||||
"notice.column.createdAt",
|
||||
"notice.column.actions",
|
||||
"notice.card.author",
|
||||
"notice.card.createdAt",
|
||||
"notice.status.active",
|
||||
"notice.status.inactive",
|
||||
"notice.priority.high",
|
||||
"notice.priority.medium",
|
||||
"notice.priority.low",
|
||||
"notice.form.titleCreate",
|
||||
"notice.form.titleEdit",
|
||||
"notice.form.descriptionCreate",
|
||||
"notice.form.descriptionEdit",
|
||||
"notice.form.titleLabel",
|
||||
"notice.form.titlePlaceholder",
|
||||
"notice.form.contentLabel",
|
||||
"notice.form.contentPlaceholder",
|
||||
"notice.form.priorityLabel",
|
||||
"notice.form.priorityPlaceholder",
|
||||
"notice.form.activeLabel",
|
||||
"notice.form.cancel",
|
||||
"notice.form.save",
|
||||
"notice.form.saving",
|
||||
"notice.validate.titleRequired",
|
||||
"notice.validate.contentRequired",
|
||||
"notice.delete.title",
|
||||
"notice.delete.description",
|
||||
"notice.delete.descriptionIrreversible",
|
||||
"notice.delete.cancel",
|
||||
"notice.delete.confirm",
|
||||
"notice.delete.deleting",
|
||||
] as const;
|
||||
|
||||
// 한국어 기본 텍스트
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"notice.page.title": "시스템 공지사항",
|
||||
"notice.page.description": "시스템 사용자에게 전달할 공지사항을 관리합니다.",
|
||||
"notice.error.title": "오류가 발생했습니다",
|
||||
"notice.error.closeLabel": "에러 메시지 닫기",
|
||||
"notice.error.loadFailed": "공지사항 목록을 불러오는 데 실패했습니다.",
|
||||
"notice.error.saveFailed": "저장에 실패했습니다.",
|
||||
"notice.error.deleteFailed": "삭제에 실패했습니다.",
|
||||
"notice.filter.statusPlaceholder": "상태 필터",
|
||||
"notice.filter.all": "전체",
|
||||
"notice.filter.active": "활성",
|
||||
"notice.filter.inactive": "비활성",
|
||||
"notice.filter.searchPlaceholder": "제목 또는 내용으로 검색...",
|
||||
"notice.list.total": "총",
|
||||
"notice.list.countSuffix": "건",
|
||||
"notice.list.refreshLabel": "새로고침",
|
||||
"notice.list.empty": "공지사항이 없습니다.",
|
||||
"notice.button.create": "등록",
|
||||
"notice.button.editLabel": "수정",
|
||||
"notice.button.deleteLabel": "삭제",
|
||||
"notice.column.title": "제목",
|
||||
"notice.column.status": "상태",
|
||||
"notice.column.priority": "우선순위",
|
||||
"notice.column.author": "작성자",
|
||||
"notice.column.createdAt": "작성일",
|
||||
"notice.column.actions": "관리",
|
||||
"notice.card.author": "작성자",
|
||||
"notice.card.createdAt": "작성일",
|
||||
"notice.status.active": "활성",
|
||||
"notice.status.inactive": "비활성",
|
||||
"notice.priority.high": "높음",
|
||||
"notice.priority.medium": "보통",
|
||||
"notice.priority.low": "낮음",
|
||||
"notice.form.titleCreate": "공지사항 등록",
|
||||
"notice.form.titleEdit": "공지사항 수정",
|
||||
"notice.form.descriptionCreate": "새로운 공지사항을 등록합니다.",
|
||||
"notice.form.descriptionEdit": "공지사항 내용을 수정합니다.",
|
||||
"notice.form.titleLabel": "제목",
|
||||
"notice.form.titlePlaceholder": "공지사항 제목을 입력하세요",
|
||||
"notice.form.contentLabel": "내용",
|
||||
"notice.form.contentPlaceholder": "공지사항 내용을 입력하세요",
|
||||
"notice.form.priorityLabel": "우선순위",
|
||||
"notice.form.priorityPlaceholder": "우선순위 선택",
|
||||
"notice.form.activeLabel": "활성화 (체크 시 공지사항이 사용자에게 표시됩니다)",
|
||||
"notice.form.cancel": "취소",
|
||||
"notice.form.save": "저장",
|
||||
"notice.form.saving": "저장 중...",
|
||||
"notice.validate.titleRequired": "제목을 입력해주세요.",
|
||||
"notice.validate.contentRequired": "내용을 입력해주세요.",
|
||||
"notice.delete.title": "공지사항 삭제",
|
||||
"notice.delete.description": "아래 공지사항을 삭제하시겠습니까?",
|
||||
"notice.delete.descriptionIrreversible": "이 작업은 되돌릴 수 없습니다.",
|
||||
"notice.delete.cancel": "취소",
|
||||
"notice.delete.confirm": "삭제",
|
||||
"notice.delete.deleting": "삭제 중...",
|
||||
};
|
||||
|
||||
function getPriorityLabel(priority: number): { langKey: string; variant: "default" | "secondary" | "destructive" | "outline" } {
|
||||
if (priority >= 3) return { langKey: "notice.priority.high", variant: "destructive" };
|
||||
if (priority === 2) return { langKey: "notice.priority.medium", variant: "default" };
|
||||
return { langKey: "notice.priority.low", variant: "secondary" };
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
@@ -57,6 +174,8 @@ const EMPTY_FORM: CreateSystemNoticePayload = {
|
||||
};
|
||||
|
||||
export default function SystemNoticesPage() {
|
||||
const { t } = usePageMultiLang({ keys: LANG_KEYS, defaults: DEFAULT_TEXTS, menuCode: "admin.systemNotices" });
|
||||
|
||||
const [notices, setNotices] = useState<SystemNotice[]>([]);
|
||||
const [filteredNotices, setFilteredNotices] = useState<SystemNotice[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -80,7 +199,7 @@ export default function SystemNoticesPage() {
|
||||
if (result.success && result.data) {
|
||||
setNotices(result.data);
|
||||
} else {
|
||||
setErrorMsg(result.message || "공지사항 목록을 불러오는 데 실패했습니다.");
|
||||
setErrorMsg(result.message || t("notice.error.loadFailed"));
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
@@ -128,11 +247,11 @@ export default function SystemNoticesPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
alert("제목을 입력해주세요.");
|
||||
alert(t("notice.validate.titleRequired"));
|
||||
return;
|
||||
}
|
||||
if (!formData.content.trim()) {
|
||||
alert("내용을 입력해주세요.");
|
||||
alert(t("notice.validate.contentRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,7 +268,7 @@ export default function SystemNoticesPage() {
|
||||
setIsFormOpen(false);
|
||||
await loadNotices();
|
||||
} else {
|
||||
alert(result.message || "저장에 실패했습니다.");
|
||||
alert(result.message || t("notice.error.saveFailed"));
|
||||
}
|
||||
setIsSaving(false);
|
||||
};
|
||||
@@ -162,7 +281,7 @@ export default function SystemNoticesPage() {
|
||||
setDeleteTarget(null);
|
||||
await loadNotices();
|
||||
} else {
|
||||
alert(result.message || "삭제에 실패했습니다.");
|
||||
alert(result.message || t("notice.error.deleteFailed"));
|
||||
}
|
||||
setIsDeleting(false);
|
||||
};
|
||||
@@ -170,33 +289,33 @@ export default function SystemNoticesPage() {
|
||||
const columns: RDVColumn<SystemNotice>[] = [
|
||||
{
|
||||
key: "title",
|
||||
label: "제목",
|
||||
label: t("notice.column.title"),
|
||||
render: (_val, notice) => (
|
||||
<span className="font-medium">{notice.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "상태",
|
||||
label: t("notice.column.status"),
|
||||
width: "100px",
|
||||
render: (_val, notice) => (
|
||||
<Badge variant={notice.is_active ? "default" : "secondary"}>
|
||||
{notice.is_active ? "활성" : "비활성"}
|
||||
{notice.is_active ? t("notice.status.active") : t("notice.status.inactive")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "priority",
|
||||
label: "우선순위",
|
||||
label: t("notice.column.priority"),
|
||||
width: "100px",
|
||||
render: (_val, notice) => {
|
||||
const p = getPriorityLabel(notice.priority);
|
||||
return <Badge variant={p.variant}>{p.label}</Badge>;
|
||||
return <Badge variant={p.variant}>{t(p.langKey)}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "created_by",
|
||||
label: "작성자",
|
||||
label: t("notice.column.author"),
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (_val, notice) => (
|
||||
@@ -205,7 +324,7 @@ export default function SystemNoticesPage() {
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
label: "작성일",
|
||||
label: t("notice.column.createdAt"),
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (_val, notice) => (
|
||||
@@ -216,11 +335,11 @@ export default function SystemNoticesPage() {
|
||||
|
||||
const cardFields: RDVCardField<SystemNotice>[] = [
|
||||
{
|
||||
label: "작성자",
|
||||
label: t("notice.card.author"),
|
||||
render: (notice) => notice.created_by || "-",
|
||||
},
|
||||
{
|
||||
label: "작성일",
|
||||
label: t("notice.card.createdAt"),
|
||||
render: (notice) => formatDate(notice.created_at),
|
||||
},
|
||||
];
|
||||
@@ -230,9 +349,9 @@ export default function SystemNoticesPage() {
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">시스템 공지사항</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("notice.page.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
시스템 사용자에게 전달할 공지사항을 관리합니다.
|
||||
{t("notice.page.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -240,11 +359,11 @@ export default function SystemNoticesPage() {
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-destructive">오류가 발생했습니다</p>
|
||||
<p className="text-sm font-semibold text-destructive">{t("notice.error.title")}</p>
|
||||
<button
|
||||
onClick={() => setErrorMsg(null)}
|
||||
className="text-destructive transition-colors hover:text-destructive/80"
|
||||
aria-label="에러 메시지 닫기"
|
||||
aria-label={t("notice.error.closeLabel")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -259,12 +378,12 @@ export default function SystemNoticesPage() {
|
||||
<div className="w-full sm:w-[160px]">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="상태 필터" />
|
||||
<SelectValue placeholder={t("notice.filter.statusPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="active">활성</SelectItem>
|
||||
<SelectItem value="inactive">비활성</SelectItem>
|
||||
<SelectItem value="all">{t("notice.filter.all")}</SelectItem>
|
||||
<SelectItem value="active">{t("notice.filter.active")}</SelectItem>
|
||||
<SelectItem value="inactive">{t("notice.filter.inactive")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -272,7 +391,7 @@ export default function SystemNoticesPage() {
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="제목 또는 내용으로 검색..."
|
||||
placeholder={t("notice.filter.searchPlaceholder")}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
@@ -282,20 +401,20 @@ export default function SystemNoticesPage() {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredNotices.length}</span> 건
|
||||
{t("notice.list.total")} <span className="font-semibold text-foreground">{filteredNotices.length}</span> {t("notice.list.countSuffix")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={loadNotices}
|
||||
aria-label="새로고침"
|
||||
aria-label={t("notice.list.refreshLabel")}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button className="h-10 gap-2 text-sm font-medium" onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
{t("notice.button.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,7 +424,7 @@ export default function SystemNoticesPage() {
|
||||
columns={columns}
|
||||
keyExtractor={(n) => String(n.id)}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="공지사항이 없습니다."
|
||||
emptyMessage={t("notice.list.empty")}
|
||||
skeletonCount={5}
|
||||
cardTitle={(n) => n.title}
|
||||
cardHeaderRight={(n) => (
|
||||
@@ -315,7 +434,7 @@ export default function SystemNoticesPage() {
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleOpenEdit(n)}
|
||||
aria-label="수정"
|
||||
aria-label={t("notice.button.editLabel")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -324,7 +443,7 @@ export default function SystemNoticesPage() {
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(n)}
|
||||
aria-label="삭제"
|
||||
aria-label={t("notice.button.deleteLabel")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -335,14 +454,14 @@ export default function SystemNoticesPage() {
|
||||
return (
|
||||
<span className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge variant={n.is_active ? "default" : "secondary"}>
|
||||
{n.is_active ? "활성" : "비활성"}
|
||||
{n.is_active ? t("notice.status.active") : t("notice.status.inactive")}
|
||||
</Badge>
|
||||
<Badge variant={p.variant}>{p.label}</Badge>
|
||||
<Badge variant={p.variant}>{t(p.langKey)}</Badge>
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="관리"
|
||||
actionsLabel={t("notice.column.actions")}
|
||||
actionsWidth="120px"
|
||||
renderActions={(notice) => (
|
||||
<>
|
||||
@@ -351,7 +470,7 @@ export default function SystemNoticesPage() {
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleOpenEdit(notice)}
|
||||
aria-label="수정"
|
||||
aria-label={t("notice.button.editLabel")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -360,7 +479,7 @@ export default function SystemNoticesPage() {
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(notice)}
|
||||
aria-label="삭제"
|
||||
aria-label={t("notice.button.deleteLabel")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -374,43 +493,43 @@ export default function SystemNoticesPage() {
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[540px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{editTarget ? "공지사항 수정" : "공지사항 등록"}
|
||||
{editTarget ? t("notice.form.titleEdit") : t("notice.form.titleCreate")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{editTarget ? "공지사항 내용을 수정합니다." : "새로운 공지사항을 등록합니다."}
|
||||
{editTarget ? t("notice.form.descriptionEdit") : t("notice.form.descriptionCreate")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="notice-title" className="text-xs sm:text-sm">
|
||||
제목 <span className="text-destructive">*</span>
|
||||
{t("notice.form.titleLabel")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="notice-title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="공지사항 제목을 입력하세요"
|
||||
placeholder={t("notice.form.titlePlaceholder")}
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notice-content" className="text-xs sm:text-sm">
|
||||
내용 <span className="text-destructive">*</span>
|
||||
{t("notice.form.contentLabel")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notice-content"
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, content: e.target.value }))}
|
||||
placeholder="공지사항 내용을 입력하세요"
|
||||
placeholder={t("notice.form.contentPlaceholder")}
|
||||
className="mt-1 min-h-[120px] text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notice-priority" className="text-xs sm:text-sm">
|
||||
우선순위
|
||||
{t("notice.form.priorityLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(formData.priority)}
|
||||
@@ -419,12 +538,12 @@ export default function SystemNoticesPage() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="notice-priority" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="우선순위 선택" />
|
||||
<SelectValue placeholder={t("notice.form.priorityPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">낮음</SelectItem>
|
||||
<SelectItem value="2">보통</SelectItem>
|
||||
<SelectItem value="3">높음</SelectItem>
|
||||
<SelectItem value="1">{t("notice.priority.low")}</SelectItem>
|
||||
<SelectItem value="2">{t("notice.priority.medium")}</SelectItem>
|
||||
<SelectItem value="3">{t("notice.priority.high")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -438,7 +557,7 @@ export default function SystemNoticesPage() {
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="notice-active" className="cursor-pointer text-xs sm:text-sm">
|
||||
활성화 (체크 시 공지사항이 사용자에게 표시됩니다)
|
||||
{t("notice.form.activeLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,14 +569,14 @@ export default function SystemNoticesPage() {
|
||||
disabled={isSaving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
{t("notice.form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
{isSaving ? t("notice.form.saving") : t("notice.form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -467,10 +586,10 @@ export default function SystemNoticesPage() {
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">공지사항 삭제</DialogTitle>
|
||||
<DialogTitle className="text-base sm:text-lg">{t("notice.delete.title")}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
아래 공지사항을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
{t("notice.delete.description")}
|
||||
<br />{t("notice.delete.descriptionIrreversible")}
|
||||
<br />
|
||||
<span className="mt-2 block font-medium text-foreground">
|
||||
"{deleteTarget?.title}"
|
||||
@@ -484,7 +603,7 @@ export default function SystemNoticesPage() {
|
||||
disabled={isDeleting}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
{t("notice.delete.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -492,7 +611,7 @@ export default function SystemNoticesPage() {
|
||||
disabled={isDeleting}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
{isDeleting ? t("notice.delete.deleting") : t("notice.delete.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -26,22 +26,108 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
History,
|
||||
RefreshCw
|
||||
RefreshCw
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
|
||||
import CollectionConfigModal from "@/components/admin/CollectionConfigModal";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
// 다국어 키 목록
|
||||
const LANG_KEYS = [
|
||||
"collection.title",
|
||||
"collection.description",
|
||||
"collection.newConfig",
|
||||
"collection.filter.title",
|
||||
"collection.filter.searchPlaceholder",
|
||||
"collection.filter.statusAll",
|
||||
"collection.filter.statusActive",
|
||||
"collection.filter.statusInactive",
|
||||
"collection.filter.typeAll",
|
||||
"collection.filter.refresh",
|
||||
"collection.list.title",
|
||||
"collection.list.countSuffix",
|
||||
"collection.list.loading",
|
||||
"collection.list.emptyNoConfig",
|
||||
"collection.list.emptyNoResult",
|
||||
"collection.table.configName",
|
||||
"collection.table.collectionType",
|
||||
"collection.table.sourceTable",
|
||||
"collection.table.targetTable",
|
||||
"collection.table.schedule",
|
||||
"collection.table.status",
|
||||
"collection.table.lastCollected",
|
||||
"collection.table.actions",
|
||||
"collection.status.active",
|
||||
"collection.status.inactive",
|
||||
"collection.action.edit",
|
||||
"collection.action.execute",
|
||||
"collection.action.delete",
|
||||
"collection.confirm.delete",
|
||||
"collection.toast.deleteSuccess",
|
||||
"collection.toast.deleteFail",
|
||||
"collection.toast.executeSuccess",
|
||||
"collection.toast.executeFail",
|
||||
"collection.toast.executeFailGuidance",
|
||||
"collection.toast.loadFail",
|
||||
"collection.toast.loadFailGuidance",
|
||||
] as const;
|
||||
|
||||
// 한국어 기본 텍스트
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"collection.title": "수집 관리",
|
||||
"collection.description": "외부 데이터베이스에서 데이터를 수집하는 설정을 관리합니다.",
|
||||
"collection.newConfig": "새 수집 설정",
|
||||
"collection.filter.title": "필터 및 검색",
|
||||
"collection.filter.searchPlaceholder": "설정명, 테이블명, 설명으로 검색...",
|
||||
"collection.filter.statusAll": "전체",
|
||||
"collection.filter.statusActive": "활성",
|
||||
"collection.filter.statusInactive": "비활성",
|
||||
"collection.filter.typeAll": "전체 타입",
|
||||
"collection.filter.refresh": "새로고침",
|
||||
"collection.list.title": "수집 설정 목록",
|
||||
"collection.list.countSuffix": "{count}개",
|
||||
"collection.list.loading": "수집 설정을 불러오는 중...",
|
||||
"collection.list.emptyNoConfig": "수집 설정이 없습니다.",
|
||||
"collection.list.emptyNoResult": "검색 결과가 없습니다.",
|
||||
"collection.table.configName": "설정명",
|
||||
"collection.table.collectionType": "수집 타입",
|
||||
"collection.table.sourceTable": "소스 테이블",
|
||||
"collection.table.targetTable": "대상 테이블",
|
||||
"collection.table.schedule": "스케줄",
|
||||
"collection.table.status": "상태",
|
||||
"collection.table.lastCollected": "마지막 수집",
|
||||
"collection.table.actions": "작업",
|
||||
"collection.status.active": "활성",
|
||||
"collection.status.inactive": "비활성",
|
||||
"collection.action.edit": "수정",
|
||||
"collection.action.execute": "실행",
|
||||
"collection.action.delete": "삭제",
|
||||
"collection.confirm.delete": "\"{name}\" 수집 설정을 삭제하시겠습니까?",
|
||||
"collection.toast.deleteSuccess": "수집 설정이 삭제되었습니다.",
|
||||
"collection.toast.deleteFail": "수집 설정 삭제에 실패했습니다.",
|
||||
"collection.toast.executeSuccess": "\"{name}\" 수집 작업을 시작했습니다.",
|
||||
"collection.toast.executeFail": "수집 작업 실행에 실패했습니다",
|
||||
"collection.toast.executeFailGuidance": "수집 설정을 확인해 주세요.",
|
||||
"collection.toast.loadFail": "수집 설정 목록을 불러오는 데 실패했습니다",
|
||||
"collection.toast.loadFailGuidance": "네트워크 연결을 확인해 주세요.",
|
||||
};
|
||||
|
||||
export default function CollectionManagementPage() {
|
||||
const { t } = usePageMultiLang({
|
||||
keys: LANG_KEYS,
|
||||
defaults: DEFAULT_TEXTS,
|
||||
menuCode: "admin.systemMng.collectionManagement",
|
||||
});
|
||||
const [configs, setConfigs] = useState<DataCollectionConfig[]>([]);
|
||||
const [filteredConfigs, setFilteredConfigs] = useState<DataCollectionConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -70,7 +156,7 @@ export default function CollectionManagementPage() {
|
||||
setConfigs(data);
|
||||
} catch (error) {
|
||||
console.error("수집 설정 목록 조회 오류:", error);
|
||||
showErrorToast("수집 설정 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
||||
showErrorToast(t("collection.toast.loadFail"), error, { guidance: t("collection.toast.loadFailGuidance") });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -112,27 +198,27 @@ export default function CollectionManagementPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (config: DataCollectionConfig) => {
|
||||
if (!confirm(`"${config.config_name}" 수집 설정을 삭제하시겠습니까?`)) {
|
||||
if (!confirm(t("collection.confirm.delete").replace("{name}", config.config_name))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await CollectionAPI.deleteCollectionConfig(config.id!);
|
||||
toast.success("수집 설정이 삭제되었습니다.");
|
||||
toast.success(t("collection.toast.deleteSuccess"));
|
||||
loadConfigs();
|
||||
} catch (error) {
|
||||
console.error("수집 설정 삭제 오류:", error);
|
||||
toast.error("수집 설정 삭제에 실패했습니다.");
|
||||
toast.error(t("collection.toast.deleteFail"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (config: DataCollectionConfig) => {
|
||||
try {
|
||||
await CollectionAPI.executeCollection(config.id!);
|
||||
toast.success(`"${config.config_name}" 수집 작업을 시작했습니다.`);
|
||||
toast.success(t("collection.toast.executeSuccess").replace("{name}", config.config_name));
|
||||
} catch (error) {
|
||||
console.error("수집 작업 실행 오류:", error);
|
||||
showErrorToast("수집 작업 실행에 실패했습니다", error, { guidance: "수집 설정을 확인해 주세요." });
|
||||
showErrorToast(t("collection.toast.executeFail"), error, { guidance: t("collection.toast.executeFailGuidance") });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,9 +228,9 @@ export default function CollectionManagementPage() {
|
||||
|
||||
const getStatusBadge = (isActive: string) => {
|
||||
return isActive === "Y" ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-800">활성</Badge>
|
||||
<Badge className="bg-emerald-100 text-emerald-800">{t("collection.status.active")}</Badge>
|
||||
) : (
|
||||
<Badge className="bg-destructive/10 text-red-800">비활성</Badge>
|
||||
<Badge className="bg-destructive/10 text-red-800">{t("collection.status.inactive")}</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -168,21 +254,21 @@ export default function CollectionManagementPage() {
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">수집 관리</h1>
|
||||
<h1 className="text-2xl font-bold">{t("collection.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
외부 데이터베이스에서 데이터를 수집하는 설정을 관리합니다.
|
||||
{t("collection.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
새 수집 설정
|
||||
{t("collection.newConfig")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>필터 및 검색</CardTitle>
|
||||
<CardTitle>{t("collection.filter.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
@@ -190,7 +276,7 @@ export default function CollectionManagementPage() {
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="설정명, 테이블명, 설명으로 검색..."
|
||||
placeholder={t("collection.filter.searchPlaceholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -200,21 +286,21 @@ export default function CollectionManagementPage() {
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="상태" />
|
||||
<SelectValue placeholder={t("collection.table.status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="Y">활성</SelectItem>
|
||||
<SelectItem value="N">비활성</SelectItem>
|
||||
<SelectItem value="all">{t("collection.filter.statusAll")}</SelectItem>
|
||||
<SelectItem value="Y">{t("collection.filter.statusActive")}</SelectItem>
|
||||
<SelectItem value="N">{t("collection.filter.statusInactive")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="수집 타입" />
|
||||
<SelectValue placeholder={t("collection.table.collectionType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 타입</SelectItem>
|
||||
<SelectItem value="all">{t("collection.filter.typeAll")}</SelectItem>
|
||||
{collectionTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
@@ -225,7 +311,7 @@ export default function CollectionManagementPage() {
|
||||
|
||||
<Button variant="outline" onClick={loadConfigs} disabled={isLoading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
{t("collection.filter.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -234,30 +320,30 @@ export default function CollectionManagementPage() {
|
||||
{/* 수집 설정 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>수집 설정 목록 ({filteredConfigs.length}개)</CardTitle>
|
||||
<CardTitle>{t("collection.list.title")} ({t("collection.list.countSuffix", { count: filteredConfigs.length })})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||
<p>수집 설정을 불러오는 중...</p>
|
||||
<p>{t("collection.list.loading")}</p>
|
||||
</div>
|
||||
) : filteredConfigs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{configs.length === 0 ? "수집 설정이 없습니다." : "검색 결과가 없습니다."}
|
||||
{configs.length === 0 ? t("collection.list.emptyNoConfig") : t("collection.list.emptyNoResult")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">설정명</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">수집 타입</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">소스 테이블</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">대상 테이블</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">스케줄</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">마지막 수집</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">{t("collection.table.configName")}</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">{t("collection.table.collectionType")}</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">{t("collection.table.sourceTable")}</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">{t("collection.table.targetTable")}</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">{t("collection.table.schedule")}</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">{t("collection.table.status")}</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">{t("collection.table.lastCollected")}</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">{t("collection.table.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -303,18 +389,18 @@ export default function CollectionManagementPage() {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(config)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
{t("collection.action.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExecute(config)}
|
||||
disabled={config.is_active !== "Y"}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
실행
|
||||
{t("collection.action.execute")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(config)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
{t("collection.action.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -4,8 +4,30 @@ import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
|
||||
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
|
||||
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
// 다국어 키 목록
|
||||
const LANG_KEYS = [
|
||||
"commonCode.page.title",
|
||||
"commonCode.page.description",
|
||||
"commonCode.category.title",
|
||||
"commonCode.detail.title",
|
||||
] as const;
|
||||
|
||||
// 한국어 기본 텍스트
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"commonCode.page.title": "공통코드 관리",
|
||||
"commonCode.page.description": "시스템에서 사용하는 공통코드를 관리합니다",
|
||||
"commonCode.category.title": "코드 카테고리",
|
||||
"commonCode.detail.title": "코드 상세 정보",
|
||||
};
|
||||
|
||||
export default function CommonCodeManagementPage() {
|
||||
const { t } = usePageMultiLang({
|
||||
keys: LANG_KEYS,
|
||||
defaults: DEFAULT_TEXTS,
|
||||
menuCode: "admin.systemMng.commonCode",
|
||||
});
|
||||
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
|
||||
|
||||
return (
|
||||
@@ -13,8 +35,8 @@ export default function CommonCodeManagementPage() {
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">공통코드 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">시스템에서 사용하는 공통코드를 관리합니다</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("commonCode.page.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("commonCode.page.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
|
||||
@@ -22,7 +44,7 @@ export default function CommonCodeManagementPage() {
|
||||
{/* 좌측: 카테고리 패널 */}
|
||||
<div className="w-full lg:w-80 lg:border-r lg:pr-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">코드 카테고리</h2>
|
||||
<h2 className="text-lg font-semibold">{t("commonCode.category.title")}</h2>
|
||||
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,7 +53,7 @@ export default function CommonCodeManagementPage() {
|
||||
<div className="min-w-0 flex-1 lg:pl-0">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
코드 상세 정보
|
||||
{t("commonCode.detail.title")}
|
||||
{selectedCategoryCode && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user