1
0
forked from baron/baron-sso

프론트엔드 UI/UX를 전면 개편

This commit is contained in:
2026-02-20 17:56:53 +09:00
parent 2ec2653bfb
commit 919bcd27e8
18 changed files with 1092 additions and 736 deletions

View File

@@ -1,7 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import type { AxiosError } from "axios";
import { Plus, Search, ShieldCheck, Trash2, UserPlus, Users } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -10,6 +13,14 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
@@ -25,40 +36,50 @@ import {
fetchUsers,
removeTenantAdmin,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantAdminsTab() {
export function TenantAdminsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsAddDialogOpen] = useState(false);
if (!tenantId) return null;
// 현재 관리자 목록
// 현재 관리자 목록 조회
const adminsQuery = useQuery({
queryKey: ["tenant-admins", tenantId],
queryFn: () => fetchTenantAdmins(tenantId),
enabled: !!tenantId,
});
// 전체 사용자 목록 (관리자 추가용)
// 사용자 검색 조회 (2자 이상 입력 시)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }],
queryFn: () => fetchUsers(100, 0, searchTerm),
enabled: searchTerm.length > 1,
queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: isDialogOpen && searchTerm.length >= 2,
});
const addMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onSuccess: () => {
adminsQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
toast.success(t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."));
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
},
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onSuccess: () => {
adminsQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
toast.success(t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
},
});
@@ -67,144 +88,176 @@ function TenantAdminsTab() {
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
if (window.confirm(t("msg.admin.tenants.admins.remove_confirm", { name: userName }))) {
removeMutation.mutate(userId);
}
};
const currentAdmins = adminsQuery.data || [];
const searchResults = usersQuery.data?.items || [];
return (
<div className="grid gap-6 lg:grid-cols-2 mt-6">
{/* 현재 테넌트 관리자 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 사용자 검색 및 추가 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
<div className="space-y-6 mt-6">
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
</div>
<CardDescription>
( ).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<CardDescription className="text-muted-foreground">
{t("msg.admin.tenants.admins.subtitle", "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.")}
</CardDescription>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchTerm.length < 2 && (
<Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsAddDialogOpen(open);
if (!open) setSearchTerm("");
}}>
<DialogTrigger asChild>
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가")}
</DialogTitle>
<DialogDescription>
{t("ui.admin.tenants.admins.dialog_description", "이름 또는 이메일로 사용자를 검색하세요.")}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.admin.tenants.admins.dialog_search_placeholder", "사용자 검색 (최소 2자)...")}
className="pl-10 h-11"
autoFocus
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">{t("ui.admin.tenants.admins.dialog_search_hint", "검색어를 입력해 주세요.")}</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto"></div>
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t("ui.admin.tenants.admins.dialog_no_results", "검색 결과가 없습니다.")}
</div>
) : (
<div className="divide-y divide-border">
{searchResults.map((user) => {
const isAlreadyAdmin = currentAdmins.some((a) => a.id === user.id);
return (
<div key={user.id} className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">{user.email}</span>
</div>
</div>
<Button
size="sm"
variant={isAlreadyAdmin ? "ghost" : "outline"}
disabled={isAlreadyAdmin || addMutation.isPending}
onClick={() => handleAddAdmin(user.id)}
>
{isAlreadyAdmin ? (
<Badge variant="secondary" className="font-normal">{t("ui.admin.tenants.admins.already_admin", "이미 관리자")}</Badge>
) : (
<><Plus className="h-3 w-3 mr-1" /> {t("ui.common.add", "추가")}</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="rounded-xl border border-border overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.admins.table_actions", "액션")}
</TableHead>
</TableRow>
)}
{searchTerm.length >= 2 &&
usersQuery.data?.items.length === 0 && (
</TableHeader>
<TableBody>
{adminsQuery.isLoading ? (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
.
<TableCell colSpan={3} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto"></div>
</TableCell>
</TableRow>
)}
{usersQuery.data?.items
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>{t("msg.admin.tenants.admins.empty", "등록된 관리자가 없습니다.")}</p>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
currentAdmins.map((admin) => (
<TableRow key={admin.id} className="hover:bg-muted/30 transition-colors group">
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{admin.name.charAt(0)}
</div>
<span>{admin.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{admin.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
title={t("ui.admin.tenants.admins.remove_title", "관리자 권한 회수")}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>

View File

@@ -21,6 +21,7 @@ import { t } from "../../../lib/i18n";
function TenantCreatePage() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
@@ -30,6 +31,7 @@ function TenantCreatePage() {
mutationFn: () =>
createTenant({
name,
type,
slug: slug || undefined,
description: description || undefined,
status,
@@ -92,14 +94,30 @@ function TenantCreatePage() {
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "Tenant name")}{" "}
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "Slug")}
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">{t("domain.tenant_type.company", "COMPANY (일반 기업)")}</option>
<option value="COMPANY_GROUP">{t("domain.tenant_type.company_group", "COMPANY_GROUP (그룹사/지주사)")}</option>
<option value="USER_GROUP">{t("domain.tenant_type.user_group", "USER_GROUP (내부 부서/팀)")}</option>
<option value="PERSONAL">{t("domain.tenant_type.personal", "PERSONAL (개인 워크스페이스)")}</option>
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
value={slug}
@@ -112,7 +130,7 @@ function TenantCreatePage() {
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.description", "Description")}
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
rows={3}
@@ -124,7 +142,7 @@ function TenantCreatePage() {
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.create.form.domains_label",
"Allowed Domains (Comma separated)",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
@@ -138,13 +156,13 @@ function TenantCreatePage() {
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.tenants.create.form.domains_help",
"Users with these email domains will be automatically assigned to this tenant.",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "Status")}
{t("ui.admin.tenants.create.form.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
@@ -152,14 +170,14 @@ function TenantCreatePage() {
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "Active")}
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "Inactive")}
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { ArrowLeft } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
@@ -17,88 +18,91 @@ function TenantDetailPage() {
const isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins");
const isUserGroupsTab = location.pathname.includes("/user-groups");
const isOrganizationTab = location.pathname.includes("/organization");
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenants" className="inline-flex items-center gap-2">
<Link to="/tenants" className="inline-flex items-center gap-2 hover:text-foreground transition-colors">
<ArrowLeft size={14} />
Tenants
{t("ui.admin.tenants.detail.breadcrumb_list", "테넌트 목록")}
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
<span className="text-foreground">{t("ui.admin.tenants.detail.title", "상세")}</span>
</div>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ?? "Loading Tenant..."}
{tenantQuery.data?.name ?? t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
Edit tenant information or manage federation settings.
{t("ui.admin.tenants.detail.header_subtitle", "테넌트 정보를 수정하거나 연동 설정을 관리합니다.")}
</p>
</div>
<Badge variant="muted">Admin only</Badge>
<Badge variant="muted">{t("ui.common.admin_only", "관리자 전용")}</Badge>
</header>
{/* Tabs */}
<div className="flex border-b">
<div className="flex border-b border-border">
<Link
to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
!isFederationTab &&
!isAdminTab &&
!location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
!location.pathname.includes("/schema") &&
!isOrganizationTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Profile
{t("ui.admin.tenants.detail.tab_profile", "프로필")}
</Link>
<Link
to={`/tenants/${tenantId}/federation`}
className={`px-4 py-2 text-sm font-medium ${
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isFederationTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Federation
{t("ui.admin.tenants.detail.tab_federation", "외부 연동")}
</Link>
<Link
to={`/tenants/${tenantId}/admins`}
className={`px-4 py-2 text-sm font-medium ${
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isAdminTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Admins
{t("ui.admin.tenants.detail.tab_admins", "관리자 설정")}
</Link>
<Link
to={`/tenants/${tenantId}/user-groups`}
className={`px-4 py-2 text-sm font-medium ${
isUserGroupsTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
to={`/tenants/${tenantId}/organization`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isOrganizationTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
User Groups
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Schema
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
</div>
{/* Outlet for nested routes */}
<Outlet />
<div className="animate-in fade-in duration-500">
<Outlet />
</div>
</div>
);
}

View File

@@ -128,6 +128,9 @@ function TenantListPage() {
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.type", "TYPE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
@@ -163,6 +166,11 @@ function TenantListPage() {
{items.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] font-mono">
{tenant.type || "PERSONAL"}
</Badge>
</TableCell>
<TableCell>{tenant.slug}</TableCell>
<TableCell>
<Badge

View File

@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
import { Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -20,6 +21,7 @@ import {
fetchTenant,
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
@@ -27,7 +29,7 @@ export function TenantProfilePage() {
const queryClient = useQueryClient();
if (!tenantId) {
return <div>Tenant ID is missing</div>;
return <div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>;
}
const tenantQuery = useQuery({
@@ -36,6 +38,7 @@ export function TenantProfilePage() {
});
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
@@ -44,6 +47,7 @@ export function TenantProfilePage() {
useEffect(() => {
if (tenantQuery.data) {
setName(tenantQuery.data.name);
setType(tenantQuery.data.type || "COMPANY");
setSlug(tenantQuery.data.slug);
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
@@ -55,6 +59,7 @@ export function TenantProfilePage() {
mutationFn: () =>
updateTenant(tenantId, {
name,
type,
slug,
description: description || undefined,
status,
@@ -66,8 +71,11 @@ export function TenantProfilePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant updated successfully");
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("err.common.unknown", "오류가 발생했습니다."));
}
});
const approveMutation = useMutation({
@@ -75,14 +83,18 @@ export function TenantProfilePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant approved successfully");
toast.success(t("msg.admin.tenants.approve_success", "테넌트가 승인되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("err.common.unknown", "오류가 발생했습니다."));
}
});
const deleteMutation = useMutation({
mutationFn: () => deleteTenant(tenantId),
onSuccess: () => {
navigate("/tenants");
toast.success(t("msg.admin.tenants.delete_success", "테넌트가 삭제되었습니다."));
},
});
@@ -92,13 +104,13 @@ export function TenantProfilePage() {
?.response?.data?.error;
const handleDelete = () => {
if (window.confirm("Are you sure you want to delete this tenant?")) {
if (window.confirm(t("msg.admin.tenants.delete_confirm", { name: tenantQuery.data?.name }))) {
deleteMutation.mutate();
}
};
const handleApprove = () => {
if (window.confirm("Approve this tenant?")) {
if (window.confirm(t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"))) {
approveMutation.mutate();
}
};
@@ -107,9 +119,9 @@ export function TenantProfilePage() {
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>Tenant profile</CardTitle>
<CardTitle>{t("ui.admin.tenants.profile.title", "테넌트 프로필")}</CardTitle>
<CardDescription>
Changes to slug and status are applied immediately.
{t("ui.admin.tenants.profile.subtitle", "슬러그 및 상태 변경은 즉시 적용됩니다.")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -120,16 +132,30 @@ export function TenantProfilePage() {
)}
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
{t("ui.admin.tenants.profile.name", "테넌트 이름")} <span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Label className="text-sm font-semibold">{t("ui.admin.tenants.profile.type", "테넌트 유형")}</Label>
<select
id="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">{t("domain.tenant_type.company", "COMPANY (일반 기업)")}</option>
<option value="COMPANY_GROUP">{t("domain.tenant_type.company_group", "COMPANY_GROUP (그룹사/지주사)")}</option>
<option value="USER_GROUP">{t("domain.tenant_type.user_group", "USER_GROUP (내부 부서/팀)")}</option>
<option value="PERSONAL">{t("domain.tenant_type.personal", "PERSONAL (개인 워크스페이스)")}</option>
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Label className="text-sm font-semibold">{t("ui.admin.tenants.profile.description", "설명")}</Label>
<Textarea
rows={3}
value={description}
@@ -138,7 +164,7 @@ export function TenantProfilePage() {
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
{t("ui.admin.tenants.profile.allowed_domains", "허용된 도메인 (콤마로 구분)")}
</Label>
<Input
value={domains}
@@ -146,26 +172,25 @@ export function TenantProfilePage() {
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
{t("ui.admin.tenants.profile.allowed_domains_help", "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.")}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<Label className="text-sm font-semibold">{t("ui.admin.tenants.profile.status", "상태")}</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
@@ -184,7 +209,7 @@ export function TenantProfilePage() {
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
Delete
{t("ui.common.delete", "삭제")}
</Button>
<div className="flex items-center gap-2">
{status === "pending" && (
@@ -194,11 +219,11 @@ export function TenantProfilePage() {
onClick={handleApprove}
disabled={approveMutation.isPending}
>
Approve Tenant
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
</Button>
)}
<Button variant="outline" onClick={() => navigate("/tenants")}>
Cancel
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => updateMutation.mutate()}
@@ -209,7 +234,7 @@ export function TenantProfilePage() {
}
>
<Save size={16} />
Save
{t("ui.common.save", "저장")}
</Button>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
import { Plus, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -39,7 +40,9 @@ export function TenantSchemaPage() {
if (!tenantId) {
return (
<div>{t("msg.admin.tenants.schema.missing_id", "Tenant ID missing")}</div>
<div className="p-8 text-center text-muted-foreground">
{t("msg.admin.tenants.schema.missing_id", "테넌트 ID가 없습니다.")}
</div>
);
}
@@ -78,18 +81,10 @@ export function TenantSchemaPage() {
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert(
t(
"msg.admin.tenants.schema.update_success",
"Schema updated successfully",
),
);
toast.success(t("msg.admin.tenants.schema.update_success", "스키마가 저장되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
alert(
err.response?.data?.error ||
t("msg.admin.tenants.schema.update_error", "Failed to update schema"),
);
toast.error(err.response?.data?.error || t("msg.admin.tenants.schema.update_error", "저장에 실패했습니다."));
},
});
@@ -118,56 +113,57 @@ export function TenantSchemaPage() {
return (
<div className="space-y-6 mt-6">
<Card>
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>
{t("ui.admin.tenants.schema.title", "User Schema Extension")}
<div className="space-y-1">
<CardTitle className="text-2xl font-bold">
{t("ui.admin.tenants.schema.title", "사용자 스키마 확장")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.tenants.schema.subtitle",
"Define custom attributes for users in this tenant.",
"이 테넌트 사용자를 위한 커스텀 속성을 정의합니다.",
)}
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" />
{t("ui.admin.tenants.schema.add_field", "Add Field")}
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{fields.length === 0 && (
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
<div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
{t(
"msg.admin.tenants.schema.empty",
'No custom fields defined. Click "Add Field" to begin.',
'정의된 커스텀 필드가 없습니다. "필드 추가"를 눌러 시작하세요.',
)}
</div>
)}
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-end gap-4 p-4 border rounded-md bg-muted/30"
className="flex items-end gap-4 p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors"
>
<div className="flex-1 space-y-2">
<Label>
{t("ui.admin.tenants.schema.field.key", "Field Key (ID)")}
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"e.g. employee_id",
"예: employee_id",
)}
className="h-10"
/>
</div>
<div className="flex-1 space-y-2">
<Label>
{t("ui.admin.tenants.schema.field.label", "Display Label")}
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
</Label>
<Input
value={field.label}
@@ -176,14 +172,17 @@ export function TenantSchemaPage() {
}
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"e.g. 사번",
"예: 사번",
)}
className="h-10"
/>
</div>
<div className="w-32 space-y-2">
<Label>{t("ui.admin.tenants.schema.field.type", "Type")}</Label>
<div className="w-40 space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type}
onChange={(e) => {
const nextType = e.target.value;
@@ -197,36 +196,37 @@ export function TenantSchemaPage() {
}}
>
<option value="text">
{t("ui.admin.tenants.schema.field.type_text", "Text")}
{t("ui.admin.tenants.schema.field.type_text", "텍스트 (Text)")}
</option>
<option value="number">
{t("ui.admin.tenants.schema.field.type_number", "Number")}
{t("ui.admin.tenants.schema.field.type_number", "숫자 (Number)")}
</option>
<option value="boolean">
{t("ui.admin.tenants.schema.field.type_boolean", "Boolean")}
{t("ui.admin.tenants.schema.field.type_boolean", "불리언 (Boolean)")}
</option>
</select>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
>
<Trash2 size={16} />
<Trash2 size={18} />
</Button>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-end">
<div className="flex justify-end pt-2">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading}
className="px-8 h-11"
>
<Save size={16} className="mr-2" />
{t("ui.admin.tenants.schema.save", "Save Schema Changes")}
<Save size={18} className="mr-2" />
{t("ui.admin.tenants.schema.save", "변경사항 저장")}
</Button>
</div>
</div>