forked from baron/baron-sso
Format adminfront code check targets
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"node_modules",
|
"node_modules",
|
||||||
"tsconfig*.json",
|
"tsconfig*.json",
|
||||||
"test-results",
|
"test-results",
|
||||||
|
"test-results.nobody-backup",
|
||||||
"playwright-report"
|
"playwright-report"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ describe("DomainTagInput", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText("example.com"), "samaneng.com ");
|
await user.type(
|
||||||
|
screen.getByPlaceholderText("example.com"),
|
||||||
|
"samaneng.com ",
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
|
|||||||
@@ -71,8 +71,7 @@ export function normalizeSchemaField(field: unknown): SchemaField {
|
|||||||
type,
|
type,
|
||||||
required: Boolean(source.required),
|
required: Boolean(source.required),
|
||||||
adminOnly: Boolean(source.adminOnly),
|
adminOnly: Boolean(source.adminOnly),
|
||||||
validation:
|
validation: typeof source.validation === "string" ? source.validation : "",
|
||||||
typeof source.validation === "string" ? source.validation : "",
|
|
||||||
unsigned: Boolean(source.unsigned),
|
unsigned: Boolean(source.unsigned),
|
||||||
isLoginId,
|
isLoginId,
|
||||||
indexed: isLoginId || Boolean(source.indexed),
|
indexed: isLoginId || Boolean(source.indexed),
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export function formatDomainConflictMessage(
|
|||||||
const tenantName =
|
const tenantName =
|
||||||
"tenant" in conflict
|
"tenant" in conflict
|
||||||
? conflict.tenant.name
|
? conflict.tenant.name
|
||||||
: conflict.tenantName || conflict.tenantSlug || conflict.tenantId || "다른";
|
: conflict.tenantName ||
|
||||||
|
conflict.tenantSlug ||
|
||||||
|
conflict.tenantId ||
|
||||||
|
"다른";
|
||||||
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
|
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ export function buildTenantImportPreview(
|
|||||||
candidates[0] && candidates[0].score >= 0.95
|
candidates[0] && candidates[0].score >= 0.95
|
||||||
? candidates[0].tenantId
|
? candidates[0].tenantId
|
||||||
: "",
|
: "",
|
||||||
defaultCreateSlug: suggestUniqueTenantSlug(row.slug || row.name, tenants),
|
defaultCreateSlug: suggestUniqueTenantSlug(
|
||||||
|
row.slug || row.name,
|
||||||
|
tenants,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -148,10 +151,7 @@ export function serializeTenantImportCSV(
|
|||||||
const sortedRows = [...previewRows].sort(
|
const sortedRows = [...previewRows].sort(
|
||||||
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
||||||
);
|
);
|
||||||
const targetTenantIds = buildTargetTenantIds(
|
const targetTenantIds = buildTargetTenantIds(sortedRows, selectedTenantIds);
|
||||||
sortedRows,
|
|
||||||
selectedTenantIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const preview of sortedRows) {
|
for (const preview of sortedRows) {
|
||||||
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||||
@@ -241,7 +241,9 @@ function remapParentTenantId(
|
|||||||
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
|
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
|
||||||
}
|
}
|
||||||
if (parentTenantSlug) {
|
if (parentTenantSlug) {
|
||||||
return targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? "";
|
return (
|
||||||
|
targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,9 +164,7 @@ function UserCreatePage() {
|
|||||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||||
return envTenantId.trim();
|
return envTenantId.trim();
|
||||||
}
|
}
|
||||||
return (
|
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||||
tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""
|
|
||||||
);
|
|
||||||
}, [tenants]);
|
}, [tenants]);
|
||||||
const nonHanmacFamilyTenants = React.useMemo(
|
const nonHanmacFamilyTenants = React.useMemo(
|
||||||
() => filterNonHanmacFamilyTenants(tenants, hanmacFamilyTenantId),
|
() => filterNonHanmacFamilyTenants(tenants, hanmacFamilyTenantId),
|
||||||
@@ -291,9 +289,7 @@ function UserCreatePage() {
|
|||||||
) => {
|
) => {
|
||||||
setAdditionalAppointments((current) =>
|
setAdditionalAppointments((current) =>
|
||||||
current.map((appointment, currentIndex) =>
|
current.map((appointment, currentIndex) =>
|
||||||
currentIndex === index
|
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
||||||
? { ...appointment, ...patch }
|
|
||||||
: appointment,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -339,10 +335,7 @@ function UserCreatePage() {
|
|||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
setError(
|
setError(
|
||||||
err.response?.data?.error ||
|
err.response?.data?.error ||
|
||||||
t(
|
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
||||||
"msg.admin.users.create.error",
|
|
||||||
"사용자 생성에 실패했습니다.",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -494,24 +487,15 @@ function UserCreatePage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3">
|
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3">
|
||||||
<span className="font-mono text-sm">
|
<span className="font-mono text-sm">{generatedPassword}</span>
|
||||||
{generatedPassword}
|
<Button size="sm" variant="outline" onClick={onCopyPassword}>
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCopyPassword}
|
|
||||||
>
|
|
||||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||||
{t("ui.common.copy", "복사")}
|
{t("ui.common.copy", "복사")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={() => navigate("/users")}>
|
<Button onClick={() => navigate("/users")}>
|
||||||
{t(
|
{t("ui.admin.users.create.go_list", "목록으로 이동")}
|
||||||
"ui.admin.users.create.go_list",
|
|
||||||
"목록으로 이동",
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -531,10 +515,7 @@ function UserCreatePage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
@@ -543,10 +524,7 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">
|
<Label htmlFor="email">
|
||||||
{t(
|
{t("ui.admin.users.create.form.email", "이메일")}
|
||||||
"ui.admin.users.create.form.email",
|
|
||||||
"이메일",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -571,25 +549,15 @@ function UserCreatePage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">
|
<Label htmlFor="password">
|
||||||
{t(
|
{t("ui.admin.users.create.form.password", "비밀번호")}
|
||||||
"ui.admin.users.create.form.password",
|
|
||||||
"비밀번호",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoPassword}
|
checked={autoPassword}
|
||||||
onChange={(event) =>
|
onChange={(event) => setAutoPassword(event.target.checked)}
|
||||||
setAutoPassword(
|
|
||||||
event.target.checked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{t(
|
{t("ui.admin.users.create.form.auto_password", "자동 생성")}
|
||||||
"ui.admin.users.create.form.auto_password",
|
|
||||||
"자동 생성",
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -618,10 +586,7 @@ function UserCreatePage() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
{t(
|
{t("ui.admin.users.create.form.name", "이름")}
|
||||||
"ui.admin.users.create.form.name",
|
|
||||||
"이름",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
@@ -645,10 +610,7 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">
|
<Label htmlFor="phone">
|
||||||
{t(
|
{t("ui.admin.users.create.form.phone", "전화번호")}
|
||||||
"ui.admin.users.create.form.phone",
|
|
||||||
"전화번호",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
@@ -661,10 +623,7 @@ function UserCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs value={userType} onValueChange={handleUserTypeChange}>
|
||||||
value={userType}
|
|
||||||
onValueChange={handleUserTypeChange}
|
|
||||||
>
|
|
||||||
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="hanmac"
|
value="hanmac"
|
||||||
@@ -686,42 +645,26 @@ function UserCreatePage() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="external" className="mt-4 space-y-4">
|
||||||
value="external"
|
|
||||||
className="mt-4 space-y-4"
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tenantSlug">
|
<Label htmlFor="tenantSlug">대표소속</Label>
|
||||||
대표소속
|
|
||||||
</Label>
|
|
||||||
<select
|
<select
|
||||||
id="tenantSlug"
|
id="tenantSlug"
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
{...register("tenantSlug")}
|
{...register("tenantSlug")}
|
||||||
disabled={
|
disabled={profile?.role === "tenant_admin"}
|
||||||
profile?.role === "tenant_admin"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{nonHanmacFamilyTenants.map(
|
{nonHanmacFamilyTenants.map((tenant) => (
|
||||||
(tenant) => (
|
<option key={tenant.id} value={tenant.slug}>
|
||||||
<option
|
{tenant.name} ({tenant.slug})
|
||||||
key={tenant.id}
|
|
||||||
value={tenant.slug}
|
|
||||||
>
|
|
||||||
{tenant.name} (
|
|
||||||
{tenant.slug})
|
|
||||||
</option>
|
</option>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="department">부서</Label>
|
<Label htmlFor="department">부서</Label>
|
||||||
<Input
|
<Input id="department" {...register("department")} />
|
||||||
id="department"
|
|
||||||
{...register("department")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -745,19 +688,13 @@ function UserCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="hanmac" className="mt-4 space-y-4">
|
||||||
value="hanmac"
|
|
||||||
className="mt-4 space-y-4"
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">소속별 직급/직무</p>
|
||||||
소속별 직급/직무
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
테넌트별 조직장 여부, 직무,
|
테넌트별 조직장 여부, 직무, 직급을 입력합니다.
|
||||||
직급을 입력합니다.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -771,8 +708,7 @@ function UserCreatePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{additionalAppointments.map(
|
{additionalAppointments.map((appointment, index) => (
|
||||||
(appointment, index) => (
|
|
||||||
<div
|
<div
|
||||||
key={appointment.draftId}
|
key={appointment.draftId}
|
||||||
data-testid={`appointment-row-${index}`}
|
data-testid={`appointment-row-${index}`}
|
||||||
@@ -788,44 +724,28 @@ function UserCreatePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPickerTarget(
|
setPickerTarget({
|
||||||
{
|
|
||||||
kind: "appointment",
|
kind: "appointment",
|
||||||
index,
|
index,
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
isResolvingTenant
|
|
||||||
}
|
}
|
||||||
|
disabled={isResolvingTenant}
|
||||||
>
|
>
|
||||||
<Building2 className="mr-2 h-4 w-4" />
|
<Building2 className="mr-2 h-4 w-4" />
|
||||||
{appointment.tenantName ||
|
{appointment.tenantName || "테넌트 선택"}
|
||||||
"테넌트 선택"}
|
|
||||||
</Button>
|
</Button>
|
||||||
{appointment.tenantSlug && (
|
{appointment.tenantSlug && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{
|
{appointment.tenantSlug}
|
||||||
appointment.tenantSlug
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-3 text-sm">
|
<label className="flex items-center gap-3 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={appointment.isOwner}
|
||||||
appointment.isOwner
|
onCheckedChange={(checked) =>
|
||||||
}
|
updateAppointment(index, {
|
||||||
onCheckedChange={(
|
isOwner: checked === true,
|
||||||
checked,
|
})
|
||||||
) =>
|
|
||||||
updateAppointment(
|
|
||||||
index,
|
|
||||||
{
|
|
||||||
isOwner:
|
|
||||||
checked ===
|
|
||||||
true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
조직장
|
조직장
|
||||||
@@ -838,52 +758,30 @@ function UserCreatePage() {
|
|||||||
data-testid={`appointment-position-line-${index}`}
|
data-testid={`appointment-position-line-${index}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor={`appointment-job-title-${index}`}>
|
||||||
htmlFor={`appointment-job-title-${index}`}
|
|
||||||
>
|
|
||||||
직무
|
직무
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`appointment-job-title-${index}`}
|
id={`appointment-job-title-${index}`}
|
||||||
value={
|
value={appointment.jobTitle ?? ""}
|
||||||
appointment.jobTitle ??
|
|
||||||
""
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateAppointment(
|
updateAppointment(index, {
|
||||||
index,
|
jobTitle: event.target.value,
|
||||||
{
|
})
|
||||||
jobTitle:
|
|
||||||
event
|
|
||||||
.target
|
|
||||||
.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor={`appointment-position-${index}`}>
|
||||||
htmlFor={`appointment-position-${index}`}
|
|
||||||
>
|
|
||||||
직급
|
직급
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`appointment-position-${index}`}
|
id={`appointment-position-${index}`}
|
||||||
value={
|
value={appointment.position ?? ""}
|
||||||
appointment.position ??
|
|
||||||
""
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateAppointment(
|
updateAppointment(index, {
|
||||||
index,
|
position: event.target.value,
|
||||||
{
|
})
|
||||||
position:
|
|
||||||
event
|
|
||||||
.target
|
|
||||||
.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -892,21 +790,15 @@ function UserCreatePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => removeAppointment(index)}
|
||||||
removeAppointment(index)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{t(
|
{t("ui.common.delete", "삭제")}
|
||||||
"ui.common.delete",
|
|
||||||
"삭제",
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -933,18 +825,11 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{userSchema.map((field) => (
|
{userSchema.map((field) => (
|
||||||
<div
|
<div key={field.key} className="space-y-2">
|
||||||
key={field.key}
|
<Label htmlFor={`metadata.${field.key}`}>
|
||||||
className="space-y-2"
|
|
||||||
>
|
|
||||||
<Label
|
|
||||||
htmlFor={`metadata.${field.key}`}
|
|
||||||
>
|
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && (
|
{field.required && (
|
||||||
<span className="ml-1 text-destructive">
|
<span className="ml-1 text-destructive">*</span>
|
||||||
*
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{field.adminOnly && (
|
{field.adminOnly && (
|
||||||
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
|
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
|
||||||
@@ -968,15 +853,12 @@ function UserCreatePage() {
|
|||||||
? "number"
|
? "number"
|
||||||
: field.type === "date"
|
: field.type === "date"
|
||||||
? "date"
|
? "date"
|
||||||
: field.type ===
|
: field.type === "boolean"
|
||||||
"boolean"
|
|
||||||
? "checkbox"
|
? "checkbox"
|
||||||
: "text"
|
: "text"
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
field.type === "boolean"
|
field.type === "boolean" ? "w-auto h-auto" : ""
|
||||||
? "w-auto h-auto"
|
|
||||||
: ""
|
|
||||||
}
|
}
|
||||||
{...registerMetadata(field)}
|
{...registerMetadata(field)}
|
||||||
/>
|
/>
|
||||||
@@ -984,9 +866,7 @@ function UserCreatePage() {
|
|||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
{
|
{
|
||||||
(
|
(
|
||||||
errors.metadata[
|
errors.metadata[field.key] as {
|
||||||
field.key
|
|
||||||
] as {
|
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
)?.message
|
)?.message
|
||||||
@@ -1012,10 +892,7 @@ function UserCreatePage() {
|
|||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{t(
|
{t("ui.admin.users.create.submit", "사용자 생성")}
|
||||||
"ui.admin.users.create.submit",
|
|
||||||
"사용자 생성",
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1030,10 +907,7 @@ function UserCreatePage() {
|
|||||||
<DialogContent className="max-w-[460px] p-4">
|
<DialogContent className="max-w-[460px] p-4">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{t(
|
{t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
||||||
"ui.admin.users.create.form.pick_tenant",
|
|
||||||
"테넌트 선택",
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t(
|
{t(
|
||||||
@@ -1043,10 +917,7 @@ function UserCreatePage() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<iframe
|
<iframe
|
||||||
title={t(
|
title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
||||||
"ui.admin.users.create.form.pick_tenant",
|
|
||||||
"테넌트 선택",
|
|
||||||
)}
|
|
||||||
src={pickerUrl}
|
src={pickerUrl}
|
||||||
className="h-[600px] w-full rounded-md border"
|
className="h-[600px] w-full rounded-md border"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -230,9 +230,7 @@ function TenantMetadataFields({
|
|||||||
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
|
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && (
|
{field.required && <span className="text-destructive">*</span>}
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
)}
|
|
||||||
{field.adminOnly && (
|
{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">
|
<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
|
Admin Only
|
||||||
@@ -240,10 +238,7 @@ function TenantMetadataFields({
|
|||||||
)}
|
)}
|
||||||
{field.isLoginId && (
|
{field.isLoginId && (
|
||||||
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
|
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||||
{t(
|
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
|
||||||
"ui.admin.users.detail.form.is_login_id",
|
|
||||||
"로그인 ID",
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -258,14 +253,8 @@ function TenantMetadataFields({
|
|||||||
? "checkbox"
|
? "checkbox"
|
||||||
: "text"
|
: "text"
|
||||||
}
|
}
|
||||||
className={
|
className={field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"}
|
||||||
field.type === "boolean"
|
{...register(`metadata.${tenant.id}.${field.key}` as const, {
|
||||||
? "w-5 h-5"
|
|
||||||
: "h-10 text-sm"
|
|
||||||
}
|
|
||||||
{...register(
|
|
||||||
`metadata.${tenant.id}.${field.key}` as const,
|
|
||||||
{
|
|
||||||
required: field.required
|
required: field.required
|
||||||
? t(
|
? t(
|
||||||
"msg.admin.users.detail.form.field_required",
|
"msg.admin.users.detail.form.field_required",
|
||||||
@@ -274,17 +263,14 @@ function TenantMetadataFields({
|
|||||||
: false,
|
: false,
|
||||||
pattern: field.validation
|
pattern: field.validation
|
||||||
? {
|
? {
|
||||||
value: new RegExp(
|
value: new RegExp(field.validation),
|
||||||
field.validation,
|
|
||||||
),
|
|
||||||
message: t(
|
message: t(
|
||||||
"msg.admin.users.detail.form.invalid_format",
|
"msg.admin.users.detail.form.invalid_format",
|
||||||
"형식이 올바르지 않습니다.",
|
"형식이 올바르지 않습니다.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
})}
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{(
|
{(
|
||||||
errors.metadata as unknown as Record<
|
errors.metadata as unknown as Record<
|
||||||
@@ -324,8 +310,7 @@ function UserDetailPage() {
|
|||||||
const [passwordResetMode, setPasswordResetMode] =
|
const [passwordResetMode, setPasswordResetMode] =
|
||||||
React.useState<PasswordResetMode>("generated");
|
React.useState<PasswordResetMode>("generated");
|
||||||
const [manualPassword, setManualPassword] = React.useState("");
|
const [manualPassword, setManualPassword] = React.useState("");
|
||||||
const [manualPasswordConfirm, setManualPasswordConfirm] =
|
const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState("");
|
||||||
React.useState("");
|
|
||||||
const [isManualPasswordVisible, setIsManualPasswordVisible] =
|
const [isManualPasswordVisible, setIsManualPasswordVisible] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
const [passwordResetError, setPasswordResetError] = React.useState<
|
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||||
@@ -405,8 +390,7 @@ function UserDetailPage() {
|
|||||||
const watchedStatus = watch("status");
|
const watchedStatus = watch("status");
|
||||||
|
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
mutationFn: (newPass: string) =>
|
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
|
||||||
updateUser(userId, { password: newPass }),
|
|
||||||
onSuccess: (_, newPass) => {
|
onSuccess: (_, newPass) => {
|
||||||
setGeneratedPassword(newPass);
|
setGeneratedPassword(newPass);
|
||||||
setPasswordResetError(null);
|
setPasswordResetError(null);
|
||||||
@@ -420,10 +404,7 @@ function UserDetailPage() {
|
|||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
const message =
|
const message =
|
||||||
err.response?.data?.error ||
|
err.response?.data?.error ||
|
||||||
t(
|
t("msg.admin.users.detail.update_error", "수정에 실패했습니다.");
|
||||||
"msg.admin.users.detail.update_error",
|
|
||||||
"수정에 실패했습니다.",
|
|
||||||
);
|
|
||||||
setPasswordResetError(message);
|
setPasswordResetError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
},
|
},
|
||||||
@@ -478,9 +459,7 @@ function UserDetailPage() {
|
|||||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||||
return envTenantId.trim();
|
return envTenantId.trim();
|
||||||
}
|
}
|
||||||
return (
|
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||||
tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""
|
|
||||||
);
|
|
||||||
}, [tenants]);
|
}, [tenants]);
|
||||||
const personalTenant = React.useMemo(
|
const personalTenant = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -558,9 +537,7 @@ function UserDetailPage() {
|
|||||||
) => {
|
) => {
|
||||||
setAdditionalAppointments((current) =>
|
setAdditionalAppointments((current) =>
|
||||||
current.map((appointment, currentIndex) =>
|
current.map((appointment, currentIndex) =>
|
||||||
currentIndex === index
|
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
||||||
? { ...appointment, ...patch }
|
|
||||||
: appointment,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -625,8 +602,7 @@ function UserDetailPage() {
|
|||||||
tenantSlug:
|
tenantSlug:
|
||||||
user.companyCode ||
|
user.companyCode ||
|
||||||
user.joinedTenants?.find(
|
user.joinedTenants?.find(
|
||||||
(t) =>
|
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||||
t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
|
||||||
)?.slug ||
|
)?.slug ||
|
||||||
"",
|
"",
|
||||||
department: user.department || "",
|
department: user.department || "",
|
||||||
@@ -644,8 +620,7 @@ function UserDetailPage() {
|
|||||||
hanmacFamilyTenantId,
|
hanmacFamilyTenantId,
|
||||||
);
|
);
|
||||||
const resolvedUserType =
|
const resolvedUserType =
|
||||||
metadata.userType === "personal" ||
|
metadata.userType === "personal" || user.companyCode === "personal"
|
||||||
user.companyCode === "personal"
|
|
||||||
? "personal"
|
? "personal"
|
||||||
: isUserHanmacFamily
|
: isUserHanmacFamily
|
||||||
? "hanmac"
|
? "hanmac"
|
||||||
@@ -657,22 +632,15 @@ function UserDetailPage() {
|
|||||||
...(user.tenant ? [user.tenant] : []),
|
...(user.tenant ? [user.tenant] : []),
|
||||||
].filter(
|
].filter(
|
||||||
(tenant, index, allTenants) =>
|
(tenant, index, allTenants) =>
|
||||||
allTenants.findIndex((item) => item.id === tenant.id) ===
|
allTenants.findIndex((item) => item.id === tenant.id) === index &&
|
||||||
index &&
|
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
|
||||||
isHanmacFamilyTenant(
|
|
||||||
tenant,
|
|
||||||
tenants,
|
|
||||||
hanmacFamilyTenantId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
setAdditionalAppointments(
|
setAdditionalAppointments(
|
||||||
Array.isArray(rawAppointments)
|
Array.isArray(rawAppointments)
|
||||||
? (rawAppointments as UserAppointment[]).map(
|
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
||||||
(appointment) => ({
|
|
||||||
...appointment,
|
...appointment,
|
||||||
draftId: createDraftId(),
|
draftId: createDraftId(),
|
||||||
}),
|
}))
|
||||||
)
|
|
||||||
: isUserHanmacFamily
|
: isUserHanmacFamily
|
||||||
? familyFallbackTenants.length > 0
|
? familyFallbackTenants.length > 0
|
||||||
? familyFallbackTenants.map((tenant) => ({
|
? familyFallbackTenants.map((tenant) => ({
|
||||||
@@ -693,9 +661,7 @@ function UserDetailPage() {
|
|||||||
tenantId: fallbackAppointment.id,
|
tenantId: fallbackAppointment.id,
|
||||||
tenantName: fallbackAppointment.name,
|
tenantName: fallbackAppointment.name,
|
||||||
tenantSlug: fallbackAppointment.slug,
|
tenantSlug: fallbackAppointment.slug,
|
||||||
isOwner:
|
isOwner: metadata.primaryTenantIsOwner === true,
|
||||||
metadata.primaryTenantIsOwner ===
|
|
||||||
true,
|
|
||||||
jobTitle: user.jobTitle,
|
jobTitle: user.jobTitle,
|
||||||
position: user.position,
|
position: user.position,
|
||||||
},
|
},
|
||||||
@@ -726,10 +692,7 @@ function UserDetailPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t("msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다."),
|
||||||
"msg.admin.users.detail.delete_success",
|
|
||||||
"사용자가 삭제되었습니다.",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
navigate("/users");
|
navigate("/users");
|
||||||
},
|
},
|
||||||
@@ -825,10 +788,7 @@ function UserDetailPage() {
|
|||||||
}, [user?.joinedTenants, user?.tenant]);
|
}, [user?.joinedTenants, user?.tenant]);
|
||||||
const selectableRepresentativeTenants = React.useMemo(
|
const selectableRepresentativeTenants = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
filterNonHanmacFamilyTenants(
|
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
|
||||||
userAffiliatedTenants,
|
|
||||||
hanmacFamilyTenantId,
|
|
||||||
),
|
|
||||||
[userAffiliatedTenants, hanmacFamilyTenantId],
|
[userAffiliatedTenants, hanmacFamilyTenantId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -844,10 +804,7 @@ function UserDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-destructive/15 p-6 text-center mt-6">
|
<div className="rounded-md bg-destructive/15 p-6 text-center mt-6">
|
||||||
<p className="text-destructive font-medium">
|
<p className="text-destructive font-medium">
|
||||||
{t(
|
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
|
||||||
"msg.admin.users.detail.not_found",
|
|
||||||
"사용자를 찾을 수 없습니다.",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -874,11 +831,7 @@ function UserDetailPage() {
|
|||||||
{t("ui.admin.users.detail.back", "목록으로")}
|
{t("ui.admin.users.detail.back", "목록으로")}
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button variant="destructive" onClick={handleDelete} size="sm">
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDelete}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{t("ui.admin.users.detail.delete", "사용자 삭제")}
|
{t("ui.admin.users.detail.delete", "사용자 삭제")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -904,27 +857,15 @@ function UserDetailPage() {
|
|||||||
{user.tenant?.name ||
|
{user.tenant?.name ||
|
||||||
user.companyCode ||
|
user.companyCode ||
|
||||||
user.joinedTenants?.find(
|
user.joinedTenants?.find(
|
||||||
(t) =>
|
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||||
t.type === "COMPANY" ||
|
|
||||||
t.type === "COMPANY_GROUP",
|
|
||||||
)?.name ||
|
)?.name ||
|
||||||
t(
|
t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
|
||||||
"ui.admin.users.detail.form.tenant_global",
|
|
||||||
"시스템 전역",
|
|
||||||
)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={user.status === "active" ? "default" : "secondary"}
|
||||||
user.status === "active"
|
|
||||||
? "default"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
className="h-6 px-3"
|
className="h-6 px-3"
|
||||||
>
|
>
|
||||||
{t(
|
{t(`ui.common.status.${user.status}`, user.status)}
|
||||||
`ui.common.status.${user.status}`,
|
|
||||||
user.status,
|
|
||||||
)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-4 text-muted-foreground text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-muted-foreground text-sm">
|
||||||
@@ -934,10 +875,7 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{user.phone && (
|
{user.phone && (
|
||||||
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
||||||
<Shield
|
<Shield size={14} className="text-primary/70" />
|
||||||
size={14}
|
|
||||||
className="text-primary/70"
|
|
||||||
/>
|
|
||||||
{user.phone}
|
{user.phone}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -946,8 +884,7 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start md:items-end text-[10px] text-muted-foreground gap-1.5 uppercase tracking-widest font-bold opacity-70">
|
<div className="flex flex-col items-start md:items-end text-[10px] text-muted-foreground gap-1.5 uppercase tracking-widest font-bold opacity-70">
|
||||||
<p>
|
<p>
|
||||||
{t("ui.admin.users.detail.created_at", "가입일")}:{" "}
|
{t("ui.admin.users.detail.created_at", "가입일")}: {user.createdAt}
|
||||||
{user.createdAt}
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t("ui.admin.users.detail.updated_at", "최근 수정")}:{" "}
|
{t("ui.admin.users.detail.updated_at", "최근 수정")}:{" "}
|
||||||
@@ -974,20 +911,14 @@ function UserDetailPage() {
|
|||||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||||
>
|
>
|
||||||
<Building2 size={16} className="mr-2" />
|
<Building2 size={16} className="mr-2" />
|
||||||
{t(
|
{t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
|
||||||
"ui.admin.users.detail.tabs.tenants",
|
|
||||||
"테넌트 프로필",
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="security"
|
value="security"
|
||||||
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
|
||||||
>
|
>
|
||||||
<Shield size={16} className="mr-2" />
|
<Shield size={16} className="mr-2" />
|
||||||
{t(
|
{t("ui.admin.users.detail.tabs.security", "보안 & 활동")}
|
||||||
"ui.admin.users.detail.tabs.security",
|
|
||||||
"보안 & 활동",
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -999,14 +930,8 @@ function UserDetailPage() {
|
|||||||
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl overflow-hidden">
|
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl overflow-hidden">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<BadgeCheck
|
<BadgeCheck size={18} className="text-primary" />
|
||||||
size={18}
|
{t("ui.admin.users.detail.edit_title", "프로필 정보")}
|
||||||
className="text-primary"
|
|
||||||
/>
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.detail.edit_title",
|
|
||||||
"프로필 정보",
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t(
|
{t(
|
||||||
@@ -1023,10 +948,7 @@ function UserDetailPage() {
|
|||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="text-xs font-bold uppercase text-muted-foreground"
|
className="text-xs font-bold uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.admin.users.detail.form.email", "이메일")}
|
||||||
"ui.admin.users.detail.form.email",
|
|
||||||
"이메일",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -1040,10 +962,7 @@ function UserDetailPage() {
|
|||||||
htmlFor="name"
|
htmlFor="name"
|
||||||
className="text-xs font-bold uppercase text-muted-foreground"
|
className="text-xs font-bold uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.admin.users.detail.form.name", "이름")}
|
||||||
"ui.admin.users.detail.form.name",
|
|
||||||
"이름",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
@@ -1064,10 +983,7 @@ function UserDetailPage() {
|
|||||||
htmlFor="phone"
|
htmlFor="phone"
|
||||||
className="text-xs font-bold uppercase text-muted-foreground"
|
className="text-xs font-bold uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.admin.users.detail.form.phone", "연락처")}
|
||||||
"ui.admin.users.detail.form.phone",
|
|
||||||
"연락처",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
@@ -1083,24 +999,14 @@ function UserDetailPage() {
|
|||||||
htmlFor="status"
|
htmlFor="status"
|
||||||
className="text-xs font-bold uppercase text-muted-foreground"
|
className="text-xs font-bold uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.admin.users.detail.form.status", "상태")}
|
||||||
"ui.admin.users.detail.form.status",
|
|
||||||
"상태",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
|
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
|
||||||
<Switch
|
<Switch
|
||||||
id="status"
|
id="status"
|
||||||
checked={
|
checked={watchedStatus === "active"}
|
||||||
watchedStatus === "active"
|
|
||||||
}
|
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setValue(
|
setValue("status", checked ? "active" : "inactive")
|
||||||
"status",
|
|
||||||
checked
|
|
||||||
? "active"
|
|
||||||
: "inactive",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -1158,23 +1064,15 @@ function UserDetailPage() {
|
|||||||
className="flex h-11 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"
|
className="flex h-11 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("tenantSlug")}
|
{...register("tenantSlug")}
|
||||||
disabled={
|
disabled={
|
||||||
profile?.role ===
|
profile?.role === "tenant_admin" &&
|
||||||
"tenant_admin" &&
|
selectableRepresentativeTenants.length <= 1
|
||||||
selectableRepresentativeTenants.length <=
|
|
||||||
1
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{selectableRepresentativeTenants.map(
|
{selectableRepresentativeTenants.map((t) => (
|
||||||
(t) => (
|
<option key={t.id} value={t.slug}>
|
||||||
<option
|
{t.name} ({t.slug})
|
||||||
key={t.id}
|
|
||||||
value={t.slug}
|
|
||||||
>
|
|
||||||
{t.name} (
|
|
||||||
{t.slug})
|
|
||||||
</option>
|
</option>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
<BadgeCheck
|
<BadgeCheck
|
||||||
size={14}
|
size={14}
|
||||||
@@ -1218,19 +1116,13 @@ function UserDetailPage() {
|
|||||||
onClick={addAppointment}
|
onClick={addAppointment}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t(
|
{t("ui.common.add", "추가")}
|
||||||
"ui.common.add",
|
|
||||||
"추가",
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{additionalAppointments.map(
|
{additionalAppointments.map((appointment, index) => (
|
||||||
(appointment, index) => (
|
|
||||||
<div
|
<div
|
||||||
key={
|
key={appointment.draftId}
|
||||||
appointment.draftId
|
|
||||||
}
|
|
||||||
data-testid={`detail-appointment-row-${index}`}
|
data-testid={`detail-appointment-row-${index}`}
|
||||||
className="grid gap-3 rounded-md border p-3 lg:grid-cols-[minmax(280px,1.2fr)_minmax(280px,1fr)_auto] lg:items-end"
|
className="grid gap-3 rounded-md border p-3 lg:grid-cols-[minmax(280px,1.2fr)_minmax(280px,1fr)_auto] lg:items-end"
|
||||||
>
|
>
|
||||||
@@ -1249,16 +1141,12 @@ function UserDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPickerTarget(
|
setPickerTarget({
|
||||||
{
|
|
||||||
kind: "appointment",
|
kind: "appointment",
|
||||||
index,
|
index,
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
isResolvingTenant
|
|
||||||
}
|
}
|
||||||
|
disabled={isResolvingTenant}
|
||||||
>
|
>
|
||||||
<Building2 className="mr-2 h-4 w-4" />
|
<Building2 className="mr-2 h-4 w-4" />
|
||||||
{appointment.tenantName ||
|
{appointment.tenantName ||
|
||||||
@@ -1269,27 +1157,16 @@ function UserDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{appointment.tenantSlug && (
|
{appointment.tenantSlug && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{
|
{appointment.tenantSlug}
|
||||||
appointment.tenantSlug
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-3 text-sm">
|
<label className="flex items-center gap-3 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={appointment.isOwner}
|
||||||
appointment.isOwner
|
onCheckedChange={(checked) =>
|
||||||
}
|
updateAppointment(index, {
|
||||||
onCheckedChange={(
|
isOwner: checked === true,
|
||||||
checked,
|
})
|
||||||
) =>
|
|
||||||
updateAppointment(
|
|
||||||
index,
|
|
||||||
{
|
|
||||||
isOwner:
|
|
||||||
checked ===
|
|
||||||
true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{t(
|
{t(
|
||||||
@@ -1315,22 +1192,11 @@ function UserDetailPage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`detail-appointment-job-title-${index}`}
|
id={`detail-appointment-job-title-${index}`}
|
||||||
value={
|
value={appointment.jobTitle ?? ""}
|
||||||
appointment.jobTitle ??
|
onChange={(event) =>
|
||||||
""
|
updateAppointment(index, {
|
||||||
}
|
jobTitle: event.target.value,
|
||||||
onChange={(
|
})
|
||||||
event,
|
|
||||||
) =>
|
|
||||||
updateAppointment(
|
|
||||||
index,
|
|
||||||
{
|
|
||||||
jobTitle:
|
|
||||||
event
|
|
||||||
.target
|
|
||||||
.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1346,22 +1212,11 @@ function UserDetailPage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`detail-appointment-position-${index}`}
|
id={`detail-appointment-position-${index}`}
|
||||||
value={
|
value={appointment.position ?? ""}
|
||||||
appointment.position ??
|
onChange={(event) =>
|
||||||
""
|
updateAppointment(index, {
|
||||||
}
|
position: event.target.value,
|
||||||
onChange={(
|
})
|
||||||
event,
|
|
||||||
) =>
|
|
||||||
updateAppointment(
|
|
||||||
index,
|
|
||||||
{
|
|
||||||
position:
|
|
||||||
event
|
|
||||||
.target
|
|
||||||
.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1370,23 +1225,15 @@ function UserDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => removeAppointment(index)}
|
||||||
removeAppointment(
|
|
||||||
index,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{t(
|
{t("ui.common.delete", "삭제")}
|
||||||
"ui.common.delete",
|
|
||||||
"삭제",
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1423,10 +1270,7 @@ function UserDetailPage() {
|
|||||||
htmlFor="position"
|
htmlFor="position"
|
||||||
className="text-xs font-bold uppercase text-muted-foreground"
|
className="text-xs font-bold uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.admin.users.detail.form.position", "직급")}
|
||||||
"ui.admin.users.detail.form.position",
|
|
||||||
"직급",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="position"
|
id="position"
|
||||||
@@ -1439,10 +1283,7 @@ function UserDetailPage() {
|
|||||||
htmlFor="jobTitle"
|
htmlFor="jobTitle"
|
||||||
className="text-xs font-bold uppercase text-muted-foreground"
|
className="text-xs font-bold uppercase text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.admin.users.detail.form.job_title", "직무")}
|
||||||
"ui.admin.users.detail.form.job_title",
|
|
||||||
"직무",
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="jobTitle"
|
id="jobTitle"
|
||||||
@@ -1467,10 +1308,7 @@ function UserDetailPage() {
|
|||||||
<Save className="mr-2 h-5 w-5" />
|
<Save className="mr-2 h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
<span className="text-base font-bold">
|
<span className="text-base font-bold">
|
||||||
{t(
|
{t("ui.admin.users.detail.save", "저장하기")}
|
||||||
"ui.admin.users.detail.save",
|
|
||||||
"저장하기",
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1483,10 +1321,7 @@ function UserDetailPage() {
|
|||||||
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
|
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Building2
|
<Building2 size={18} className="text-primary" />
|
||||||
size={18}
|
|
||||||
className="text-primary"
|
|
||||||
/>
|
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.custom_fields.multi_title",
|
"ui.admin.users.detail.custom_fields.multi_title",
|
||||||
"테넌트별 상세 프로필",
|
"테넌트별 상세 프로필",
|
||||||
@@ -1502,10 +1337,7 @@ function UserDetailPage() {
|
|||||||
<CardContent className="space-y-8 p-8">
|
<CardContent className="space-y-8 p-8">
|
||||||
{userAffiliatedTenants.length === 0 ? (
|
{userAffiliatedTenants.length === 0 ? (
|
||||||
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
|
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
|
||||||
<Building2
|
<Building2 size={48} className="mx-auto mb-4 opacity-20" />
|
||||||
size={48}
|
|
||||||
className="mx-auto mb-4 opacity-20"
|
|
||||||
/>
|
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.no_tenants",
|
"msg.admin.users.detail.no_tenants",
|
||||||
@@ -1519,8 +1351,7 @@ function UserDetailPage() {
|
|||||||
const tDetail = tenants.find(
|
const tDetail = tenants.find(
|
||||||
(tenant) => tenant.id === t.id,
|
(tenant) => tenant.id === t.id,
|
||||||
);
|
);
|
||||||
const schema = (tDetail?.config
|
const schema = (tDetail?.config?.userSchema ||
|
||||||
?.userSchema ||
|
|
||||||
[]) as UserSchemaField[];
|
[]) as UserSchemaField[];
|
||||||
return (
|
return (
|
||||||
<TenantMetadataFields
|
<TenantMetadataFields
|
||||||
@@ -1568,10 +1399,7 @@ function UserDetailPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Key size={18} className="text-primary" />
|
<Key size={18} className="text-primary" />
|
||||||
{t(
|
{t("ui.admin.users.detail.password_title", "비밀번호 관리")}
|
||||||
"ui.admin.users.detail.password_title",
|
|
||||||
"비밀번호 관리",
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t(
|
{t(
|
||||||
@@ -1603,19 +1431,13 @@ function UserDetailPage() {
|
|||||||
className="h-10 rounded-xl px-5 border-primary/20 hover:border-primary/50 hover:bg-primary/5"
|
className="h-10 rounded-xl px-5 border-primary/20 hover:border-primary/50 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t(
|
{t("ui.admin.users.detail.reset_password", "초기화 도구")}
|
||||||
"ui.admin.users.detail.reset_password",
|
|
||||||
"초기화 도구",
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<div className="rounded-xl bg-blue-500/5 border border-blue-500/10 px-5 py-4 text-sm text-blue-600 flex items-center gap-3">
|
<div className="rounded-xl bg-blue-500/5 border border-blue-500/10 px-5 py-4 text-sm text-blue-600 flex items-center gap-3">
|
||||||
<Shield
|
<Shield size={18} className="shrink-0" />
|
||||||
size={18}
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
<p className="leading-relaxed">
|
<p className="leading-relaxed">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.self_password_reset_blocked",
|
"msg.admin.users.detail.self_password_reset_blocked",
|
||||||
@@ -1625,16 +1447,12 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPasswordResetOpen &&
|
{isPasswordResetOpen && !generatedPassword && !isSelf && (
|
||||||
!generatedPassword &&
|
|
||||||
!isSelf && (
|
|
||||||
<div className="mt-4 p-6 border rounded-2xl bg-card shadow-sm animate-in zoom-in-95 duration-200">
|
<div className="mt-4 p-6 border rounded-2xl bg-card shadow-sm animate-in zoom-in-95 duration-200">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={passwordResetMode}
|
value={passwordResetMode}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setPasswordResetMode(
|
setPasswordResetMode(v as PasswordResetMode)
|
||||||
v as PasswordResetMode,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
<TabsList className="grid w-full grid-cols-2 mb-6 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||||
@@ -1642,26 +1460,17 @@ function UserDetailPage() {
|
|||||||
value="auto"
|
value="auto"
|
||||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
|
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.admin.users.detail.reset_auto", "자동 생성")}
|
||||||
"ui.admin.users.detail.reset_auto",
|
|
||||||
"자동 생성",
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="manual"
|
value="manual"
|
||||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
|
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.admin.users.detail.reset_manual", "직접 입력")}
|
||||||
"ui.admin.users.detail.reset_manual",
|
|
||||||
"직접 입력",
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="auto" className="space-y-4">
|
||||||
value="auto"
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div className="bg-muted/50 p-4 rounded-xl text-xs text-muted-foreground leading-relaxed">
|
<div className="bg-muted/50 p-4 rounded-xl text-xs text-muted-foreground leading-relaxed">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.reset_auto_desc",
|
"msg.admin.users.detail.reset_auto_desc",
|
||||||
@@ -1671,9 +1480,7 @@ function UserDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setManualPassword(
|
setManualPassword(generateSecurePassword())
|
||||||
generateSecurePassword(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full h-11 rounded-xl font-bold"
|
className="w-full h-11 rounded-xl font-bold"
|
||||||
@@ -1685,10 +1492,7 @@ function UserDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="manual" className="space-y-5 pt-2">
|
||||||
value="manual"
|
|
||||||
className="space-y-5 pt-2"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-bold uppercase">
|
<Label className="text-xs font-bold uppercase">
|
||||||
{t(
|
{t(
|
||||||
@@ -1699,18 +1503,11 @@ function UserDetailPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type={
|
type={
|
||||||
isManualPasswordVisible
|
isManualPasswordVisible ? "text" : "password"
|
||||||
? "text"
|
|
||||||
: "password"
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
manualPassword
|
|
||||||
}
|
}
|
||||||
|
value={manualPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setManualPassword(
|
setManualPassword(e.target.value)
|
||||||
e.target
|
|
||||||
.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="h-11 rounded-xl shadow-sm pr-12"
|
className="h-11 rounded-xl shadow-sm pr-12"
|
||||||
/>
|
/>
|
||||||
@@ -1726,17 +1523,9 @@ function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isManualPasswordVisible ? (
|
{isManualPasswordVisible ? (
|
||||||
<EyeOff
|
<EyeOff size={18} />
|
||||||
size={
|
|
||||||
18
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Eye
|
<Eye size={18} />
|
||||||
size={
|
|
||||||
18
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1750,14 +1539,9 @@ function UserDetailPage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={
|
value={manualPasswordConfirm}
|
||||||
manualPasswordConfirm
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setManualPasswordConfirm(
|
setManualPasswordConfirm(e.target.value)
|
||||||
e.target
|
|
||||||
.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="h-11 rounded-xl shadow-sm"
|
className="h-11 rounded-xl shadow-sm"
|
||||||
/>
|
/>
|
||||||
@@ -1777,24 +1561,15 @@ function UserDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={
|
onClick={handleClosePasswordReset}
|
||||||
handleClosePasswordReset
|
|
||||||
}
|
|
||||||
className="h-11 rounded-xl px-6 font-bold"
|
className="h-11 rounded-xl px-6 font-bold"
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.common.cancel", "취소")}
|
||||||
"ui.common.cancel",
|
|
||||||
"취소",
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={
|
onClick={handleExecutePasswordReset}
|
||||||
handleExecutePasswordReset
|
disabled={resetMutation.isPending}
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
resetMutation.isPending
|
|
||||||
}
|
|
||||||
className="h-11 rounded-xl px-8 font-bold shadow-md"
|
className="h-11 rounded-xl px-8 font-bold shadow-md"
|
||||||
>
|
>
|
||||||
{resetMutation.isPending && (
|
{resetMutation.isPending && (
|
||||||
@@ -1813,10 +1588,7 @@ function UserDetailPage() {
|
|||||||
{generatedPassword && (
|
{generatedPassword && (
|
||||||
<div className="mt-4 p-8 bg-green-500/10 border border-green-500/20 rounded-2xl space-y-6 animate-in zoom-in-95">
|
<div className="mt-4 p-8 bg-green-500/10 border border-green-500/20 rounded-2xl space-y-6 animate-in zoom-in-95">
|
||||||
<div className="flex items-center gap-3 text-green-700 font-extrabold text-lg">
|
<div className="flex items-center gap-3 text-green-700 font-extrabold text-lg">
|
||||||
<BadgeCheck
|
<BadgeCheck size={28} className="text-green-600" />
|
||||||
size={28}
|
|
||||||
className="text-green-600"
|
|
||||||
/>
|
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.password_done",
|
"ui.admin.users.detail.password_done",
|
||||||
"성공적으로 초기화됨",
|
"성공적으로 초기화됨",
|
||||||
@@ -1830,22 +1602,14 @@ function UserDetailPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(generatedPassword);
|
||||||
generatedPassword,
|
|
||||||
);
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t("msg.common.copied", "복사되었습니다."),
|
||||||
"msg.common.copied",
|
|
||||||
"복사되었습니다.",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold"
|
className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold"
|
||||||
>
|
>
|
||||||
<Copy
|
<Copy size={16} className="mr-2" />
|
||||||
size={16}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
{t("ui.common.copy", "복사")}
|
{t("ui.common.copy", "복사")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1854,10 +1618,7 @@ function UserDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleClosePasswordReset}
|
onClick={handleClosePasswordReset}
|
||||||
>
|
>
|
||||||
{t(
|
{t("ui.common.close", "안전하게 도구 닫기")}
|
||||||
"ui.common.close",
|
|
||||||
"안전하게 도구 닫기",
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1867,14 +1628,8 @@ function UserDetailPage() {
|
|||||||
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
|
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<History
|
<History size={18} className="text-primary" />
|
||||||
size={18}
|
{t("ui.admin.users.detail.history_title", "서비스 이용 내역")}
|
||||||
className="text-primary"
|
|
||||||
/>
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.detail.history_title",
|
|
||||||
"서비스 이용 내역",
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t(
|
{t(
|
||||||
@@ -1886,18 +1641,11 @@ function UserDetailPage() {
|
|||||||
<CardContent className="p-8 pt-2">
|
<CardContent className="p-8 pt-2">
|
||||||
{rpHistoryQuery.isLoading ? (
|
{rpHistoryQuery.isLoading ? (
|
||||||
<div className="py-12 text-center text-muted-foreground animate-pulse">
|
<div className="py-12 text-center text-muted-foreground animate-pulse">
|
||||||
{t(
|
{t("msg.common.loading", "불러오는 중...")}
|
||||||
"msg.common.loading",
|
|
||||||
"불러오는 중...",
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : !rpHistoryQuery.data ||
|
) : !rpHistoryQuery.data || rpHistoryQuery.data.length === 0 ? (
|
||||||
rpHistoryQuery.data.length === 0 ? (
|
|
||||||
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
|
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
|
||||||
<History
|
<History size={40} className="mx-auto mb-4 opacity-10" />
|
||||||
size={40}
|
|
||||||
className="mx-auto mb-4 opacity-10"
|
|
||||||
/>
|
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.no_history",
|
"msg.admin.users.detail.no_history",
|
||||||
@@ -1909,16 +1657,12 @@ function UserDetailPage() {
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{rpHistoryQuery.data.map((item) => (
|
{rpHistoryQuery.data.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={
|
key={item.client_id || item.client_id}
|
||||||
item.client_id ||
|
|
||||||
item.client_id
|
|
||||||
}
|
|
||||||
className="flex items-center justify-between p-5 rounded-2xl border bg-card hover:border-primary/40 hover:shadow-md transition-all group"
|
className="flex items-center justify-between p-5 rounded-2xl border bg-card hover:border-primary/40 hover:shadow-md transition-all group"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="font-extrabold text-base group-hover:text-primary transition-colors">
|
<span className="font-extrabold text-base group-hover:text-primary transition-colors">
|
||||||
{item.client_name ||
|
{item.client_name || item.client_id}
|
||||||
item.client_id}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded w-fit">
|
<span className="text-[11px] text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded w-fit">
|
||||||
{item.client_id}
|
{item.client_id}
|
||||||
@@ -1950,10 +1694,7 @@ function UserDetailPage() {
|
|||||||
<DialogContent className="max-w-[460px] p-4">
|
<DialogContent className="max-w-[460px] p-4">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{t(
|
{t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
|
||||||
"ui.admin.users.detail.form.pick_tenant",
|
|
||||||
"테넌트 선택",
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t(
|
{t(
|
||||||
@@ -1963,10 +1704,7 @@ function UserDetailPage() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<iframe
|
<iframe
|
||||||
title={t(
|
title={t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
|
||||||
"ui.admin.users.detail.form.pick_tenant",
|
|
||||||
"테넌트 선택",
|
|
||||||
)}
|
|
||||||
src={pickerUrl}
|
src={pickerUrl}
|
||||||
className="h-[600px] w-full rounded-md border"
|
className="h-[600px] w-full rounded-md border"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ function UserListPage() {
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(
|
toast.error(
|
||||||
t(
|
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
|
||||||
"msg.admin.users.export_error",
|
|
||||||
"사용자 내보내기에 실패했습니다.",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -400,10 +400,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
data-testid="user-import-tenant-resolution"
|
data-testid="user-import-tenant-resolution"
|
||||||
>
|
>
|
||||||
<div className="mb-2 font-medium">
|
<div className="mb-2 font-medium">
|
||||||
{t(
|
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
||||||
"ui.admin.users.bulk.tenant_resolution",
|
|
||||||
"테넌트 매핑",
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{tenantPreviewRows.map((preview) => (
|
{tenantPreviewRows.map((preview) => (
|
||||||
@@ -487,7 +484,9 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<input
|
<input
|
||||||
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
||||||
value={hanmacEmailPreviews[index]?.finalEmail ?? u.email}
|
value={
|
||||||
|
hanmacEmailPreviews[index]?.finalEmail ?? u.email
|
||||||
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setPreviewData((prev) =>
|
setPreviewData((prev) =>
|
||||||
prev.map((item, itemIndex) =>
|
prev.map((item, itemIndex) =>
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ test.describe("Tenant Allowed Domains", () => {
|
|||||||
|
|
||||||
await page.getByRole("button", { name: "저장" }).click();
|
await page.getByRole("button", { name: "저장" }).click();
|
||||||
|
|
||||||
await expect.poll(() => savedPayload).toMatchObject({
|
await expect
|
||||||
|
.poll(() => savedPayload)
|
||||||
|
.toMatchObject({
|
||||||
domains: ["samaneng.com"],
|
domains: ["samaneng.com"],
|
||||||
forceDomainConflicts: ["samaneng.com"],
|
forceDomainConflicts: ["samaneng.com"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -288,9 +288,7 @@ test.describe("Tenants Management", () => {
|
|||||||
await page
|
await page
|
||||||
.getByTestId("tenant-import-match-select-3")
|
.getByTestId("tenant-import-match-select-3")
|
||||||
.selectOption("__create__");
|
.selectOption("__create__");
|
||||||
await page
|
await page.getByTestId("tenant-import-create-slug-3").fill("child-created");
|
||||||
.getByTestId("tenant-import-create-slug-3")
|
|
||||||
.fill("child-created");
|
|
||||||
await page.getByTestId("tenant-import-confirm-btn").click();
|
await page.getByTestId("tenant-import-confirm-btn").click();
|
||||||
|
|
||||||
await expect(page.getByTestId("tenant-import-result")).toContainText(
|
await expect(page.getByTestId("tenant-import-result")).toContainText(
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ test.describe("Tenants CSV live E2E", () => {
|
|||||||
await page.route("**/api/v1/**", async (route) => {
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
const requestUrl = new URL(route.request().url());
|
const requestUrl = new URL(route.request().url());
|
||||||
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
|
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
|
||||||
const headers = { ...route.request().headers() };
|
const { authorization: _authorization, ...headers } = route
|
||||||
delete headers.authorization;
|
.request()
|
||||||
|
.headers();
|
||||||
headers["x-test-role"] = "super_admin";
|
headers["x-test-role"] = "super_admin";
|
||||||
const response = await route.fetch({ url: liveUrl, headers });
|
const response = await route.fetch({ url: liveUrl, headers });
|
||||||
await route.fulfill({ response });
|
await route.fulfill({ response });
|
||||||
|
|||||||
@@ -368,7 +368,9 @@ test.describe("User Management", () => {
|
|||||||
"외부 기업 회원",
|
"외부 기업 회원",
|
||||||
"개인 회원",
|
"개인 회원",
|
||||||
]);
|
]);
|
||||||
await expect(page.getByRole("tab", { name: /외부 기업 회원/i })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("tab", { name: /외부 기업 회원/i }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -376,9 +378,7 @@ test.describe("User Management", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||||
).toHaveAttribute("data-state", "active");
|
).toHaveAttribute("data-state", "active");
|
||||||
await expect(
|
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
|
||||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
|
||||||
).toHaveCount(0);
|
|
||||||
|
|
||||||
// Select Tenant first (important for schema fields to show up)
|
// Select Tenant first (important for schema fields to show up)
|
||||||
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
|
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
|
||||||
@@ -527,9 +527,7 @@ test.describe("User Management", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||||
).toHaveAttribute("data-state", "active");
|
).toHaveAttribute("data-state", "active");
|
||||||
await expect(
|
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
|
||||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await expect(page.locator("select#role")).toHaveCount(0);
|
await expect(page.locator("select#role")).toHaveCount(0);
|
||||||
await expect(page.locator("input#department")).toHaveCount(0);
|
await expect(page.locator("input#department")).toHaveCount(0);
|
||||||
|
|
||||||
@@ -722,9 +720,7 @@ test.describe("User Management", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||||
).toHaveAttribute("data-state", "active");
|
).toHaveAttribute("data-state", "active");
|
||||||
await expect(
|
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
|
||||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
|
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("detail-appointment-tenant-owner-line-0"),
|
page.getByTestId("detail-appointment-tenant-owner-line-0"),
|
||||||
|
|||||||
@@ -177,9 +177,9 @@ test.describe("Users Bulk Upload", () => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByTestId("user-import-tenant-resolution")).toContainText(
|
await expect(
|
||||||
/신규 생성|Create new/i,
|
page.getByTestId("user-import-tenant-resolution"),
|
||||||
);
|
).toContainText(/신규 생성|Create new/i);
|
||||||
await page.getByTestId("bulk-start-btn").click();
|
await page.getByTestId("bulk-start-btn").click();
|
||||||
|
|
||||||
await expect(page.getByText("new@test.com")).toBeVisible();
|
await expect(page.getByText("new@test.com")).toBeVisible();
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ test.describe("Users CSV live E2E", () => {
|
|||||||
await page.route("**/api/v1/**", async (route) => {
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
const requestUrl = new URL(route.request().url());
|
const requestUrl = new URL(route.request().url());
|
||||||
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
|
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
|
||||||
const headers = { ...route.request().headers() };
|
const { authorization: _authorization, ...headers } = route
|
||||||
delete headers.authorization;
|
.request()
|
||||||
|
.headers();
|
||||||
headers["x-test-role"] = "super_admin";
|
headers["x-test-role"] = "super_admin";
|
||||||
const response = await route.fetch({ url: liveUrl, headers });
|
const response = await route.fetch({ url: liveUrl, headers });
|
||||||
await route.fulfill({ response });
|
await route.fulfill({ response });
|
||||||
@@ -75,7 +76,9 @@ test.describe("Users CSV live E2E", () => {
|
|||||||
expect(path).toBeTruthy();
|
expect(path).toBeTruthy();
|
||||||
|
|
||||||
const csv = fs.readFileSync(path as string, "utf8");
|
const csv = fs.readFileSync(path as string, "utf8");
|
||||||
expect(csv).toContain("ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt");
|
expect(csv).toContain(
|
||||||
|
"ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt",
|
||||||
|
);
|
||||||
expect(csv).not.toContain("Role");
|
expect(csv).not.toContain("Role");
|
||||||
expect(csv).not.toContain("Department");
|
expect(csv).not.toContain("Department");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -230,5 +230,4 @@ test.describe("User Schema Dynamic Form", () => {
|
|||||||
.first();
|
.first();
|
||||||
await expect(errorMsg).toBeVisible();
|
await expect(errorMsg).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user