1
0
forked from baron/baron-sso

i18n refresh and frontend fixes

This commit is contained in:
Lectom C Han
2026-02-10 19:15:51 +09:00
parent 2441c64598
commit b6d3b69cda
44 changed files with 8603 additions and 1760 deletions

View File

@@ -15,24 +15,39 @@ import {
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
fetchUser,
fetchTenants,
fetchTenant,
updateUser,
type UserUpdateRequest,
fetchTenant,
fetchTenants,
fetchUser,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean";
required?: boolean;
};
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
function UserDetailPage() {
const { id } = useParams<{ id: string }>();
const params = useParams<{ id: string }>();
const userId = params.id ?? "";
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id!),
enabled: !!id,
const {
data: user,
isLoading,
isError,
} = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
enabled: userId.length > 0,
});
const { data: tenantsData } = useQuery({
@@ -47,7 +62,7 @@ function UserDetailPage() {
reset,
watch,
formState: { errors },
} = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
} = useForm<UserFormValues>({
defaultValues: {
name: "",
phone: "",
@@ -63,13 +78,22 @@ function UserDetailPage() {
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (key: string) =>
register(`metadata.${key}` as `metadata.${string}`);
React.useEffect(() => {
if (user) {
@@ -87,15 +111,26 @@ function UserDetailPage() {
}, [user, reset]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(id!, data),
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", id] });
setSuccessMsg("사용자 정보가 수정되었습니다.");
queryClient.invalidateQueries({ queryKey: ["user", userId] });
setSuccessMsg(
t(
"msg.admin.users.detail.update_success",
"사용자 정보가 수정되었습니다.",
),
);
setError(null);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 수정에 실패했습니다.");
setError(
err.response?.data?.error ||
t(
"msg.admin.users.detail.update_error",
"사용자 수정에 실패했습니다.",
),
);
setSuccessMsg(null);
},
});
@@ -103,19 +138,23 @@ function UserDetailPage() {
const onSubmit = (data: UserUpdateRequest) => {
const payload = { ...data };
if (!payload.password) {
delete payload.password;
payload.password = undefined;
}
mutation.mutate(payload);
};
if (isLoading) {
return <div className="p-8 text-center">Loading...</div>;
return (
<div className="p-8 text-center">
{t("msg.common.loading", "Loading...")}
</div>
);
}
if (isError || !user) {
return (
<div className="p-8 text-center text-destructive">
.
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
</div>
);
}
@@ -126,26 +165,34 @@ function UserDetailPage() {
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
{t("ui.admin.users.detail.breadcrumb.section", "Users")}
</Link>
<span>/</span>
<span className="text-foreground">{user.name}</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.detail.title", "사용자 상세")}
</h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
{t("ui.admin.users.detail.back", "목록으로 돌아가기")}
</Link>
</Button>
</header>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardTitle>
{t("ui.admin.users.detail.edit_title", "정보 수정")}
</CardTitle>
<CardDescription>
{user.email} .
{t(
"msg.admin.users.detail.edit_subtitle",
"{{email}} 계정의 정보를 수정합니다.",
{ email: user.email },
)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -163,22 +210,39 @@ function UserDetailPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">
{t("ui.admin.users.detail.form.name", "이름")}
</Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
placeholder={t(
"ui.admin.users.detail.form.name_placeholder",
"홍길동",
)}
{...register("name", {
required: t(
"msg.admin.users.detail.form.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
<p className="text-xs text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Label htmlFor="phone">
{t("ui.admin.users.detail.form.phone", "전화번호")}
</Label>
<Input
id="phone"
placeholder="010-1234-5678"
placeholder={t(
"ui.admin.users.detail.form.phone_placeholder",
"010-1234-5678",
)}
{...register("phone")}
/>
</div>
@@ -186,149 +250,145 @@ function UserDetailPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Label htmlFor="status">
{t("ui.admin.users.detail.form.status", "상태")}
</Label>
<div className="relative">
<select
id="status"
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"
{...register("status")}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="blocked">Blocked</option>
<option value="active">
{t("ui.common.status.active", "Active")}
</option>
<option value="inactive">
{t("ui.common.status.inactive", "Inactive")}
</option>
<option value="blocked">
{t("ui.common.status.blocked", "Blocked")}
</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<Label htmlFor="role">
{t("ui.admin.users.detail.form.role", "역할 (Role)")}
</Label>
<div className="relative">
<select
id="role"
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"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">
{t("ui.common.role.user", "User")}
</option>
<option value="admin">
{t("ui.common.role.admin", "Admin")}
</option>
</select>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
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"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password"> </Label>
<Label htmlFor="companyCode">
{t("ui.admin.users.detail.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="relative">
<select
id="companyCode"
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"
{...register("companyCode")}
>
<option value="">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.detail.form.department", "부서")}
</Label>
<Input
id="department"
placeholder={t(
"ui.admin.users.detail.form.department_placeholder",
"개발팀",
)}
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t(
"ui.admin.users.detail.custom_fields.title",
"테넌트 확장 정보 (Custom Fields)",
)}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...registerMetadata(field.key)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t("ui.admin.users.detail.security.title", "보안 설정")}
</h3>
<div className="space-y-2">
<Label htmlFor="password">
{t(
"ui.admin.users.detail.security.password",
"비밀번호 변경",
)}
</Label>
<Input
id="password"
type="password"
placeholder="변경할 경우에만 입력"
placeholder={t(
"ui.admin.users.detail.security.password_placeholder",
"변경할 경우에만 입력",
)}
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
. .
{t(
"msg.admin.users.detail.security.password_hint",
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
)}
</p>
</div>
</div>
@@ -339,14 +399,14 @@ function UserDetailPage() {
variant="outline"
onClick={() => navigate("/users")}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
{t("ui.common.save", "저장")}
</Button>
</div>
</form>
@@ -356,4 +416,4 @@ function UserDetailPage() {
);
}
export default UserDetailPage;
export default UserDetailPage;