diff --git a/adminfront/package-lock.json b/adminfront/package-lock.json index 484e6408..591889ed 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" }, @@ -39,6 +38,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.23", "jsdom": "^28.1.0", @@ -2659,6 +2659,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", @@ -2689,6 +2696,29 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", @@ -5156,16 +5186,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..7640abdb 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" }, @@ -47,6 +46,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.23", "jsdom": "^28.1.0", diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 5026833f..4422e908 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -60,10 +60,5 @@ export const router = createBrowserRouter( ], }, ], - // React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅) - { - future: { - v7_startTransition: true, - }, - } as unknown as Parameters[1], + // React Router v7 플래그는 Provider에서 적용합니다. ); 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..402ed87c --- /dev/null +++ b/adminfront/src/components/ui/use-toast.ts @@ -0,0 +1,66 @@ +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..551c13dc 100644 --- a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx +++ b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx @@ -2,7 +2,6 @@ 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 { Button } from "../../../components/ui/button"; import { Dialog, @@ -13,6 +12,7 @@ import { DialogTitle, DialogTrigger, } from "../../../components/ui/dialog"; +import { toast } from "../../../components/ui/use-toast"; import { importOrgChart } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 33faca35..4031f590 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -12,7 +12,6 @@ import { import { useState } from "react"; import { useAuth } from "react-oidc-context"; import { useParams } from "react-router-dom"; -import { toast } from "sonner"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { @@ -39,6 +38,7 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; +import { toast } from "../../../components/ui/use-toast"; import { addTenantAdmin, addTenantOwner, @@ -291,25 +291,29 @@ export function TenantAdminsAndOwnersTab() { {owner.email} - + + {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 511f6680..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 "sonner"; 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 aba6f9d5..75683569 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 "sonner"; 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", + "하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.", + )} +

+
@@ -776,7 +775,10 @@ const TenantTreeRow: React.FC<{ onClick={() => { if (node.type === "USER_GROUP") { // User groups have a different detail path - const baseTenantId = node.tenantId || tenantId; + const baseTenantId = + (node as unknown as { tenantId?: string }).tenantId || + node.parentId || + ""; navigate(`/tenants/${baseTenantId}/organization/${node.id}`); } else { navigate(`/tenants/${node.id}`); @@ -867,6 +869,11 @@ function TenantUserGroupsTab() { toast.success(t("msg.info.saved_success", "저장되었습니다.")); setIsAddDialogOpen(false); }, + onError: (error: AxiosError<{ error?: string }>) => { + toast.error(t("msg.common.error", "오류 발생"), { + description: error.response?.data?.error || error.message, + }); + }, }); const allTenants = data?.items ?? []; diff --git a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx index 4bf60753..f2104737 100644 --- a/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx +++ b/adminfront/src/features/user-groups/routes/UserGroupDetailPage.tsx @@ -3,7 +3,6 @@ import type { AxiosError } from "axios"; import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react"; import { useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { toast } from "sonner"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { @@ -39,6 +38,7 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; +import { toast } from "../../../components/ui/use-toast"; import { addGroupMember, assignGroupRole, @@ -189,7 +189,7 @@ export function UserGroupDetailPage() {

Error:{" "} {(error as AxiosError<{ error?: string }>)?.response?.data?.error || - error.message || + (error instanceof Error ? error.message : String(error)) || "Not found"}

diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index d8907b0f..a29d6367 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -18,6 +18,7 @@ import { type UserCreateRequest, type UserCreateResponse, createUser, + fetchMe, fetchTenant, fetchTenants, } from "../../lib/adminApi"; @@ -68,7 +69,7 @@ function UserCreatePage() { name: "", phone: "", role: "user", - companyCode: "", + tenantSlug: "", department: "", position: "", jobTitle: "", @@ -78,13 +79,13 @@ function UserCreatePage() { // Lock company for tenant_admin React.useEffect(() => { - if (profile?.role === "tenant_admin" && profile.companyCode) { - setValue("companyCode", profile.companyCode); + if (profile?.role === "tenant_admin" && profile.tenantSlug) { + setValue("tenantSlug", profile.tenantSlug); } }, [profile, setValue]); - const selectedCompanyCode = watch("companyCode"); - const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode); + const selectedTenantSlug = watch("tenantSlug"); + const selectedTenant = tenants.find((t) => t.slug === selectedTenantSlug); const selectedTenantId = selectedTenant?.id ?? ""; @@ -351,15 +352,15 @@ function UserCreatePage() {
-