Implement multi-language support in user management and system management pages
- Integrated multi-language functionality across various user management components, including user list, roles list, and user authorization pages, 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 user management features, making the application more inclusive.
This commit is contained in:
@@ -651,15 +651,17 @@ export default function I18nPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none px-4 py-8">
|
||||
<div className="container mx-auto p-2">
|
||||
<div className="flex h-[calc(100vh-64px)] flex-col bg-muted">
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 py-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="flex space-x-1 border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab("keys")}
|
||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
activeTab === "keys" ? "bg-accent0 text-white" : "bg-muted text-foreground hover:bg-muted/80"
|
||||
activeTab === "keys"
|
||||
? "border-b-2 border-b-blue-500 bg-white text-blue-600 dark:bg-zinc-900 dark:text-blue-400"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
다국어 키 관리
|
||||
@@ -667,7 +669,9 @@ export default function I18nPage() {
|
||||
<button
|
||||
onClick={() => setActiveTab("languages")}
|
||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
activeTab === "languages" ? "bg-accent0 text-white" : "bg-muted text-foreground hover:bg-muted/80"
|
||||
activeTab === "languages"
|
||||
? "border-b-2 border-b-blue-500 bg-white text-blue-600 dark:bg-zinc-900 dark:text-blue-400"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
언어 관리
|
||||
@@ -675,14 +679,14 @@ export default function I18nPage() {
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 영역 */}
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
{/* 언어 관리 탭 */}
|
||||
{activeTab === "languages" && (
|
||||
<Card>
|
||||
<Card className="flex min-h-0 flex-1 flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>언어 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="min-h-0 flex-1">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">총 {languages.length}개의 언어가 등록되어 있습니다.</div>
|
||||
<div className="flex space-x-2">
|
||||
@@ -701,16 +705,16 @@ export default function I18nPage() {
|
||||
|
||||
{/* 다국어 키 관리 탭 */}
|
||||
{activeTab === "keys" && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
{/* 좌측: 카테고리 트리 (2/12) */}
|
||||
<Card className="lg:col-span-2">
|
||||
<Card className="flex min-h-0 flex-col lg:col-span-2">
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">카테고리</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<ScrollArea className="h-[500px]">
|
||||
<CardContent className="min-h-0 flex-1 p-2">
|
||||
<ScrollArea className="h-full">
|
||||
<CategoryTree
|
||||
selectedCategoryId={selectedCategory?.categoryId || null}
|
||||
onSelectCategory={(cat) => setSelectedCategory(cat)}
|
||||
@@ -724,7 +728,7 @@ export default function I18nPage() {
|
||||
</Card>
|
||||
|
||||
{/* 중앙: 언어 키 목록 (6/12) */}
|
||||
<Card className="lg:col-span-6">
|
||||
<Card className="flex min-h-0 flex-col lg:col-span-6">
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">
|
||||
@@ -758,7 +762,7 @@ export default function I18nPage() {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<CardContent className="min-h-0 flex-1 pt-0">
|
||||
{/* 검색 필터 영역 */}
|
||||
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<div>
|
||||
@@ -806,7 +810,7 @@ export default function I18nPage() {
|
||||
</Card>
|
||||
|
||||
{/* 우측: 선택된 키의 다국어 관리 (4/12) */}
|
||||
<Card className="lg:col-span-4">
|
||||
<Card className="flex min-h-0 flex-col lg:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedKey ? (
|
||||
@@ -821,11 +825,11 @@ export default function I18nPage() {
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="min-h-0 flex-1">
|
||||
{selectedKey ? (
|
||||
<div>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 스크롤 가능한 텍스트 영역 */}
|
||||
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-2">
|
||||
{languages
|
||||
.filter((lang) => lang.isActive === "Y")
|
||||
.map((lang) => {
|
||||
@@ -854,7 +858,7 @@ export default function I18nPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg font-medium">언어 키를 선택하세요</div>
|
||||
<div className="text-sm">좌측 목록에서 편집할 언어 키를 클릭하세요</div>
|
||||
|
||||
@@ -10,6 +10,109 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
// 다국어 키 목록
|
||||
const LANG_KEYS = [
|
||||
"company.page.title",
|
||||
"company.page.description",
|
||||
"company.access.denied.title",
|
||||
"company.access.denied.description",
|
||||
"company.access.denied.back",
|
||||
"company.toolbar.total",
|
||||
"company.toolbar.create",
|
||||
"company.table.companyCode",
|
||||
"company.table.companyName",
|
||||
"company.table.writer",
|
||||
"company.table.diskUsage",
|
||||
"company.table.diskNoInfo",
|
||||
"company.table.fileCount",
|
||||
"company.table.actions",
|
||||
"company.table.empty",
|
||||
"company.table.cardWriter",
|
||||
"company.table.cardDiskUsage",
|
||||
"company.table.actionDept",
|
||||
"company.table.actionEdit",
|
||||
"company.table.actionDelete",
|
||||
"company.form.titleCreate",
|
||||
"company.form.titleEdit",
|
||||
"company.form.companyName",
|
||||
"company.form.companyNamePlaceholder",
|
||||
"company.form.businessNumber",
|
||||
"company.form.businessNumberHint",
|
||||
"company.form.representativeName",
|
||||
"company.form.representativeNamePlaceholder",
|
||||
"company.form.representativePhone",
|
||||
"company.form.email",
|
||||
"company.form.website",
|
||||
"company.form.address",
|
||||
"company.form.addressPlaceholder",
|
||||
"company.form.companyCodeLabel",
|
||||
"company.form.writerLabel",
|
||||
"company.form.regdateLabel",
|
||||
"company.form.cancel",
|
||||
"company.form.save",
|
||||
"company.form.update",
|
||||
"company.delete.title",
|
||||
"company.delete.description",
|
||||
"company.delete.companyName",
|
||||
"company.delete.companyCode",
|
||||
"company.delete.writer",
|
||||
"company.delete.regdate",
|
||||
"company.delete.cancel",
|
||||
"company.delete.confirm",
|
||||
] as const;
|
||||
|
||||
// 한국어 기본 텍스트
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"company.page.title": "회사 관리",
|
||||
"company.page.description": "시스템에서 사용하는 회사 정보를 관리합니다",
|
||||
"company.access.denied.title": "접근 권한 없음",
|
||||
"company.access.denied.description": "회사 관리는 최고 관리자만 접근할 수 있습니다.",
|
||||
"company.access.denied.back": "뒤로 가기",
|
||||
"company.toolbar.total": "총",
|
||||
"company.toolbar.create": "회사 등록",
|
||||
"company.table.companyCode": "회사코드",
|
||||
"company.table.companyName": "회사명",
|
||||
"company.table.writer": "등록자",
|
||||
"company.table.diskUsage": "디스크 사용량",
|
||||
"company.table.diskNoInfo": "정보 없음",
|
||||
"company.table.fileCount": "{count}개 파일",
|
||||
"company.table.actions": "작업",
|
||||
"company.table.empty": "등록된 회사가 없습니다.",
|
||||
"company.table.cardWriter": "작성자",
|
||||
"company.table.cardDiskUsage": "디스크 사용량",
|
||||
"company.table.actionDept": "부서관리",
|
||||
"company.table.actionEdit": "수정",
|
||||
"company.table.actionDelete": "삭제",
|
||||
"company.form.titleCreate": "새 회사 등록",
|
||||
"company.form.titleEdit": "회사 정보 수정",
|
||||
"company.form.companyName": "회사명",
|
||||
"company.form.companyNamePlaceholder": "회사명을 입력하세요",
|
||||
"company.form.businessNumber": "사업자등록번호",
|
||||
"company.form.businessNumberHint": "10자리 숫자 (자동 하이픈 추가)",
|
||||
"company.form.representativeName": "대표자명",
|
||||
"company.form.representativeNamePlaceholder": "대표자명을 입력하세요",
|
||||
"company.form.representativePhone": "대표 연락처",
|
||||
"company.form.email": "이메일",
|
||||
"company.form.website": "웹사이트",
|
||||
"company.form.address": "회사 주소",
|
||||
"company.form.addressPlaceholder": "서울특별시 강남구...",
|
||||
"company.form.companyCodeLabel": "회사 코드:",
|
||||
"company.form.writerLabel": "등록자:",
|
||||
"company.form.regdateLabel": "등록일:",
|
||||
"company.form.cancel": "취소",
|
||||
"company.form.save": "등록",
|
||||
"company.form.update": "수정",
|
||||
"company.delete.title": "회사 삭제 확인",
|
||||
"company.delete.description": "선택한 회사를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"company.delete.companyName": "회사명",
|
||||
"company.delete.companyCode": "회사 코드",
|
||||
"company.delete.writer": "등록자",
|
||||
"company.delete.regdate": "등록일",
|
||||
"company.delete.cancel": "취소",
|
||||
"company.delete.confirm": "삭제",
|
||||
};
|
||||
|
||||
/**
|
||||
* 회사 관리 페이지
|
||||
@@ -19,6 +122,12 @@ export default function CompanyPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||
|
||||
const { t } = usePageMultiLang({
|
||||
keys: LANG_KEYS,
|
||||
defaults: DEFAULT_TEXTS,
|
||||
menuCode: "company.management",
|
||||
});
|
||||
|
||||
const {
|
||||
// 데이터
|
||||
companies,
|
||||
@@ -62,17 +171,17 @@ export default function CompanyPage() {
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<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("company.page.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("company.page.description")}</p>
|
||||
</div>
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
||||
<h3 className="mb-2 text-lg font-semibold">{t("company.access.denied.title")}</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
회사 관리는 최고 관리자만 접근할 수 있습니다.
|
||||
{t("company.access.denied.description")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
뒤로 가기
|
||||
{t("company.access.denied.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,8 +194,8 @@ export default function CompanyPage() {
|
||||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||
{/* 페이지 헤더 */}
|
||||
<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("company.page.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("company.page.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* 디스크 사용량 요약 */}
|
||||
@@ -100,10 +209,11 @@ export default function CompanyPage() {
|
||||
onSearchChange={updateSearchFilter}
|
||||
onSearchClear={clearSearchFilter}
|
||||
onCreateClick={openCreateModal}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 회사 목록 테이블 */}
|
||||
<CompanyTable companies={companies} isLoading={isLoading} onEdit={openEditModal} onDelete={openDeleteDialog} />
|
||||
<CompanyTable companies={companies} isLoading={isLoading} onEdit={openEditModal} onDelete={openDeleteDialog} t={t} />
|
||||
|
||||
{/* 회사 등록/수정 모달 */}
|
||||
<CompanyFormModal
|
||||
@@ -114,6 +224,7 @@ export default function CompanyPage() {
|
||||
onSave={saveCompany}
|
||||
onFormChange={updateFormData}
|
||||
onClearError={clearError}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 회사 삭제 확인 다이얼로그 */}
|
||||
@@ -124,6 +235,7 @@ export default function CompanyPage() {
|
||||
onClose={closeDeleteDialog}
|
||||
onConfirm={deleteCompany}
|
||||
onClearError={clearError}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,81 @@ import { DualListBox } from "@/components/common/DualListBox";
|
||||
import { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
const LANG_KEYS = [
|
||||
"detail.loading",
|
||||
"detail.error.title",
|
||||
"detail.error.load",
|
||||
"detail.error.load.generic",
|
||||
"detail.error.notfound",
|
||||
"detail.button.backToList",
|
||||
"detail.status.active",
|
||||
"detail.status.inactive",
|
||||
"detail.tab.members",
|
||||
"detail.tab.permissions",
|
||||
"detail.members.title",
|
||||
"detail.members.description",
|
||||
"detail.members.mode.user",
|
||||
"detail.members.mode.dept",
|
||||
"detail.members.saving",
|
||||
"detail.members.save",
|
||||
"detail.members.available",
|
||||
"detail.members.selected",
|
||||
"detail.members.dept.available",
|
||||
"detail.members.dept.selected",
|
||||
"detail.members.summary",
|
||||
"detail.members.summary.count",
|
||||
"detail.members.empty",
|
||||
"detail.members.save.success",
|
||||
"detail.members.save.fail",
|
||||
"detail.members.save.error",
|
||||
"detail.permissions.title",
|
||||
"detail.permissions.description",
|
||||
"detail.permissions.saving",
|
||||
"detail.permissions.save",
|
||||
"detail.permissions.save.success",
|
||||
"detail.permissions.save.fail",
|
||||
"detail.permissions.save.error",
|
||||
"detail.count.members",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"detail.loading": "권한 그룹 정보를 불러오는 중...",
|
||||
"detail.error.title": "오류 발생",
|
||||
"detail.error.load": "권한 그룹 정보를 불러오는데 실패했습니다.",
|
||||
"detail.error.load.generic": "권한 그룹 정보를 불러오는 중 오류가 발생했습니다.",
|
||||
"detail.error.notfound": "권한 그룹을 찾을 수 없습니다.",
|
||||
"detail.button.backToList": "목록으로 돌아가기",
|
||||
"detail.status.active": "활성",
|
||||
"detail.status.inactive": "비활성",
|
||||
"detail.tab.members": "멤버 관리",
|
||||
"detail.tab.permissions": "메뉴 권한",
|
||||
"detail.members.title": "멤버 관리",
|
||||
"detail.members.description": "이 권한 그룹에 속한 사용자를 관리합니다",
|
||||
"detail.members.mode.user": "사용자별",
|
||||
"detail.members.mode.dept": "부서별",
|
||||
"detail.members.saving": "저장 중...",
|
||||
"detail.members.save": "멤버 저장",
|
||||
"detail.members.available": "전체 사용자",
|
||||
"detail.members.selected": "그룹 멤버",
|
||||
"detail.members.dept.available": "부서 목록 (선택 시 소속 사용자 전체 추가)",
|
||||
"detail.members.dept.selected": "추가된 부서",
|
||||
"detail.members.summary": "현재 그룹 멤버",
|
||||
"detail.members.summary.count": "현재 그룹 멤버 ({count}명)",
|
||||
"detail.members.empty": "멤버가 없습니다",
|
||||
"detail.members.save.success": "멤버가 성공적으로 저장되었습니다.",
|
||||
"detail.members.save.fail": "멤버 저장에 실패했습니다.",
|
||||
"detail.members.save.error": "멤버 저장 중 오류가 발생했습니다.",
|
||||
"detail.permissions.title": "메뉴 권한 설정",
|
||||
"detail.permissions.description": "이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다",
|
||||
"detail.permissions.saving": "저장 중...",
|
||||
"detail.permissions.save": "권한 저장",
|
||||
"detail.permissions.save.success": "메뉴 권한이 성공적으로 저장되었습니다.",
|
||||
"detail.permissions.save.fail": "메뉴 권한 저장에 실패했습니다.",
|
||||
"detail.permissions.save.error": "메뉴 권한 저장 중 오류가 발생했습니다.",
|
||||
"detail.count.members": "{count}명",
|
||||
};
|
||||
|
||||
/**
|
||||
* 권한 그룹 상세 페이지
|
||||
@@ -26,6 +101,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
const { user: currentUser } = useAuth();
|
||||
const router = useRouter();
|
||||
const { refreshMenus } = useMenu();
|
||||
const { t } = usePageMultiLang({ keys: LANG_KEYS, defaults: DEFAULT_TEXTS, menuCode: "roles.management" });
|
||||
|
||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||
|
||||
@@ -61,11 +137,11 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
if (response.success && response.data) {
|
||||
setRoleGroup(response.data);
|
||||
} else {
|
||||
setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
|
||||
setError(response.message || t("detail.error.load"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("권한 그룹 정보 로드 오류:", err);
|
||||
setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
|
||||
setError(t("detail.error.load.generic"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -130,7 +206,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
return {
|
||||
id: `dept_${deptCode}`,
|
||||
label: dept.deptName || dept.dept_name || deptCode,
|
||||
description: `${userIds.length}명`,
|
||||
description: t("detail.count.members", { count: userIds.length }),
|
||||
userIds,
|
||||
};
|
||||
}).filter((d: any) => d.userIds.length > 0),
|
||||
@@ -236,17 +312,17 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
|
||||
|
||||
if (response.success) {
|
||||
alert("멤버가 성공적으로 저장되었습니다.");
|
||||
alert(t("detail.members.save.success"));
|
||||
loadMembers(); // 새로고침
|
||||
|
||||
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
|
||||
await refreshMenus();
|
||||
} else {
|
||||
alert(response.message || "멤버 저장에 실패했습니다.");
|
||||
alert(response.message || t("detail.members.save.fail"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("멤버 저장 오류:", err);
|
||||
alert("멤버 저장 중 오류가 발생했습니다.");
|
||||
alert(t("detail.members.save.error"));
|
||||
} finally {
|
||||
setIsSavingMembers(false);
|
||||
}
|
||||
@@ -261,17 +337,17 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
|
||||
|
||||
if (response.success) {
|
||||
alert("메뉴 권한이 성공적으로 저장되었습니다.");
|
||||
alert(t("detail.permissions.save.success"));
|
||||
loadMenuPermissions(); // 새로고침
|
||||
|
||||
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
|
||||
await refreshMenus();
|
||||
} else {
|
||||
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
|
||||
alert(response.message || t("detail.permissions.save.fail"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("메뉴 권한 저장 오류:", err);
|
||||
alert("메뉴 권한 저장 중 오류가 발생했습니다.");
|
||||
alert(t("detail.permissions.save.error"));
|
||||
} finally {
|
||||
setIsSavingPermissions(false);
|
||||
}
|
||||
@@ -282,7 +358,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-12 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||
<p className="text-muted-foreground text-sm">권한 그룹 정보를 불러오는 중...</p>
|
||||
<p className="text-muted-foreground text-sm">{t("detail.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -292,10 +368,10 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
return (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">오류 발생</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p>
|
||||
<h3 className="mb-2 text-lg font-semibold">{t("detail.error.title")}</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">{error || t("detail.error.notfound")}</p>
|
||||
<Button variant="outline" onClick={() => router.push("/admin/userMng/rolesList")}>
|
||||
목록으로 돌아가기
|
||||
{t("detail.button.backToList")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -321,7 +397,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
roleGroup.status === "active" ? "bg-emerald-100 text-emerald-800" : "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{roleGroup.status === "active" ? "활성" : "비활성"}
|
||||
{roleGroup.status === "active" ? t("detail.status.active") : t("detail.status.inactive")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,7 +413,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
}`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
멤버 관리 ({selectedUsers.length})
|
||||
{t("detail.tab.members")} ({selectedUsers.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("permissions")}
|
||||
@@ -348,7 +424,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
}`}
|
||||
>
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
메뉴 권한 ({menuPermissions.length})
|
||||
{t("detail.tab.permissions")} ({menuPermissions.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -358,8 +434,8 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">멤버 관리</h2>
|
||||
<p className="text-muted-foreground text-sm">이 권한 그룹에 속한 사용자를 관리합니다</p>
|
||||
<h2 className="text-xl font-semibold">{t("detail.members.title")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("detail.members.description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 사용자별/부서별 모드 전환 */}
|
||||
@@ -371,7 +447,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="h-3.5 w-3.5" />
|
||||
사용자별
|
||||
{t("detail.members.mode.user")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMemberMode("dept")}
|
||||
@@ -380,12 +456,12 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
}`}
|
||||
>
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
부서별
|
||||
{t("detail.members.mode.dept")}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSavingMembers ? "저장 중..." : "멤버 저장"}
|
||||
{isSavingMembers ? t("detail.members.saving") : t("detail.members.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,8 +471,8 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
availableItems={availableUsers}
|
||||
selectedItems={selectedUsers}
|
||||
onSelectionChange={setSelectedUsers}
|
||||
availableLabel="전체 사용자"
|
||||
selectedLabel="그룹 멤버"
|
||||
availableLabel={t("detail.members.available")}
|
||||
selectedLabel={t("detail.members.selected")}
|
||||
enableSearch
|
||||
/>
|
||||
) : (
|
||||
@@ -405,8 +481,8 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
availableItems={availableDepts}
|
||||
selectedItems={selectedDepts}
|
||||
onSelectionChange={handleDeptSelectionChange}
|
||||
availableLabel="부서 목록 (선택 시 소속 사용자 전체 추가)"
|
||||
selectedLabel="추가된 부서"
|
||||
availableLabel={t("detail.members.dept.available")}
|
||||
selectedLabel={t("detail.members.dept.selected")}
|
||||
enableSearch
|
||||
renderItem={(item) => (
|
||||
<div className="flex flex-col">
|
||||
@@ -418,9 +494,9 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
|
||||
{/* 현재 멤버 요약 */}
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<p className="mb-2 text-sm font-semibold">현재 그룹 멤버 ({selectedUsers.length}명)</p>
|
||||
<p className="mb-2 text-sm font-semibold">{t("detail.members.summary.count", { count: selectedUsers.length })}</p>
|
||||
{selectedUsers.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">멤버가 없습니다</p>
|
||||
<p className="text-muted-foreground text-sm">{t("detail.members.empty")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedUsers.map((user) => (
|
||||
@@ -446,12 +522,12 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">메뉴 권한 설정</h2>
|
||||
<p className="text-muted-foreground text-sm">이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다</p>
|
||||
<h2 className="text-xl font-semibold">{t("detail.permissions.title")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("detail.permissions.description")}</p>
|
||||
</div>
|
||||
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isSavingPermissions ? "저장 중..." : "권한 저장"}
|
||||
{isSavingPermissions ? t("detail.permissions.saving") : t("detail.permissions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,63 @@ import { useTabStore } from "@/stores/tabStore";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { companyAPI } from "@/lib/api/company";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
const LANG_KEYS = [
|
||||
"roles.title",
|
||||
"roles.description",
|
||||
"access.denied",
|
||||
"access.denied.msg",
|
||||
"button.back",
|
||||
"error.occurred",
|
||||
"error.close.aria",
|
||||
"roles.list.title",
|
||||
"filter.company.placeholder",
|
||||
"filter.company.all",
|
||||
"button.create",
|
||||
"loading",
|
||||
"empty.message",
|
||||
"empty.hint",
|
||||
"status.active",
|
||||
"status.inactive",
|
||||
"label.company",
|
||||
"label.memberCount",
|
||||
"label.menuPermissions",
|
||||
"count.members",
|
||||
"count.menus",
|
||||
"button.edit",
|
||||
"button.delete",
|
||||
"error.load.list",
|
||||
"error.load.list.generic",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_TEXTS: Record<string, string> = {
|
||||
"roles.title": "권한 그룹 관리",
|
||||
"roles.description": "회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)",
|
||||
"access.denied": "접근 권한 없음",
|
||||
"access.denied.msg": "권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.",
|
||||
"button.back": "뒤로 가기",
|
||||
"error.occurred": "오류가 발생했습니다",
|
||||
"error.close.aria": "에러 메시지 닫기",
|
||||
"roles.list.title": "권한 그룹 목록",
|
||||
"filter.company.placeholder": "회사 선택",
|
||||
"filter.company.all": "전체 회사",
|
||||
"button.create": "권한 그룹 생성",
|
||||
"loading": "권한 그룹 목록을 불러오는 중...",
|
||||
"empty.message": "등록된 권한 그룹이 없습니다.",
|
||||
"empty.hint": "권한 그룹을 생성하여 멤버를 관리해보세요.",
|
||||
"status.active": "활성",
|
||||
"status.inactive": "비활성",
|
||||
"label.company": "회사",
|
||||
"label.memberCount": "멤버 수",
|
||||
"label.menuPermissions": "메뉴 권한",
|
||||
"count.members": "{count}명",
|
||||
"count.menus": "{count}개",
|
||||
"button.edit": "수정",
|
||||
"button.delete": "삭제",
|
||||
"error.load.list": "권한 그룹 목록을 불러오는데 실패했습니다.",
|
||||
"error.load.list.generic": "권한 그룹 목록을 불러오는 중 오류가 발생했습니다.",
|
||||
};
|
||||
|
||||
/**
|
||||
* 권한 그룹 관리 페이지
|
||||
@@ -31,6 +88,7 @@ export default function RolesPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const router = useRouter();
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
const { t } = usePageMultiLang({ keys: LANG_KEYS, defaults: DEFAULT_TEXTS, menuCode: "roles.management" });
|
||||
|
||||
// 회사 관리자 또는 최고 관리자 여부
|
||||
const isAdmin =
|
||||
@@ -95,11 +153,11 @@ export default function RolesPage() {
|
||||
setRoleGroups(response.data);
|
||||
console.log("권한 그룹 조회 성공:", response.data.length, "개");
|
||||
} else {
|
||||
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
|
||||
setError(response.message || t("error.load.list"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("권한 그룹 목록 로드 오류:", err);
|
||||
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
|
||||
setError(t("error.load.list.generic"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -164,18 +222,18 @@ export default function RolesPage() {
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<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("roles.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("roles.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
||||
<h3 className="mb-2 text-lg font-semibold">{t("access.denied")}</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
|
||||
{t("access.denied.msg")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
뒤로 가기
|
||||
{t("button.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,19 +248,19 @@ export default function RolesPage() {
|
||||
<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("roles.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("roles.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||
<p className="text-destructive text-sm font-semibold">{t("error.occurred")}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||
aria-label="에러 메시지 닫기"
|
||||
aria-label={t("error.close.aria")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -214,7 +272,7 @@ export default function RolesPage() {
|
||||
{/* 액션 버튼 영역 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-semibold">권한 그룹 목록 ({roleGroups.length})</h2>
|
||||
<h2 className="text-xl font-semibold">{t("roles.list.title")} ({roleGroups.length})</h2>
|
||||
|
||||
{/* 최고 관리자 전용: 회사 필터 */}
|
||||
{isSuperAdmin && (
|
||||
@@ -222,10 +280,10 @@ export default function RolesPage() {
|
||||
<Filter className="text-muted-foreground h-4 w-4" />
|
||||
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
|
||||
<SelectTrigger className="h-10 w-[200px]">
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
<SelectValue placeholder={t("filter.company.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 회사</SelectItem>
|
||||
<SelectItem value="all">{t("filter.company.all")}</SelectItem>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.company_code} value={company.company_code}>
|
||||
{company.company_name}
|
||||
@@ -244,7 +302,7 @@ export default function RolesPage() {
|
||||
|
||||
<Button onClick={handleCreateRole} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
권한 그룹 생성
|
||||
{t("button.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -253,14 +311,14 @@ export default function RolesPage() {
|
||||
<div className="bg-card rounded-lg border p-12 shadow-sm">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||
<p className="text-muted-foreground text-sm">권한 그룹 목록을 불러오는 중...</p>
|
||||
<p className="text-muted-foreground text-sm">{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : roleGroups.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-muted-foreground text-sm">등록된 권한 그룹이 없습니다.</p>
|
||||
<p className="text-muted-foreground text-xs">권한 그룹을 생성하여 멤버를 관리해보세요.</p>
|
||||
<p className="text-muted-foreground text-sm">{t("empty.message")}</p>
|
||||
<p className="text-muted-foreground text-xs">{t("empty.hint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -282,7 +340,7 @@ export default function RolesPage() {
|
||||
role.status === "active" ? "bg-emerald-100 text-emerald-800" : "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{role.status === "active" ? "활성" : "비활성"}
|
||||
{role.status === "active" ? t("status.active") : t("status.inactive")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +349,7 @@ export default function RolesPage() {
|
||||
{/* 최고 관리자는 회사명 표시 */}
|
||||
{isSuperAdmin && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">회사</span>
|
||||
<span className="text-muted-foreground">{t("label.company")}</span>
|
||||
<span className="font-medium">
|
||||
{companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
|
||||
</span>
|
||||
@@ -300,16 +358,16 @@ export default function RolesPage() {
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
멤버 수
|
||||
{t("label.memberCount")}
|
||||
</span>
|
||||
<span className="font-medium">{role.memberCount || 0}명</span>
|
||||
<span className="font-medium">{t("count.members", { count: role.memberCount || 0 })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
<Menu className="h-3 w-3" />
|
||||
메뉴 권한
|
||||
{t("label.menuPermissions")}
|
||||
</span>
|
||||
<span className="font-medium">{role.menuCount || 0}개</span>
|
||||
<span className="font-medium">{t("count.menus", { count: role.menuCount || 0 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,7 +384,7 @@ export default function RolesPage() {
|
||||
className="flex-1 gap-1 text-xs"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
수정
|
||||
{t("button.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -338,7 +396,7 @@ export default function RolesPage() {
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
삭제
|
||||
{t("button.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,46 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
// 다국어 키 목록
|
||||
const USER_AUTH_KEYS = [
|
||||
"user.auth.title", "user.auth.description",
|
||||
"access.denied", "access.denied.msg", "button.back",
|
||||
"error.occurred", "error.load.users", "error.load.users.detail",
|
||||
"role.super_admin", "role.company_admin", "role.user", "role.guest", "role.partner", "role.unassigned",
|
||||
"table.userId", "table.userName", "table.company", "table.dept", "table.currentAuth",
|
||||
"table.empty", "table.actions", "action.change.auth",
|
||||
"pagination.prev", "pagination.next",
|
||||
] as const;
|
||||
|
||||
// 한국어 기본 텍스트
|
||||
const USER_AUTH_DEFAULTS: Record<string, string> = {
|
||||
"user.auth.title": "사용자 권한 관리",
|
||||
"user.auth.description": "사용자별 권한 레벨을 관리합니다.",
|
||||
"access.denied": "접근 권한 없음",
|
||||
"access.denied.msg": "권한 관리는 관리자만 접근할 수 있습니다.",
|
||||
"button.back": "뒤로 가기",
|
||||
"error.occurred": "오류가 발생했습니다",
|
||||
"error.load.users": "사용자 목록을 불러오는데 실패했습니다.",
|
||||
"error.load.users.detail": "사용자 목록을 불러오는 중 오류가 발생했습니다.",
|
||||
"role.super_admin": "최고 관리자",
|
||||
"role.company_admin": "회사 관리자",
|
||||
"role.user": "일반 사용자",
|
||||
"role.guest": "게스트",
|
||||
"role.partner": "협력업체",
|
||||
"role.unassigned": "미지정",
|
||||
"table.userId": "사용자 ID",
|
||||
"table.userName": "사용자명",
|
||||
"table.company": "회사",
|
||||
"table.dept": "부서",
|
||||
"table.currentAuth": "현재 권한",
|
||||
"table.empty": "등록된 사용자가 없습니다.",
|
||||
"table.actions": "액션",
|
||||
"action.change.auth": "권한 변경",
|
||||
"pagination.prev": "이전",
|
||||
"pagination.next": "다음",
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자 권한 관리 페이지
|
||||
@@ -18,6 +58,11 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
* 회사관리자는 SUPER_ADMIN 권한 부여 불가
|
||||
*/
|
||||
export default function UserAuthPage() {
|
||||
const { t } = usePageMultiLang({
|
||||
keys: USER_AUTH_KEYS,
|
||||
defaults: USER_AUTH_DEFAULTS,
|
||||
menuCode: "user.auth",
|
||||
});
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
// 관리자 여부
|
||||
@@ -65,11 +110,11 @@ export default function UserAuthPage() {
|
||||
totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
|
||||
});
|
||||
} else {
|
||||
setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
|
||||
setError(response.message || t("error.load.users"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("사용자 목록 로드 오류:", err);
|
||||
setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
|
||||
setError(t("error.load.users.detail"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -114,18 +159,18 @@ export default function UserAuthPage() {
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<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-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("user.auth.title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("user.auth.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
||||
<h3 className="mb-2 text-lg font-semibold">{t("access.denied")}</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
권한 관리는 관리자만 접근할 수 있습니다.
|
||||
{t("access.denied.msg")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
뒤로 가기
|
||||
{t("button.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,15 +185,15 @@ export default function UserAuthPage() {
|
||||
<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-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("user.auth.title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("user.auth.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||
<p className="text-destructive text-sm font-semibold">{t("error.occurred")}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||
@@ -169,6 +214,7 @@ export default function UserAuthPage() {
|
||||
paginationInfo={paginationInfo}
|
||||
onEditAuth={handleEditAuth}
|
||||
onPageChange={handlePageChange}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 권한 변경 모달 */}
|
||||
|
||||
@@ -8,6 +8,90 @@ import { Pagination } from "@/components/common/Pagination";
|
||||
import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal";
|
||||
import { UserFormModal } from "@/components/admin/UserFormModal";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { usePageMultiLang } from "@/hooks/usePageMultiLang";
|
||||
|
||||
// 다국어 키 목록
|
||||
const USER_MNG_KEYS = [
|
||||
"user.mng.title",
|
||||
"user.mng.description",
|
||||
"error.occurred",
|
||||
"table.sabun",
|
||||
"table.company",
|
||||
"table.dept",
|
||||
"table.position",
|
||||
"table.userId",
|
||||
"table.userName",
|
||||
"table.phone",
|
||||
"table.email",
|
||||
"table.regDate",
|
||||
"table.status",
|
||||
"table.dept.short",
|
||||
"table.contact",
|
||||
"table.empty",
|
||||
"table.actions",
|
||||
"action.edit.user",
|
||||
"action.reset.password",
|
||||
"action.view.history",
|
||||
"toolbar.search.placeholder",
|
||||
"toolbar.searching",
|
||||
"toolbar.advanced.search",
|
||||
"toolbar.advanced.search.title",
|
||||
"toolbar.advanced.search.desc",
|
||||
"toolbar.advanced.mode.warning",
|
||||
"toolbar.advanced.reset",
|
||||
"toolbar.total.count",
|
||||
"toolbar.total.unit",
|
||||
"toolbar.create.user",
|
||||
"toolbar.search.company",
|
||||
"toolbar.search.dept",
|
||||
"toolbar.search.position",
|
||||
"toolbar.search.userId",
|
||||
"toolbar.search.userName",
|
||||
"toolbar.search.tel",
|
||||
"toolbar.search.email",
|
||||
] as const;
|
||||
|
||||
// 한국어 기본값
|
||||
const USER_MNG_DEFAULTS: Record<string, string> = {
|
||||
"user.mng.title": "사용자 관리",
|
||||
"user.mng.description": "시스템 사용자 계정 및 권한을 관리합니다",
|
||||
"error.occurred": "오류가 발생했습니다",
|
||||
"table.sabun": "사번",
|
||||
"table.company": "회사",
|
||||
"table.dept": "부서명",
|
||||
"table.position": "직책",
|
||||
"table.userId": "사용자 ID",
|
||||
"table.userName": "사용자명",
|
||||
"table.phone": "전화번호",
|
||||
"table.email": "이메일",
|
||||
"table.regDate": "등록일",
|
||||
"table.status": "상태",
|
||||
"table.dept.short": "부서",
|
||||
"table.contact": "연락처",
|
||||
"table.empty": "등록된 사용자가 없습니다.",
|
||||
"table.actions": "작업",
|
||||
"action.edit.user": "사용자 정보 수정",
|
||||
"action.reset.password": "비밀번호 초기화",
|
||||
"action.view.history": "변경이력 조회",
|
||||
"toolbar.search.placeholder": "통합 검색...",
|
||||
"toolbar.searching": "검색 중...",
|
||||
"toolbar.advanced.search": "고급 검색",
|
||||
"toolbar.advanced.search.title": "고급 검색 옵션",
|
||||
"toolbar.advanced.search.desc": "각 필드별로 개별 검색 조건을 설정할 수 있습니다",
|
||||
"toolbar.advanced.mode.warning":
|
||||
"고급 검색 모드가 활성화되어 있습니다. 통합 검색을 사용하려면 고급 검색 조건을 초기화하세요.",
|
||||
"toolbar.advanced.reset": "고급 검색 조건 초기화",
|
||||
"toolbar.total.count": "총",
|
||||
"toolbar.total.unit": "명",
|
||||
"toolbar.create.user": "사용자 등록",
|
||||
"toolbar.search.company": "회사명 검색",
|
||||
"toolbar.search.dept": "부서명 검색",
|
||||
"toolbar.search.position": "직책 검색",
|
||||
"toolbar.search.userId": "사용자 ID 검색",
|
||||
"toolbar.search.userName": "사용자명 검색",
|
||||
"toolbar.search.tel": "전화번호/휴대폰 검색",
|
||||
"toolbar.search.email": "이메일 검색",
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자관리 페이지
|
||||
@@ -18,6 +102,11 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
* - 실제 데이터베이스와 연동되어 작동
|
||||
*/
|
||||
export default function UserMngPage() {
|
||||
const { t } = usePageMultiLang({
|
||||
keys: USER_MNG_KEYS,
|
||||
defaults: USER_MNG_DEFAULTS,
|
||||
menuCode: "user.management",
|
||||
});
|
||||
const {
|
||||
// 데이터
|
||||
users,
|
||||
@@ -113,8 +202,8 @@ export default function UserMngPage() {
|
||||
{/* 상단 고정: 헤더 + 툴바 */}
|
||||
<div className="shrink-0 space-y-6 p-6 pb-0">
|
||||
<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("user.mng.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("user.mng.description")}</p>
|
||||
</div>
|
||||
|
||||
<UserToolbar
|
||||
@@ -123,12 +212,13 @@ export default function UserMngPage() {
|
||||
isSearching={isSearching}
|
||||
onSearchChange={updateSearchFilter}
|
||||
onCreateClick={handleCreateUser}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||
<p className="text-destructive text-sm font-semibold">{t("error.occurred")}</p>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||
@@ -151,6 +241,7 @@ export default function UserMngPage() {
|
||||
onStatusToggle={handleStatusToggle}
|
||||
onPasswordReset={handlePasswordReset}
|
||||
onEdit={handleEditUser}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CompanyDeleteDialogProps {
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<boolean>;
|
||||
onClearError: () => void;
|
||||
t?: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,21 @@ export function CompanyDeleteDialog({
|
||||
onClose,
|
||||
onConfirm,
|
||||
onClearError,
|
||||
t,
|
||||
}: CompanyDeleteDialogProps) {
|
||||
const _t = t || ((key: string) => {
|
||||
const defaults: Record<string, string> = {
|
||||
"company.delete.title": "회사 삭제 확인",
|
||||
"company.delete.description": "선택한 회사를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"company.delete.companyName": "회사명",
|
||||
"company.delete.companyCode": "회사 코드",
|
||||
"company.delete.writer": "등록자",
|
||||
"company.delete.regdate": "등록일",
|
||||
"company.delete.cancel": "취소",
|
||||
"company.delete.confirm": "삭제",
|
||||
};
|
||||
return defaults[key] || key;
|
||||
});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 다이얼로그가 열려있지 않으면 렌더링하지 않음
|
||||
@@ -71,10 +86,10 @@ export function CompanyDeleteDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
회사 삭제 확인
|
||||
{_t("company.delete.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-left">
|
||||
선택한 회사를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
{_t("company.delete.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -83,19 +98,19 @@ export function CompanyDeleteDialog({
|
||||
<div className="border-destructive/20 bg-destructive/5 rounded-md border p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm font-medium">회사명</span>
|
||||
<span className="text-muted-foreground text-sm font-medium">{_t("company.delete.companyName")}</span>
|
||||
<span className="font-medium">{targetCompany.company_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm font-medium">회사 코드</span>
|
||||
<span className="text-muted-foreground text-sm font-medium">{_t("company.delete.companyCode")}</span>
|
||||
<span className="font-mono text-sm">{targetCompany.company_code}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm font-medium">등록자</span>
|
||||
<span className="text-muted-foreground text-sm font-medium">{_t("company.delete.writer")}</span>
|
||||
<span className="text-sm">{targetCompany.writer}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm font-medium">등록일</span>
|
||||
<span className="text-muted-foreground text-sm font-medium">{_t("company.delete.regdate")}</span>
|
||||
<span className="text-sm">
|
||||
{new Date(targetCompany.regdate).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
@@ -135,7 +150,7 @@ export function CompanyDeleteDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading || isDeleting}>
|
||||
취소
|
||||
{_t("company.delete.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -143,7 +158,7 @@ export function CompanyDeleteDialog({
|
||||
disabled={isLoading || isDeleting}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{isLoading || isDeleting ? <LoadingSpinner className="mr-2 h-4 w-4" /> : "삭제"}
|
||||
{isLoading || isDeleting ? <LoadingSpinner className="mr-2 h-4 w-4" /> : _t("company.delete.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface CompanyFormModalProps {
|
||||
onSave: () => Promise<boolean>;
|
||||
onFormChange: (field: keyof CompanyFormData, value: string) => void;
|
||||
onClearError: () => void;
|
||||
t?: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +36,32 @@ export function CompanyFormModal({
|
||||
onSave,
|
||||
onFormChange,
|
||||
onClearError,
|
||||
t,
|
||||
}: CompanyFormModalProps) {
|
||||
const _t = t || ((key: string) => {
|
||||
const defaults: Record<string, string> = {
|
||||
"company.form.titleCreate": "새 회사 등록",
|
||||
"company.form.titleEdit": "회사 정보 수정",
|
||||
"company.form.companyName": "회사명",
|
||||
"company.form.companyNamePlaceholder": "회사명을 입력하세요",
|
||||
"company.form.businessNumber": "사업자등록번호",
|
||||
"company.form.businessNumberHint": "10자리 숫자 (자동 하이픈 추가)",
|
||||
"company.form.representativeName": "대표자명",
|
||||
"company.form.representativeNamePlaceholder": "대표자명을 입력하세요",
|
||||
"company.form.representativePhone": "대표 연락처",
|
||||
"company.form.email": "이메일",
|
||||
"company.form.website": "웹사이트",
|
||||
"company.form.address": "회사 주소",
|
||||
"company.form.addressPlaceholder": "서울특별시 강남구...",
|
||||
"company.form.companyCodeLabel": "회사 코드:",
|
||||
"company.form.writerLabel": "등록자:",
|
||||
"company.form.regdateLabel": "등록일:",
|
||||
"company.form.cancel": "취소",
|
||||
"company.form.save": "등록",
|
||||
"company.form.update": "수정",
|
||||
};
|
||||
return defaults[key] || key;
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [businessNumberError, setBusinessNumberError] = useState<string>("");
|
||||
|
||||
@@ -125,20 +151,20 @@ export function CompanyFormModal({
|
||||
userId={modalState.companyCode}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "회사 정보 수정" : "새 회사 등록"}</DialogTitle>
|
||||
<DialogTitle>{isEditMode ? _t("company.form.titleEdit") : _t("company.form.titleCreate")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 회사명 입력 (필수) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
{_t("company.form.companyName")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
value={formData.company_name}
|
||||
onChange={(e) => onFormChange("company_name", e.target.value)}
|
||||
placeholder="회사명을 입력하세요"
|
||||
placeholder={_t("company.form.companyNamePlaceholder")}
|
||||
disabled={isLoading || isSaving}
|
||||
className={error ? "border-destructive" : ""}
|
||||
autoFocus
|
||||
@@ -148,7 +174,7 @@ export function CompanyFormModal({
|
||||
{/* 사업자등록번호 입력 (필수) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business_registration_number">
|
||||
사업자등록번호 <span className="text-destructive">*</span>
|
||||
{_t("company.form.businessNumber")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="business_registration_number"
|
||||
@@ -162,25 +188,25 @@ export function CompanyFormModal({
|
||||
{businessNumberError ? (
|
||||
<p className="text-xs text-destructive">{businessNumberError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">10자리 숫자 (자동 하이픈 추가)</p>
|
||||
<p className="text-xs text-muted-foreground">{_t("company.form.businessNumberHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 대표자명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="representative_name">대표자명</Label>
|
||||
<Label htmlFor="representative_name">{_t("company.form.representativeName")}</Label>
|
||||
<Input
|
||||
id="representative_name"
|
||||
value={formData.representative_name || ""}
|
||||
onChange={(e) => onFormChange("representative_name", e.target.value)}
|
||||
placeholder="대표자명을 입력하세요"
|
||||
placeholder={_t("company.form.representativeNamePlaceholder")}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 대표 연락처 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="representative_phone">대표 연락처</Label>
|
||||
<Label htmlFor="representative_phone">{_t("company.form.representativePhone")}</Label>
|
||||
<Input
|
||||
id="representative_phone"
|
||||
value={formData.representative_phone || ""}
|
||||
@@ -193,7 +219,7 @@ export function CompanyFormModal({
|
||||
|
||||
{/* 이메일 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Label htmlFor="email">{_t("company.form.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={formData.email || ""}
|
||||
@@ -206,7 +232,7 @@ export function CompanyFormModal({
|
||||
|
||||
{/* 웹사이트 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">웹사이트</Label>
|
||||
<Label htmlFor="website">{_t("company.form.website")}</Label>
|
||||
<Input
|
||||
id="website"
|
||||
value={formData.website || ""}
|
||||
@@ -219,12 +245,12 @@ export function CompanyFormModal({
|
||||
|
||||
{/* 회사 주소 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">회사 주소</Label>
|
||||
<Label htmlFor="address">{_t("company.form.address")}</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address || ""}
|
||||
onChange={(e) => onFormChange("address", e.target.value)}
|
||||
placeholder="서울특별시 강남구..."
|
||||
placeholder={_t("company.form.addressPlaceholder")}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
</div>
|
||||
@@ -241,13 +267,13 @@ export function CompanyFormModal({
|
||||
<div className="bg-muted/50 rounded-md p-3">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">회사 코드:</span> {modalState.selectedCompany.company_code}
|
||||
<span className="font-medium">{_t("company.form.companyCodeLabel")}</span> {modalState.selectedCompany.company_code}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">등록자:</span> {modalState.selectedCompany.writer}
|
||||
<span className="font-medium">{_t("company.form.writerLabel")}</span> {modalState.selectedCompany.writer}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">등록일:</span>{" "}
|
||||
<span className="font-medium">{_t("company.form.regdateLabel")}</span>{" "}
|
||||
{new Date(modalState.selectedCompany.regdate).toLocaleDateString("ko-KR")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -257,7 +283,7 @@ export function CompanyFormModal({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading || isSaving}>
|
||||
취소
|
||||
{_t("company.form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
@@ -271,7 +297,7 @@ export function CompanyFormModal({
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
||||
{isEditMode ? "수정" : "등록"}
|
||||
{isEditMode ? _t("company.form.update") : _t("company.form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,12 +9,37 @@ interface CompanyTableProps {
|
||||
isLoading: boolean;
|
||||
onEdit: (company: Company) => void;
|
||||
onDelete: (company: Company) => void;
|
||||
t?: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 목록 테이블 컴포넌트
|
||||
*/
|
||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete, t }: CompanyTableProps) {
|
||||
const _t = t || ((key: string, params?: Record<string, string | number>) => {
|
||||
const defaults: Record<string, string> = {
|
||||
"company.table.companyCode": "회사코드",
|
||||
"company.table.companyName": "회사명",
|
||||
"company.table.writer": "등록자",
|
||||
"company.table.diskUsage": "디스크 사용량",
|
||||
"company.table.diskNoInfo": "정보 없음",
|
||||
"company.table.fileCount": "{count}개 파일",
|
||||
"company.table.actions": "작업",
|
||||
"company.table.empty": "등록된 회사가 없습니다.",
|
||||
"company.table.cardWriter": "작성자",
|
||||
"company.table.cardDiskUsage": "디스크 사용량",
|
||||
"company.table.actionDept": "부서관리",
|
||||
"company.table.actionEdit": "수정",
|
||||
"company.table.actionDelete": "삭제",
|
||||
};
|
||||
let text = defaults[key] || key;
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
text = text.replace(`{${k}}`, String(v));
|
||||
});
|
||||
}
|
||||
return text;
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
// 부서 관리 페이지로 이동
|
||||
@@ -28,7 +53,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span className="text-xs">정보 없음</span>
|
||||
<span className="text-xs">{_t("company.table.diskNoInfo")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +64,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="text-primary h-3 w-3" />
|
||||
<span className="text-xs font-medium">{fileCount}개 파일</span>
|
||||
<span className="text-xs font-medium">{_t("company.table.fileCount", { count: fileCount })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="text-primary h-3 w-3" />
|
||||
@@ -53,23 +78,23 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
const columns: RDVColumn<Company>[] = [
|
||||
{
|
||||
key: "company_code",
|
||||
label: "회사코드",
|
||||
label: _t("company.table.companyCode"),
|
||||
width: "12%",
|
||||
render: (value) => <span className="font-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
key: "company_name",
|
||||
label: "회사명",
|
||||
label: _t("company.table.companyName"),
|
||||
render: (value) => <span className="font-medium">{value}</span>,
|
||||
},
|
||||
{
|
||||
key: "writer",
|
||||
label: "등록자",
|
||||
label: _t("company.table.writer"),
|
||||
width: "15%",
|
||||
},
|
||||
{
|
||||
key: "diskUsage",
|
||||
label: "디스크 사용량",
|
||||
label: _t("company.table.diskUsage"),
|
||||
width: "15%",
|
||||
hideOnMobile: true,
|
||||
render: (_value, row) => formatDiskUsage(row),
|
||||
@@ -79,11 +104,11 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
// 모바일 카드 필드 정의
|
||||
const cardFields: RDVCardField<Company>[] = [
|
||||
{
|
||||
label: "작성자",
|
||||
label: _t("company.table.cardWriter"),
|
||||
render: (company) => <span className="font-medium">{company.writer}</span>,
|
||||
},
|
||||
{
|
||||
label: "디스크 사용량",
|
||||
label: _t("company.table.cardDiskUsage"),
|
||||
render: (company) => formatDiskUsage(company),
|
||||
},
|
||||
];
|
||||
@@ -94,12 +119,12 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
columns={columns}
|
||||
keyExtractor={(c) => c.regdate + c.company_code}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="등록된 회사가 없습니다."
|
||||
emptyMessage={_t("company.table.empty")}
|
||||
skeletonCount={10}
|
||||
cardTitle={(c) => c.company_name}
|
||||
cardSubtitle={(c) => <span className="font-mono">{c.company_code}</span>}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="작업"
|
||||
actionsLabel={_t("company.table.actions")}
|
||||
actionsWidth="12%"
|
||||
tableContainerClassName="!block"
|
||||
cardContainerClassName="!hidden"
|
||||
@@ -110,7 +135,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
size="icon"
|
||||
onClick={() => handleManageDepartments(company)}
|
||||
className="h-8 w-8"
|
||||
aria-label="부서관리"
|
||||
aria-label={_t("company.table.actionDept")}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -119,7 +144,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
size="icon"
|
||||
onClick={() => onEdit(company)}
|
||||
className="h-8 w-8"
|
||||
aria-label="수정"
|
||||
aria-label={_t("company.table.actionEdit")}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -128,7 +153,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
size="icon"
|
||||
onClick={() => onDelete(company)}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||
aria-label="삭제"
|
||||
aria-label={_t("company.table.actionDelete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -9,24 +9,33 @@ interface CompanyToolbarProps {
|
||||
onSearchChange: (filter: Partial<CompanySearchFilter>) => void;
|
||||
onSearchClear: () => void;
|
||||
onCreateClick: () => void;
|
||||
t?: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 관리 툴바 컴포넌트
|
||||
* 검색, 필터링, 등록 기능 제공
|
||||
*/
|
||||
export function CompanyToolbar({ totalCount, onCreateClick }: CompanyToolbarProps) {
|
||||
export function CompanyToolbar({ totalCount, onCreateClick, t }: CompanyToolbarProps) {
|
||||
const _t = t || ((key: string) => {
|
||||
const defaults: Record<string, string> = {
|
||||
"company.toolbar.total": "총",
|
||||
"company.toolbar.create": "회사 등록",
|
||||
};
|
||||
return defaults[key] || key;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* 왼쪽: 카운트 정보 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{totalCount.toLocaleString()}</span> 건
|
||||
{_t("company.toolbar.total")} <span className="font-semibold text-foreground">{totalCount.toLocaleString()}</span> 건
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 등록 버튼 */}
|
||||
<Button onClick={onCreateClick} className="h-10 w-full gap-2 text-sm font-medium lg:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
회사 등록
|
||||
{_t("company.toolbar.create")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,26 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Shield, ShieldCheck, User as UserIcon, Users, Building2 } from "lucide-react";
|
||||
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||
|
||||
// 컴포넌트 내부 기본 텍스트 (t prop이 없을 때 사용)
|
||||
const USER_AUTH_TABLE_DEFAULTS: Record<string, string> = {
|
||||
"role.super_admin": "최고 관리자",
|
||||
"role.company_admin": "회사 관리자",
|
||||
"role.user": "일반 사용자",
|
||||
"role.guest": "게스트",
|
||||
"role.partner": "협력업체",
|
||||
"role.unassigned": "미지정",
|
||||
"table.userId": "사용자 ID",
|
||||
"table.userName": "사용자명",
|
||||
"table.company": "회사",
|
||||
"table.dept": "부서",
|
||||
"table.currentAuth": "현재 권한",
|
||||
"table.empty": "등록된 사용자가 없습니다.",
|
||||
"table.actions": "액션",
|
||||
"action.change.auth": "권한 변경",
|
||||
"pagination.prev": "이전",
|
||||
"pagination.next": "다음",
|
||||
};
|
||||
|
||||
interface UserAuthTableProps {
|
||||
users: any[];
|
||||
isLoading: boolean;
|
||||
@@ -18,6 +38,7 @@ interface UserAuthTableProps {
|
||||
};
|
||||
onEditAuth: (user: any) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
t?: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,43 +46,46 @@ interface UserAuthTableProps {
|
||||
*
|
||||
* 사용자 목록과 권한 정보를 표시하고 권한 변경 기능 제공
|
||||
*/
|
||||
export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo, onEditAuth, onPageChange }: UserAuthTableProps) {
|
||||
export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo, onEditAuth, onPageChange, t }: UserAuthTableProps) {
|
||||
// 다국어 래퍼 (t prop이 없으면 기본 텍스트 사용)
|
||||
const _t = t || ((key: string) => USER_AUTH_TABLE_DEFAULTS[key] || key);
|
||||
|
||||
// 권한 레벨 표시
|
||||
const getUserTypeInfo = (userType: string) => {
|
||||
switch (userType) {
|
||||
case "SUPER_ADMIN":
|
||||
return {
|
||||
label: "최고 관리자",
|
||||
label: _t("role.super_admin"),
|
||||
icon: <ShieldCheck className="h-3 w-3" />,
|
||||
className: "bg-primary/20 text-primary border-primary/30",
|
||||
};
|
||||
case "COMPANY_ADMIN":
|
||||
return {
|
||||
label: "회사 관리자",
|
||||
label: _t("role.company_admin"),
|
||||
icon: <Building2 className="h-3 w-3" />,
|
||||
className: "bg-primary/20 text-primary border-primary/30",
|
||||
};
|
||||
case "USER":
|
||||
return {
|
||||
label: "일반 사용자",
|
||||
label: _t("role.user"),
|
||||
icon: <UserIcon className="h-3 w-3" />,
|
||||
className: "bg-muted/50 text-muted-foreground border-border",
|
||||
};
|
||||
case "GUEST":
|
||||
return {
|
||||
label: "게스트",
|
||||
label: _t("role.guest"),
|
||||
icon: <Users className="h-3 w-3" />,
|
||||
className: "bg-success/20 text-success border-success/30",
|
||||
};
|
||||
case "PARTNER":
|
||||
return {
|
||||
label: "협력업체",
|
||||
label: _t("role.partner"),
|
||||
icon: <Shield className="h-3 w-3" />,
|
||||
className: "bg-warning/20 text-warning border-warning/30",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: userType || "미지정",
|
||||
label: userType || _t("role.unassigned"),
|
||||
icon: <UserIcon className="h-3 w-3" />,
|
||||
className: "bg-muted/50 text-muted-foreground border-border",
|
||||
};
|
||||
@@ -84,18 +108,18 @@ export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo,
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "사용자 ID",
|
||||
label: _t("table.userId"),
|
||||
render: (value) => <span className="font-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
key: "userName",
|
||||
label: "사용자명",
|
||||
label: _t("table.userName"),
|
||||
},
|
||||
...(isSuperAdmin
|
||||
? [
|
||||
{
|
||||
key: "companyName",
|
||||
label: "회사",
|
||||
label: _t("table.company"),
|
||||
hideOnMobile: true,
|
||||
render: (_value: any, row: any) => <span>{row.companyName || row.companyCode}</span>,
|
||||
} as RDVColumn<any>,
|
||||
@@ -103,13 +127,13 @@ export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo,
|
||||
: []),
|
||||
{
|
||||
key: "deptName",
|
||||
label: "부서",
|
||||
label: _t("table.dept"),
|
||||
hideOnMobile: true,
|
||||
render: (value) => <span>{value || "-"}</span>,
|
||||
},
|
||||
{
|
||||
key: "userType",
|
||||
label: "현재 권한",
|
||||
label: _t("table.currentAuth"),
|
||||
className: "text-center",
|
||||
render: (_value, row) => {
|
||||
const typeInfo = getUserTypeInfo(row.userType);
|
||||
@@ -128,13 +152,13 @@ export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo,
|
||||
...(isSuperAdmin
|
||||
? [
|
||||
{
|
||||
label: "회사",
|
||||
label: _t("table.company"),
|
||||
render: (user: any) => <span>{user.companyName || user.companyCode}</span>,
|
||||
} as RDVCardField<any>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "부서",
|
||||
label: _t("table.dept"),
|
||||
render: (user) => <span>{user.deptName || "-"}</span>,
|
||||
},
|
||||
];
|
||||
@@ -146,7 +170,7 @@ export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo,
|
||||
columns={columns}
|
||||
keyExtractor={(u) => u.userId}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="등록된 사용자가 없습니다."
|
||||
emptyMessage={_t("table.empty")}
|
||||
skeletonCount={10}
|
||||
cardTitle={(u) => u.userName}
|
||||
cardSubtitle={(u) => <span className="font-mono">{u.userId}</span>}
|
||||
@@ -160,12 +184,12 @@ export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo,
|
||||
);
|
||||
}}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="액션"
|
||||
actionsLabel={_t("table.actions")}
|
||||
actionsWidth="120px"
|
||||
renderActions={(user) => (
|
||||
<Button variant="outline" size="sm" onClick={() => onEditAuth(user)} className="h-8 gap-1 text-sm">
|
||||
<Shield className="h-3 w-3" />
|
||||
권한 변경
|
||||
{_t("action.change.auth")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
@@ -179,7 +203,7 @@ export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo,
|
||||
onClick={() => onPageChange(paginationInfo.currentPage - 1)}
|
||||
disabled={paginationInfo.currentPage === 1}
|
||||
>
|
||||
이전
|
||||
{_t("pagination.prev")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{paginationInfo.currentPage} / {paginationInfo.totalPages}
|
||||
@@ -190,7 +214,7 @@ export function UserAuthTable({ users, isLoading, isSuperAdmin, paginationInfo,
|
||||
onClick={() => onPageChange(paginationInfo.currentPage + 1)}
|
||||
disabled={paginationInfo.currentPage === paginationInfo.totalPages}
|
||||
>
|
||||
다음
|
||||
{_t("pagination.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface UserTableProps {
|
||||
onStatusToggle: (user: User, newStatus: string) => void;
|
||||
onPasswordReset: (userId: string, userName: string) => void;
|
||||
onEdit: (user: User) => void;
|
||||
t?: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,31 @@ export function UserTable({
|
||||
onStatusToggle,
|
||||
onPasswordReset,
|
||||
onEdit,
|
||||
t: tProp,
|
||||
}: UserTableProps) {
|
||||
// 다국어 함수 (prop이 없으면 한국어 기본값 사용)
|
||||
const _t = tProp || ((key: string) => {
|
||||
const defaults: Record<string, string> = {
|
||||
"table.sabun": "사번",
|
||||
"table.company": "회사",
|
||||
"table.dept": "부서명",
|
||||
"table.position": "직책",
|
||||
"table.userId": "사용자 ID",
|
||||
"table.userName": "사용자명",
|
||||
"table.phone": "전화번호",
|
||||
"table.email": "이메일",
|
||||
"table.regDate": "등록일",
|
||||
"table.status": "상태",
|
||||
"table.dept.short": "부서",
|
||||
"table.contact": "연락처",
|
||||
"table.empty": "등록된 사용자가 없습니다.",
|
||||
"table.actions": "작업",
|
||||
"action.edit.user": "사용자 정보 수정",
|
||||
"action.reset.password": "비밀번호 초기화",
|
||||
"action.view.history": "변경이력 조회",
|
||||
};
|
||||
return defaults[key] || key;
|
||||
});
|
||||
const { user: currentUser } = useAuth();
|
||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||
|
||||
@@ -118,7 +143,7 @@ export function UserTable({
|
||||
},
|
||||
{
|
||||
key: "sabun",
|
||||
label: "사번",
|
||||
label: _t("table.sabun"),
|
||||
width: "80px",
|
||||
hideOnMobile: true,
|
||||
render: (value) => <span className="font-mono">{value || "-"}</span>,
|
||||
@@ -127,7 +152,7 @@ export function UserTable({
|
||||
? [
|
||||
{
|
||||
key: "companyCode" as keyof User,
|
||||
label: "회사",
|
||||
label: _t("table.company"),
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (value: any, user: User) => (
|
||||
@@ -138,42 +163,42 @@ export function UserTable({
|
||||
: []),
|
||||
{
|
||||
key: "deptName",
|
||||
label: "부서명",
|
||||
label: _t("table.dept"),
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (value) => <span className="font-medium">{value || "-"}</span>,
|
||||
},
|
||||
{
|
||||
key: "positionName",
|
||||
label: "직책",
|
||||
label: _t("table.position"),
|
||||
width: "100px",
|
||||
hideOnMobile: true,
|
||||
render: (value) => <span className="font-medium">{value || "-"}</span>,
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "사용자 ID",
|
||||
label: _t("table.userId"),
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (value) => <span className="font-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
key: "userName",
|
||||
label: "사용자명",
|
||||
label: _t("table.userName"),
|
||||
width: "100px",
|
||||
hideOnMobile: true,
|
||||
render: (value) => <span className="font-medium">{value}</span>,
|
||||
},
|
||||
{
|
||||
key: "tel",
|
||||
label: "전화번호",
|
||||
label: _t("table.phone"),
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (_value, row) => <span>{row.tel || row.cellPhone || "-"}</span>,
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "이메일",
|
||||
label: _t("table.email"),
|
||||
width: "200px",
|
||||
hideOnMobile: true,
|
||||
className: "max-w-[200px] truncate",
|
||||
@@ -183,14 +208,14 @@ export function UserTable({
|
||||
},
|
||||
{
|
||||
key: "regDate",
|
||||
label: "등록일",
|
||||
label: _t("table.regDate"),
|
||||
width: "100px",
|
||||
hideOnMobile: true,
|
||||
render: (value) => <span>{formatDate(value || "")}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "상태",
|
||||
label: _t("table.status"),
|
||||
width: "120px",
|
||||
hideOnMobile: true,
|
||||
render: (_value, row) => (
|
||||
@@ -208,14 +233,14 @@ export function UserTable({
|
||||
// 모바일 카드 필드 정의
|
||||
const cardFields: RDVCardField<User>[] = [
|
||||
{
|
||||
label: "사번",
|
||||
label: _t("table.sabun"),
|
||||
render: (user) => <span className="font-mono font-medium">{user.sabun || "-"}</span>,
|
||||
hideEmpty: true,
|
||||
},
|
||||
...(isSuperAdmin
|
||||
? [
|
||||
{
|
||||
label: "회사",
|
||||
label: _t("table.company"),
|
||||
render: (user: User) => (
|
||||
<span className="font-medium">{(user as any).companyName || user.companyCode || ""}</span>
|
||||
),
|
||||
@@ -224,27 +249,27 @@ export function UserTable({
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "부서",
|
||||
label: _t("table.dept.short"),
|
||||
render: (user) => <span className="font-medium">{user.deptName || ""}</span>,
|
||||
hideEmpty: true,
|
||||
},
|
||||
{
|
||||
label: "직책",
|
||||
label: _t("table.position"),
|
||||
render: (user) => <span className="font-medium">{user.positionName || ""}</span>,
|
||||
hideEmpty: true,
|
||||
},
|
||||
{
|
||||
label: "연락처",
|
||||
label: _t("table.contact"),
|
||||
render: (user) => <span>{user.tel || user.cellPhone || ""}</span>,
|
||||
hideEmpty: true,
|
||||
},
|
||||
{
|
||||
label: "이메일",
|
||||
label: _t("table.email"),
|
||||
render: (user) => <span className="break-all">{user.email || ""}</span>,
|
||||
hideEmpty: true,
|
||||
},
|
||||
{
|
||||
label: "등록일",
|
||||
label: _t("table.regDate"),
|
||||
render: (user) => <span>{formatDate(user.regDate || "")}</span>,
|
||||
},
|
||||
];
|
||||
@@ -256,7 +281,7 @@ export function UserTable({
|
||||
columns={columns}
|
||||
keyExtractor={(u) => u.userId}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="등록된 사용자가 없습니다."
|
||||
emptyMessage={_t("table.empty")}
|
||||
skeletonCount={10}
|
||||
cardTitle={(u) => u.userName || ""}
|
||||
cardSubtitle={(u) => <span className="font-mono">{u.userId}</span>}
|
||||
@@ -268,7 +293,7 @@ export function UserTable({
|
||||
/>
|
||||
)}
|
||||
cardFields={cardFields}
|
||||
actionsLabel="작업"
|
||||
actionsLabel={_t("table.actions")}
|
||||
actionsWidth="200px"
|
||||
renderActions={(user) => (
|
||||
<>
|
||||
@@ -277,7 +302,7 @@ export function UserTable({
|
||||
size="icon"
|
||||
onClick={() => onEdit(user)}
|
||||
className="h-8 w-8"
|
||||
title="사용자 정보 수정"
|
||||
title={_t("action.edit.user")}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -286,7 +311,7 @@ export function UserTable({
|
||||
size="icon"
|
||||
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
|
||||
className="h-8 w-8"
|
||||
title="비밀번호 초기화"
|
||||
title={_t("action.reset.password")}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -295,7 +320,7 @@ export function UserTable({
|
||||
size="icon"
|
||||
onClick={() => handleOpenHistoryModal(user)}
|
||||
className="h-8 w-8"
|
||||
title="변경이력 조회"
|
||||
title={_t("action.view.history")}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface UserToolbarProps {
|
||||
isSearching?: boolean;
|
||||
onSearchChange: (searchFilter: Partial<UserSearchFilter>) => void;
|
||||
onCreateClick: () => void;
|
||||
t?: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,7 +23,32 @@ export function UserToolbar({
|
||||
isSearching = false,
|
||||
onSearchChange,
|
||||
onCreateClick,
|
||||
t: tProp,
|
||||
}: UserToolbarProps) {
|
||||
// 다국어 함수 (prop이 없으면 한국어 기본값 사용)
|
||||
const _t = tProp || ((key: string) => {
|
||||
const defaults: Record<string, string> = {
|
||||
"toolbar.search.placeholder": "통합 검색...",
|
||||
"toolbar.searching": "검색 중...",
|
||||
"toolbar.advanced.search": "고급 검색",
|
||||
"toolbar.advanced.search.title": "고급 검색 옵션",
|
||||
"toolbar.advanced.search.desc": "각 필드별로 개별 검색 조건을 설정할 수 있습니다",
|
||||
"toolbar.advanced.mode.warning":
|
||||
"고급 검색 모드가 활성화되어 있습니다. 통합 검색을 사용하려면 고급 검색 조건을 초기화하세요.",
|
||||
"toolbar.advanced.reset": "고급 검색 조건 초기화",
|
||||
"toolbar.total.count": "총",
|
||||
"toolbar.total.unit": "명",
|
||||
"toolbar.create.user": "사용자 등록",
|
||||
"toolbar.search.company": "회사명 검색",
|
||||
"toolbar.search.dept": "부서명 검색",
|
||||
"toolbar.search.position": "직책 검색",
|
||||
"toolbar.search.userId": "사용자 ID 검색",
|
||||
"toolbar.search.userName": "사용자명 검색",
|
||||
"toolbar.search.tel": "전화번호/휴대폰 검색",
|
||||
"toolbar.search.email": "이메일 검색",
|
||||
};
|
||||
return defaults[key] || key;
|
||||
});
|
||||
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false);
|
||||
|
||||
// 통합 검색어 변경
|
||||
@@ -77,7 +103,7 @@ export function UserToolbar({
|
||||
}`}
|
||||
/>
|
||||
<Input
|
||||
placeholder="통합 검색..."
|
||||
placeholder={_t("toolbar.search.placeholder")}
|
||||
value={searchFilter.searchValue || ""}
|
||||
onChange={(e) => handleV2SearchChange(e.target.value)}
|
||||
disabled={isAdvancedSearchMode}
|
||||
@@ -87,10 +113,10 @@ export function UserToolbar({
|
||||
} ${isAdvancedSearchMode ? "cursor-not-allowed bg-muted text-muted-foreground" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
{isSearching && <p className="mt-1.5 text-xs text-primary">검색 중...</p>}
|
||||
{isSearching && <p className="mt-1.5 text-xs text-primary">{_t("toolbar.searching")}</p>}
|
||||
{isAdvancedSearchMode && (
|
||||
<p className="mt-1.5 text-xs text-warning">
|
||||
고급 검색 모드가 활성화되어 있습니다. 통합 검색을 사용하려면 고급 검색 조건을 초기화하세요.
|
||||
{_t("toolbar.advanced.mode.warning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,7 +128,7 @@ export function UserToolbar({
|
||||
onClick={() => setShowAdvancedSearch(!showAdvancedSearch)}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
고급 검색
|
||||
{_t("toolbar.advanced.search")}
|
||||
{showAdvancedSearch ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -111,13 +137,13 @@ export function UserToolbar({
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 조회 결과 정보 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{totalCount.toLocaleString()}</span> 명
|
||||
{_t("toolbar.total.count")} <span className="font-semibold text-foreground">{totalCount.toLocaleString()}</span> {_t("toolbar.total.unit")}
|
||||
</div>
|
||||
|
||||
{/* 사용자 등록 버튼 */}
|
||||
<Button onClick={onCreateClick} size="default" className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
사용자 등록
|
||||
{_t("toolbar.create.user")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,56 +152,56 @@ export function UserToolbar({
|
||||
{showAdvancedSearch && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">고급 검색 옵션</h4>
|
||||
<p className="text-xs text-muted-foreground">각 필드별로 개별 검색 조건을 설정할 수 있습니다</p>
|
||||
<h4 className="text-sm font-semibold">{_t("toolbar.advanced.search.title")}</h4>
|
||||
<p className="text-xs text-muted-foreground">{_t("toolbar.advanced.search.desc")}</p>
|
||||
</div>
|
||||
|
||||
{/* 고급 검색 필드들 */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
placeholder="회사명 검색"
|
||||
placeholder={_t("toolbar.search.company")}
|
||||
value={searchFilter.search_companyName || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_companyName", e.target.value)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="부서명 검색"
|
||||
placeholder={_t("toolbar.search.dept")}
|
||||
value={searchFilter.search_deptName || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_deptName", e.target.value)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="직책 검색"
|
||||
placeholder={_t("toolbar.search.position")}
|
||||
value={searchFilter.search_positionName || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_positionName", e.target.value)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="사용자 ID 검색"
|
||||
placeholder={_t("toolbar.search.userId")}
|
||||
value={searchFilter.search_userId || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_userId", e.target.value)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="사용자명 검색"
|
||||
placeholder={_t("toolbar.search.userName")}
|
||||
value={searchFilter.search_userName || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_userName", e.target.value)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="전화번호/휴대폰 검색"
|
||||
placeholder={_t("toolbar.search.tel")}
|
||||
value={searchFilter.search_tel || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_tel", e.target.value)}
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="이메일 검색"
|
||||
placeholder={_t("toolbar.search.email")}
|
||||
value={searchFilter.search_email || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_email", e.target.value)}
|
||||
className="h-10 text-sm"
|
||||
@@ -202,7 +228,7 @@ export function UserToolbar({
|
||||
}
|
||||
className="h-9 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
고급 검색 조건 초기화
|
||||
{_t("toolbar.advanced.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,8 +37,8 @@ function CategoryNode({
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
? "border-l-[3px] border-l-blue-500 bg-blue-50 font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
||||
: "border-l-[3px] border-l-transparent hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
onClick={() => onSelectCategory(category)}
|
||||
@@ -81,7 +81,7 @@ function CategoryNode({
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs",
|
||||
isSelected ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
isSelected ? "text-blue-500 dark:text-blue-400" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{category.keyPrefix}
|
||||
@@ -171,8 +171,8 @@ export function CategoryTree({
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
selectedCategoryId === null
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
? "border-l-[3px] border-l-blue-500 bg-blue-50 font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
||||
: "border-l-[3px] border-l-transparent hover:bg-muted"
|
||||
)}
|
||||
onClick={() => onSelectCategory(null)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
import { setTranslationCache } from "@/lib/utils/multilang";
|
||||
|
||||
interface UsePageMultiLangOptions {
|
||||
keys: readonly string[];
|
||||
defaults: Record<string, string>;
|
||||
menuCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지별 다국어 텍스트 관리 훅
|
||||
* - keys: 다국어 키 배열
|
||||
* - defaults: 한국어 기본 텍스트 매핑
|
||||
* - menuCode: 배치 API에 전달할 메뉴 코드
|
||||
*/
|
||||
export function usePageMultiLang({ keys, defaults, menuCode }: UsePageMultiLangOptions) {
|
||||
const { userLang } = useMultiLang();
|
||||
const [uiTexts, setUiTexts] = useState<Record<string, string>>(() => ({ ...defaults }));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 배치 번역 로드
|
||||
useEffect(() => {
|
||||
if (!userLang || loading) return;
|
||||
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
"/multilang/batch",
|
||||
{
|
||||
langKeys: keys,
|
||||
companyCode: "*",
|
||||
menuCode,
|
||||
userLang,
|
||||
},
|
||||
{ params: {} },
|
||||
);
|
||||
|
||||
if (!cancelled && response.data.success && response.data.data) {
|
||||
const merged = { ...defaults, ...response.data.data };
|
||||
setUiTexts(merged);
|
||||
setTranslationCache(userLang, merged);
|
||||
}
|
||||
} catch {
|
||||
// API 실패 시 기본 텍스트 유지
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [userLang]);
|
||||
|
||||
// 동기 텍스트 조회 함수
|
||||
const t = useCallback(
|
||||
(key: string, params?: Record<string, string | number>): string => {
|
||||
let text = uiTexts[key] || defaults[key] || key;
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
text = text.replace(`{${k}}`, String(v));
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
[uiTexts, defaults],
|
||||
);
|
||||
|
||||
return { t, userLang, loading };
|
||||
}
|
||||
Reference in New Issue
Block a user