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:
kjs
2026-04-01 15:57:12 +09:00
parent 2ff01456dc
commit 1d49fc7ac7
15 changed files with 812 additions and 200 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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)}
>