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:
kjs
2026-04-01 16:16:40 +09:00
parent 1d49fc7ac7
commit 540b6290c4
4 changed files with 687 additions and 241 deletions
+356 -137
View File
@@ -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}
+176 -57
View File
@@ -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">
&quot;{deleteTarget?.title}&quot;
@@ -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>
)}