From d608bdb5a8c8347688dce2dc3c2b274244d52850 Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 23 Mar 2026 15:32:32 +0900 Subject: [PATCH 1/9] refactor(adminfront): replace sonner with custom use-toast matching devfront UX policy --- adminfront/package-lock.json | 11 ---- adminfront/package.json | 1 - adminfront/src/components/ui/toaster.tsx | 35 +++++++++++ adminfront/src/components/ui/use-toast.ts | 60 +++++++++++++++++++ .../components/OrgChartUploadModal.tsx | 2 +- .../routes/TenantAdminsAndOwnersTab.tsx | 2 +- .../tenants/routes/TenantGroupsPage.tsx | 2 +- .../tenants/routes/TenantProfilePage.tsx | 2 +- .../tenants/routes/TenantSchemaPage.tsx | 2 +- .../routes/TenantUserGroupsTab.tsx | 7 +-- .../routes/UserGroupDetailPage.tsx | 4 +- .../src/features/users/UserCreatePage.tsx | 8 ++- .../src/features/users/UserDetailPage.tsx | 6 +- .../src/features/users/UserListPage.tsx | 1 + .../components/UserBulkMoveGroupModal.tsx | 4 +- .../features/users/utils/csvParser.test.ts | 2 +- adminfront/src/main.tsx | 2 + adminfront/tsconfig.app.json | 4 +- 18 files changed, 121 insertions(+), 34 deletions(-) create mode 100644 adminfront/src/components/ui/toaster.tsx create mode 100644 adminfront/src/components/ui/use-toast.ts diff --git a/adminfront/package-lock.json b/adminfront/package-lock.json index 484e6408..524f770a 100644 --- a/adminfront/package-lock.json +++ b/adminfront/package-lock.json @@ -26,7 +26,6 @@ "react-hook-form": "^7.71.1", "react-oidc-context": "^3.3.0", "react-router-dom": "^6.28.2", - "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^3.24.1" }, @@ -5156,16 +5155,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sonner": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", - "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", - "license": "MIT", - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/adminfront/package.json b/adminfront/package.json index 0ff96d03..0cbcd1fc 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -34,7 +34,6 @@ "react-hook-form": "^7.71.1", "react-oidc-context": "^3.3.0", "react-router-dom": "^6.28.2", - "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^3.24.1" }, diff --git a/adminfront/src/components/ui/toaster.tsx b/adminfront/src/components/ui/toaster.tsx new file mode 100644 index 00000000..864901c9 --- /dev/null +++ b/adminfront/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +import { AlertCircle, CheckCircle2, Info } from "lucide-react"; +import { cn } from "../../lib/utils"; +import { useToastState } from "./use-toast"; + +export function Toaster() { + const toasts = useToastState(); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((t) => ( +
+ {t.type === "success" && ( + + )} + {t.type === "error" && } + {t.type === "info" && } +

{t.message}

+
+ ))} +
+ ); +} diff --git a/adminfront/src/components/ui/use-toast.ts b/adminfront/src/components/ui/use-toast.ts new file mode 100644 index 00000000..adbc7f38 --- /dev/null +++ b/adminfront/src/components/ui/use-toast.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +type ToastType = "success" | "error" | "info"; + +interface Toast { + id: string; + message: string; + type: ToastType; +} + +let subscribers: ((toasts: Toast[]) => void)[] = []; +let toasts: Toast[] = []; + +const notify = () => { + for (const sub of subscribers) { + sub(toasts); + } +}; + +const toastBase = (message: string, type: ToastType = "success") => { + const id = Math.random().toString(36).substring(2, 9); + toasts = [...toasts, { id, message, type }]; + notify(); + + setTimeout(() => { + toasts = toasts.filter((t) => t.id !== id); + notify(); + }, 3000); +}; + +export const toast = Object.assign(toastBase, { + success: (message: string, options?: { description?: string }) => { + const finalMessage = options?.description ? `${message} +${options.description}` : message; + toastBase(finalMessage, "success"); + }, + error: (message: string, options?: { description?: string }) => { + const finalMessage = options?.description ? `${message} +${options.description}` : message; + toastBase(finalMessage, "error"); + }, + info: (message: string, options?: { description?: string }) => { + const finalMessage = options?.description ? `${message} +${options.description}` : message; + toastBase(finalMessage, "info"); + } +}); + +export const useToastState = () => { + const [state, setState] = React.useState(toasts); + + React.useEffect(() => { + subscribers.push(setState); + return () => { + subscribers = subscribers.filter((sub) => sub !== setState); + }; + }, []); + + return state; +}; diff --git a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx index ad7b27eb..1a993c18 100644 --- a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx +++ b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx @@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Download, FileText, Loader2, Upload } from "lucide-react"; import * as React from "react"; -import { toast } from "sonner"; +import { toast } from "../../../components/ui/use-toast"; import { Button } from "../../../components/ui/button"; import { Dialog, diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 33faca35..5186823c 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -12,7 +12,7 @@ import { import { useState } from "react"; import { useAuth } from "react-oidc-context"; import { useParams } from "react-router-dom"; -import { toast } from "sonner"; +import { toast } from "../../../components/ui/use-toast"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index 511f6680..6d56c737 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -18,7 +18,7 @@ import { import type React from "react"; import { useState } from "react"; import { useParams } from "react-router-dom"; -import { toast } from "sonner"; +import { toast } from "../../../components/ui/use-toast"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index aba6f9d5..17b787c3 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -3,7 +3,7 @@ import type { AxiosError } from "axios"; import { Save, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { toast } from "sonner"; +import { toast } from "../../../components/ui/use-toast"; import { Button } from "../../../components/ui/button"; import { Card, diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index 221c9a90..c3c9f1d5 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -3,7 +3,7 @@ import type { AxiosError } from "axios"; import { Plus, Save, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { toast } from "sonner"; +import { toast } from "../../../components/ui/use-toast"; import { Button } from "../../../components/ui/button"; import { Card, diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index ad60e0d3..d7f73e79 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -20,7 +20,7 @@ import { import * as React from "react"; import { useMemo, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { toast } from "sonner"; +import { toast } from "../../../components/ui/use-toast"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { @@ -266,7 +266,7 @@ const MemberTable: React.FC<{

{t("msg.admin.users.list.empty", "멤버가 없습니다.")}

+ + {owner.id === currentUserId ? t( "msg.admin.tenants.owners.remove_self", "본인의 권한은 회수할 수 없습니다.", @@ -322,11 +326,9 @@ export function TenantAdminsAndOwnersTab() { : t( "ui.admin.tenants.owners.remove_title", "소유자 권한 회수", - ) - } - > - - + )} + + )) @@ -420,25 +422,29 @@ export function TenantAdminsAndOwnersTab() { {admin.email} - + + {admin.id === currentUserId ? t( "msg.admin.tenants.admins.remove_self", "본인의 권한은 회수할 수 없습니다.", @@ -451,11 +457,9 @@ export function TenantAdminsAndOwnersTab() { : t( "ui.admin.tenants.admins.remove_title", "관리자 권한 회수", - ) - } - > - - + )} + + )) diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index 6d56c737..2778c8e8 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -18,7 +18,6 @@ import { import type React from "react"; import { useState } from "react"; import { useParams } from "react-router-dom"; -import { toast } from "../../../components/ui/use-toast"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { @@ -38,6 +37,7 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; +import { toast } from "../../../components/ui/use-toast"; import { type GroupSummary, addGroupMember, diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 17b787c3..6d186c62 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -3,7 +3,6 @@ import type { AxiosError } from "axios"; import { Save, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { toast } from "../../../components/ui/use-toast"; import { Button } from "../../../components/ui/button"; import { Card, @@ -15,10 +14,12 @@ import { import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { Textarea } from "../../../components/ui/textarea"; +import { toast } from "../../../components/ui/use-toast"; import { approveTenant, deleteTenant, fetchTenant, + fetchTenants, updateTenant, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; @@ -39,12 +40,21 @@ export function TenantProfilePage() { queryFn: () => fetchTenant(tenantId), }); + const parentQuery = useQuery({ + queryKey: ["tenants", "list-all"], + queryFn: () => fetchTenants(1000, 0), + }); + + const availableParents = + parentQuery.data?.items?.filter((t) => t.id !== tenantId) || []; + const [name, setName] = useState(""); const [type, setType] = useState("COMPANY"); const [slug, setSlug] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("active"); const [domains, setDomains] = useState(""); + const [parentId, setParentId] = useState(""); useEffect(() => { if (tenantQuery.data) { @@ -54,6 +64,7 @@ export function TenantProfilePage() { setDescription(tenantQuery.data.description ?? ""); setStatus(tenantQuery.data.status); setDomains(tenantQuery.data.domains?.join(", ") ?? ""); + setParentId(tenantQuery.data.parentId ?? ""); } }, [tenantQuery.data]); @@ -65,6 +76,7 @@ export function TenantProfilePage() { slug, description: description || undefined, status, + parentId: parentId || undefined, domains: domains .split(",") .map((d) => d.trim()) @@ -197,6 +209,31 @@ export function TenantProfilePage() { +
+ + +

+ {t( + "ui.admin.tenants.profile.form.parent_help", + "가족사 테넌트나 하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.", + )} +

+
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index e97b3296..a29d6367 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -79,9 +79,8 @@ function UserCreatePage() { // Lock company for tenant_admin React.useEffect(() => { - const p = profile as any; - if (p?.role === "tenant_admin" && p.tenantSlug) { - setValue("tenantSlug", p.tenantSlug); + if (profile?.role === "tenant_admin" && profile.tenantSlug) { + setValue("tenantSlug", profile.tenantSlug); } }, [profile, setValue]); @@ -362,7 +361,7 @@ function UserCreatePage() { 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 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("tenantSlug")} - disabled={(profile as any)?.role === "tenant_admin"} + disabled={profile?.role === "tenant_admin"} >