1
0
forked from baron/baron-sso

린트 적용

This commit is contained in:
2026-03-05 17:50:34 +09:00
parent c2b55081a6
commit 45ae1bb1c0
21 changed files with 1114 additions and 810 deletions

View File

@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, BadgeCheck, Building2, Loader2, Save, Users } from "lucide-react";
import {
ArrowLeft,
BadgeCheck,
Building2,
Loader2,
Save,
Users,
} from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
@@ -36,16 +43,16 @@ type UserSchemaField = {
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
// [New] Component for per-tenant profile/schema management
function TenantProfileCard({
tenant,
register,
errors,
isAdmin
}: {
tenant: any,
register: any,
errors: any,
isAdmin: boolean
function TenantProfileCard({
tenant,
register,
errors,
isAdmin,
}: {
tenant: any;
register: any;
errors: any;
isAdmin: boolean;
}) {
const { data: detail, isLoading } = useQuery({
queryKey: ["tenant", tenant.id],
@@ -56,7 +63,12 @@ function TenantProfileCard({
? (detail?.config?.userSchema as UserSchemaField[])
: [];
if (isLoading) return <div className="p-4 border rounded-lg animate-pulse bg-muted/20">Loading schema...</div>;
if (isLoading)
return (
<div className="p-4 border rounded-lg animate-pulse bg-muted/20">
Loading schema...
</div>
);
if (schema.length === 0) return null;
return (
@@ -64,16 +76,23 @@ function TenantProfileCard({
<div className="bg-muted/50 px-4 py-2 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Building2 size={14} className="text-primary" />
<span className="text-xs font-bold uppercase tracking-tight">{tenant.name}</span>
<span className="text-xs font-bold uppercase tracking-tight">
{tenant.name}
</span>
</div>
<span className="text-[10px] font-mono opacity-50">{tenant.slug}</span>
</div>
<div className="p-4 grid gap-4 md:grid-cols-2">
{schema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${tenant.id}.${field.key}`} className="text-xs">
<Label
htmlFor={`metadata.${tenant.id}.${field.key}`}
className="text-xs"
>
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
{field.required && (
<span className="ml-1 text-destructive">*</span>
)}
{field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only
@@ -83,13 +102,24 @@ function TenantProfileCard({
<Input
id={`metadata.${tenant.id}.${field.key}`}
type={
field.type === "number" ? "number" :
field.type === "date" ? "date" :
field.type === "boolean" ? "checkbox" : "text"
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"
}
className={field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"}
{...register(`metadata.${tenant.id}.${field.key}`, {
required: field.required ? t("msg.admin.users.detail.form.field_required", "필수입니다.") : false,
required: field.required
? t(
"msg.admin.users.detail.form.field_required",
"필수입니다.",
)
: false,
})}
/>
{errors.metadata?.[tenant.id]?.[field.key] && (
@@ -154,7 +184,8 @@ function UserDetailPage() {
},
});
const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin";
const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
React.useEffect(() => {
if (user) {
@@ -224,7 +255,10 @@ function UserDetailPage() {
// Combined affiliated tenants
const userAffiliatedTenants = [...(user.joinedTenants || [])];
if (user.tenant && !userAffiliatedTenants.find(t => t.id === user.tenant?.id)) {
if (
user.tenant &&
!userAffiliatedTenants.find((t) => t.id === user.tenant?.id)
) {
userAffiliatedTenants.push(user.tenant);
}
@@ -281,35 +315,55 @@ function UserDetailPage() {
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Building2 size={16} className="text-primary" />
{t("ui.admin.users.detail.tenants_section.title", "소속 및 조직 정보")}
{t(
"ui.admin.users.detail.tenants_section.title",
"소속 및 조직 정보",
)}
</h3>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
{t("ui.admin.users.detail.tenants_section.primary", "대표 소속 테넌트")}
{t(
"ui.admin.users.detail.tenants_section.primary",
"대표 소속 테넌트",
)}
</Label>
{/* Select box to specify representative tenant from joined ones */}
{userAffiliatedTenants.length > 0 ? (
<div className="relative">
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
{...register("companyCode")}
disabled={profile?.role === "tenant_admin" && userAffiliatedTenants.length <= 1}
disabled={
profile?.role === "tenant_admin" &&
userAffiliatedTenants.length <= 1
}
>
<option value="">{t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}</option>
<option value="">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역",
)}
</option>
{userAffiliatedTenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
<BadgeCheck size={14} className="absolute right-8 top-3 text-primary pointer-events-none" />
<BadgeCheck
size={14}
className="absolute right-8 top-3 text-primary pointer-events-none"
/>
</div>
) : (
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
{t("ui.admin.users.detail.form.tenant_global", "시스템 전역 (소속 없음)")}
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</div>
)}
<p className="text-[10px] text-muted-foreground">
@@ -320,15 +374,22 @@ function UserDetailPage() {
{userAffiliatedTenants.length > 1 && (
<div className="space-y-1">
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
{t("ui.admin.users.detail.tenants_section.additional", "전체 소속 목록")}
{t(
"ui.admin.users.detail.tenants_section.additional",
"전체 소속 목록",
)}
</Label>
<div className="flex flex-wrap gap-1.5 pt-1">
{userAffiliatedTenants.map(jt => (
<Link
{userAffiliatedTenants.map((jt) => (
<Link
key={jt.id}
to={`/tenants/${jt.id}`}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
jt.id === tenants.find(t => t.slug === watch("companyCode"))?.id ? "bg-primary/10 border-primary/30 text-primary font-bold" : "bg-background border-border text-muted-foreground hover:border-primary/50"
jt.id ===
tenants.find((t) => t.slug === watch("companyCode"))
?.id
? "bg-primary/10 border-primary/30 text-primary font-bold"
: "bg-background border-border text-muted-foreground hover:border-primary/50"
}`}
>
<Users size={10} />
@@ -453,7 +514,10 @@ function UserDetailPage() {
<div className="border-t pt-6 space-y-6">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
{t("ui.admin.users.detail.custom_fields.multi_title", "테넌트별 프로필 관리")}
{t(
"ui.admin.users.detail.custom_fields.multi_title",
"테넌트별 프로필 관리",
)}
</h3>
<p className="text-[11px] text-muted-foreground">
.