From ca00b36f19e993a8aa87e3f49a64d6ee93b9e636 Mon Sep 17 00:00:00 2001 From: Jaka Ramdani Date: Tue, 21 Apr 2026 06:30:48 +0700 Subject: [PATCH] ignore folder --- .env.example | 5 + .gitignore | 1 + .next/dev/cache/.rscinfo | 1 + .next/dev/cache/turbopack/23c46498/CURRENT | Bin 0 -> 4 bytes .next/dev/logs/next-development.log | 0 .next/dev/package.json | 3 + SETUP.md | 36 + app/(auth)/login/page.tsx | 111 +++ app/(dashboard)/audit/page.tsx | 85 ++ app/(dashboard)/layout.tsx | 32 + app/(dashboard)/modules/page.tsx | 112 +++ app/(dashboard)/page.tsx | 84 ++ app/(dashboard)/roles/page.tsx | 135 +++ app/(dashboard)/settings/page.tsx | 75 ++ app/(dashboard)/users/page.tsx | 145 ++++ app/(dashboard)/workflow/page.tsx | 197 +++++ app/globals.css | 67 ++ app/layout.tsx | 19 + app/page.tsx | 5 + components/layout/AppToasts.tsx | 35 + components/layout/DashboardShell.tsx | 111 +++ components/role/RoleCreateForm.tsx | 68 ++ components/role/RolePermissionForm.tsx | 79 ++ components/ui/Alert.tsx | 4 + components/ui/Badge.tsx | 19 + components/ui/Breadcrumb.tsx | 31 + components/ui/Card.tsx | 14 + components/ui/ConfirmDialog.tsx | 41 + components/ui/DateRange.tsx | 39 + components/ui/Dialog.tsx | 60 ++ components/ui/Drawer.tsx | 33 + components/ui/EmptyState.tsx | 18 + components/ui/FileUpload.tsx | 49 ++ components/ui/FormField.tsx | 23 + components/ui/Modal.tsx | 25 + components/ui/PageHeader.tsx | 27 + components/ui/Pagination.tsx | 29 + components/ui/Select.tsx | 38 + components/ui/Spinner.tsx | 8 + components/ui/Table.tsx | 52 ++ components/ui/Tabs.tsx | 35 + components/user/RoleUpdateForm.tsx | 58 ++ components/user/UserCreateForm.tsx | 105 +++ components/workflow/ApprovalActionModal.tsx | 62 ++ components/workflow/ApprovalTable.tsx | 36 + components/workflow/StatusBadge.tsx | 12 + hooks/useApi.ts | 11 + hooks/useAuth.ts | 5 + hooks/usePermissions.ts | 23 + hooks/useTenantHeader.ts | 6 + lib/locale.ts | 138 +++ lib/permissions.ts | 8 + next-env.d.ts | 5 + next.config.mjs | 9 + package-lock.json | 897 ++++++++++++++++++++ package.json | 27 + services/api.ts | 59 ++ services/audit.ts | 10 + services/auth.ts | 41 + services/modules.ts | 15 + services/roles.ts | 24 + services/tenant.ts | 8 + services/users.ts | 22 + services/workflow.ts | 43 + store/authStore.ts | 87 ++ store/permissionStore.ts | 16 + store/tenantStore.ts | 31 + store/uiStore.ts | 38 + tsconfig.json | 23 + types/api.ts | 101 +++ 70 files changed, 3871 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .next/dev/cache/.rscinfo create mode 100644 .next/dev/cache/turbopack/23c46498/CURRENT create mode 100644 .next/dev/logs/next-development.log create mode 100644 .next/dev/package.json create mode 100644 SETUP.md create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(dashboard)/audit/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/(dashboard)/modules/page.tsx create mode 100644 app/(dashboard)/page.tsx create mode 100644 app/(dashboard)/roles/page.tsx create mode 100644 app/(dashboard)/settings/page.tsx create mode 100644 app/(dashboard)/users/page.tsx create mode 100644 app/(dashboard)/workflow/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/layout/AppToasts.tsx create mode 100644 components/layout/DashboardShell.tsx create mode 100644 components/role/RoleCreateForm.tsx create mode 100644 components/role/RolePermissionForm.tsx create mode 100644 components/ui/Alert.tsx create mode 100644 components/ui/Badge.tsx create mode 100644 components/ui/Breadcrumb.tsx create mode 100644 components/ui/Card.tsx create mode 100644 components/ui/ConfirmDialog.tsx create mode 100644 components/ui/DateRange.tsx create mode 100644 components/ui/Dialog.tsx create mode 100644 components/ui/Drawer.tsx create mode 100644 components/ui/EmptyState.tsx create mode 100644 components/ui/FileUpload.tsx create mode 100644 components/ui/FormField.tsx create mode 100644 components/ui/Modal.tsx create mode 100644 components/ui/PageHeader.tsx create mode 100644 components/ui/Pagination.tsx create mode 100644 components/ui/Select.tsx create mode 100644 components/ui/Spinner.tsx create mode 100644 components/ui/Table.tsx create mode 100644 components/ui/Tabs.tsx create mode 100644 components/user/RoleUpdateForm.tsx create mode 100644 components/user/UserCreateForm.tsx create mode 100644 components/workflow/ApprovalActionModal.tsx create mode 100644 components/workflow/ApprovalTable.tsx create mode 100644 components/workflow/StatusBadge.tsx create mode 100644 hooks/useApi.ts create mode 100644 hooks/useAuth.ts create mode 100644 hooks/usePermissions.ts create mode 100644 hooks/useTenantHeader.ts create mode 100644 lib/locale.ts create mode 100644 lib/permissions.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 services/api.ts create mode 100644 services/audit.ts create mode 100644 services/auth.ts create mode 100644 services/modules.ts create mode 100644 services/roles.ts create mode 100644 services/tenant.ts create mode 100644 services/users.ts create mode 100644 services/workflow.ts create mode 100644 store/authStore.ts create mode 100644 store/permissionStore.ts create mode 100644 store/tenantStore.ts create mode 100644 store/uiStore.ts create mode 100644 tsconfig.json create mode 100644 types/api.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f4e3e86 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +NEXT_PUBLIC_API_BASE_URL=http://localhost:9191 +NEXT_PUBLIC_DEFAULT_TENANT=acme +NEXT_PUBLIC_LOCALE=en +# Optional: local or ldap to control create user form behavior +NEXT_PUBLIC_AUTH_MODE=local diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9f784d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +"node_modules/" diff --git a/.next/dev/cache/.rscinfo b/.next/dev/cache/.rscinfo new file mode 100644 index 0000000..8001672 --- /dev/null +++ b/.next/dev/cache/.rscinfo @@ -0,0 +1 @@ +{"encryption.key":"f2nGpaARRR6dtwAzL0EpxpoT3r4WsgTylRhv8FaEF5A=","encryption.expire_at":1777936177168} \ No newline at end of file diff --git a/.next/dev/cache/turbopack/23c46498/CURRENT b/.next/dev/cache/turbopack/23c46498/CURRENT new file mode 100644 index 0000000000000000000000000000000000000000..593f4708db84ac8fd0f5cc47c634f38c013fe9e4 GIT binary patch literal 4 LcmZQzU|;|M00aO5 literal 0 HcmV?d00001 diff --git a/.next/dev/logs/next-development.log b/.next/dev/logs/next-development.log new file mode 100644 index 0000000..e69de29 diff --git a/.next/dev/package.json b/.next/dev/package.json new file mode 100644 index 0000000..c9a4422 --- /dev/null +++ b/.next/dev/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} \ No newline at end of file diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..07e6557 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,36 @@ +# UTMS Next.js Frontend Setup + +## Install + +```bash +npm install +``` + +## Environment + +Create `.env.local` from `.env.example`: + +```bash +NEXT_PUBLIC_API_BASE_URL=http://localhost:9191 +NEXT_PUBLIC_DEFAULT_TENANT=acme +NEXT_PUBLIC_LOCALE=en +NEXT_PUBLIC_AUTH_MODE=local +``` + +## Run + +```bash +npm run dev +``` + +Application opens on: + +- `http://localhost:3000/login` for sign-in +- `http://localhost:3000/dashboard` for admin pages + +## Notes + +- JWT token and tenant are persisted in localStorage via Zustand. +- `tenant` and `locale` are kept across refresh. +- 401 and 403 are handled globally in Axios interceptor with redirect/toast. +- `LOGIN` errors show backend message directly, including lockout and LDAP provisioning errors. diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..8328f1e --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useAuthStore } from "@/store/authStore"; +import { useTenantStore } from "@/store/tenantStore"; +import { login, getCurrentUser } from "@/services/auth"; +import { usePermissionStore } from "@/store/permissionStore"; +import { useApiStore } from "@/store/uiStore"; +import { t } from "@/lib/locale"; +import { useLocaleStore } from "@/store/uiStore"; + +export default function LoginPage() { + const router = useRouter(); + const setAuth = useAuthStore((s) => s.setAuthFromLogin); + const setPermissions = usePermissionStore((s) => s.setProfile); + const setProfile = useAuthStore((s) => s.setProfile); + const addToast = useApiStore((s) => s.addToast); + const tenantId = useTenantStore((s) => s.tenantId); + const setTenant = useTenantStore((s) => s.setTenantId); + const availableTenants = useTenantStore((s) => s.availableTenants); + const locale = useLocaleStore((s) => s.locale); + const hasToken = useAuthStore((s) => !!s.accessToken); + + useEffect(() => { + useAuthStore.getState().hydrate(); + if (hasToken) router.replace("/dashboard"); + }, [hasToken, router]); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [selectedTenant, setSelectedTenant] = useState(tenantId); + const [isLoading, setIsLoading] = useState(false); + + const onSubmit = async (ev: FormEvent) => { + ev.preventDefault(); + if (!selectedTenant) { + addToast("Tenant is required", "error"); + return; + } + if (!username || !password) { + addToast("Username and password are required", "error"); + return; + } + setIsLoading(true); + try { + const authRes = await login({ username, password }, selectedTenant); + setAuth({ + tokenType: authRes.tokenType, + accessToken: authRes.accessToken, + refreshToken: authRes.refreshToken, + expiresInSeconds: authRes.expiresInSeconds + }); + setTenant(selectedTenant); + const me = await getCurrentUser(); + setProfile(me.data); + setPermissions(me.data.roles, me.data.permissions); + addToast(t("loginSuccess", locale), "success"); + router.replace("/dashboard"); + } catch (error: unknown) { + const message = + (error as { message?: string } | undefined)?.message || + t("unknownError", locale); + addToast(message, "error"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

UTMS Admin

+

Sign in with tenant context

+
+
+ + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + +
+
+
+ ); +} diff --git a/app/(dashboard)/audit/page.tsx b/app/(dashboard)/audit/page.tsx new file mode 100644 index 0000000..30ebd34 --- /dev/null +++ b/app/(dashboard)/audit/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getAudit } from "@/services/audit"; +import { usePermissions } from "@/hooks/usePermissions"; +import { useApiStore } from "@/store/uiStore"; +import { useLocaleStore } from "@/store/uiStore"; +import DataTable from "@/components/ui/Table"; +import PageHeader from "@/components/ui/PageHeader"; +import EmptyState from "@/components/ui/EmptyState"; +import Spinner from "@/components/ui/Spinner"; +import { AuditItem } from "@/types/api"; +import { t } from "@/lib/locale"; + +export default function AuditPage() { + const permissions = usePermissions(); + const addToast = useApiStore((s) => s.addToast); + const locale = useLocaleStore((s) => s.locale); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const load = async () => { + if (!permissions.isAdmin) { + setLoading(false); + return; + } + setLoading(true); + try { + const data = await getAudit(50); + setItems(data); + } catch (error) { + addToast((error as { message?: string })?.message || t("loadFailed", locale), "error"); + } finally { + setLoading(false); + } + }; + load(); + }, [permissions.isAdmin, addToast, locale]); + + if (!permissions.isAdmin) { + return ( +
+ {t("forbidden", locale)} +
+ ); + } + + return ( +
+ + {loading && } + {!loading && items.length === 0 ? ( + + ) : ( +
+
+ +
+
+ )} +
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..acce479 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import type { ReactNode } from "react"; +import DashboardShell from "@/components/layout/DashboardShell"; +import { useAuthStore } from "@/store/authStore"; +import { useTenantStore } from "@/store/tenantStore"; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const token = useAuthStore((s) => s.accessToken); + const hydrateAuth = useAuthStore((s) => s.hydrate); + const hydrateTenant = useTenantStore((s) => s.hydrate); + const [ready, setReady] = useState(false); + + useEffect(() => { + hydrateAuth(); + hydrateTenant(); + setReady(true); + }, [hydrateAuth, hydrateTenant]); + + useEffect(() => { + if (!ready) return; + if (!token && !pathname.startsWith("/login")) { + router.replace("/login"); + } + }, [token, pathname, ready, router]); + + return {children}; +} diff --git a/app/(dashboard)/modules/page.tsx b/app/(dashboard)/modules/page.tsx new file mode 100644 index 0000000..0240e7c --- /dev/null +++ b/app/(dashboard)/modules/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getModules, toggleModule } from "@/services/modules"; +import { usePermissions } from "@/hooks/usePermissions"; +import { useApiStore, useLocaleStore } from "@/store/uiStore"; +import DataTable from "@/components/ui/Table"; +import { ModuleItem } from "@/types/api"; +import { t } from "@/lib/locale"; +import PageHeader from "@/components/ui/PageHeader"; +import ConfirmDialog from "@/components/ui/ConfirmDialog"; + +export default function ModulesPage() { + const locale = useLocaleStore((s) => s.locale); + const permissions = usePermissions(); + const addToast = useApiStore((s) => s.addToast); + const [modules, setModules] = useState([]); + const [pendingModule, setPendingModule] = useState(null); + const [confirmLoading, setConfirmLoading] = useState(false); + + useEffect(() => { + const loadModules = async () => { + if (!permissions.isAdmin) return; + try { + const rows = await getModules(); + setModules(rows); + } catch (error) { + addToast((error as { message?: string })?.message || t("loadFailed", locale), "error"); + } + }; + loadModules(); + }, [permissions.isAdmin, addToast, locale]); + + const onToggle = (row: ModuleItem) => { + setPendingModule(row); + }; + + const onConfirmToggle = async () => { + if (!pendingModule) return; + setConfirmLoading(true); + try { + await toggleModule(pendingModule.code, { enabled: !pendingModule.enabled }); + setModules((prev) => + prev.map((item) => + item.code === pendingModule.code ? { ...item, enabled: !pendingModule.enabled } : item + ) + ); + addToast(t("moduleToggled", locale), "success"); + } catch (error) { + addToast((error as { message?: string })?.message || t("actionFailed", locale), "error"); + } finally { + setPendingModule(null); + setConfirmLoading(false); + } + }; + + if (!permissions.isAdmin) { + return
{t("forbidden", locale)}
; + } + + return ( +
+ +
+
+ ( + + {row.enabled ? t("enabled", locale) : t("disabled", locale)} + + ) + }, + { + key: "actions", + header: t("actions", locale), + render: (row) => ( + + ) + } + ]} + data={modules} + /> +
+
+ + setPendingModule(null)} + confirmLabel={t("save", locale)} + cancelLabel="Cancel" + loading={confirmLoading} + /> +
+ ); +} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx new file mode 100644 index 0000000..15f817d --- /dev/null +++ b/app/(dashboard)/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getWorkflowRequests } from "@/services/workflow"; +import { getAudit } from "@/services/audit"; +import { useApiStore } from "@/store/uiStore"; +import { t } from "@/lib/locale"; +import { useLocaleStore } from "@/store/uiStore"; +import PageHeader from "@/components/ui/PageHeader"; +import Spinner from "@/components/ui/Spinner"; + +type DashboardSummary = { + pendingWorkflows: number; + totalAudits: number; + recentAuditCount: number; +}; + +export default function DashboardPage() { + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const addToast = useApiStore((s) => s.addToast); + const locale = useLocaleStore((s) => s.locale); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const [pending, audits] = await Promise.all([ + getWorkflowRequests({ status: "PENDING", limit: 200 }), + getAudit(50) + ]); + setSummary({ + pendingWorkflows: pending.length, + totalAudits: audits.length, + recentAuditCount: audits.slice(0, 5).length + }); + } catch (error) { + addToast("Failed to load dashboard summary", "error"); + } finally { + setLoading(false); + } + }; + load(); + }, [addToast]); + + return ( +
+ + {loading && } +
+
+
+
+

{t("pendingWorkflows", locale)}

+

{summary?.pendingWorkflows ?? "--"}

+
+
+
+
+
+
+

{t("auditSnapshots", locale)}

+

{summary?.totalAudits ?? "--"}

+
+
+
+
+
+
+

{t("recentAudits", locale)}

+

{summary?.recentAuditCount ?? "--"}

+
+
+
+
+
+ ); +} diff --git a/app/(dashboard)/roles/page.tsx b/app/(dashboard)/roles/page.tsx new file mode 100644 index 0000000..fbbf79f --- /dev/null +++ b/app/(dashboard)/roles/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { createRoleRequest, updateRolePermissionRequest } from "@/services/roles"; +import { getWorkflowRequests } from "@/services/workflow"; +import { useApiStore, useLocaleStore } from "@/store/uiStore"; +import { usePermissions } from "@/hooks/usePermissions"; +import { WorkflowRequestItem, WorkflowResourceType } from "@/types/api"; +import RoleCreateForm from "@/components/role/RoleCreateForm"; +import RolePermissionForm from "@/components/role/RolePermissionForm"; +import { t } from "@/lib/locale"; +import DataTable from "@/components/ui/Table"; +import StatusBadge from "@/components/workflow/StatusBadge"; +import PageHeader from "@/components/ui/PageHeader"; +import EmptyState from "@/components/ui/EmptyState"; + +const RESOURCE_TYPE = WorkflowResourceType.ROLE_MANAGEMENT; + +export default function RolesPage() { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(false); + const addToast = useApiStore((s) => s.addToast); + const locale = useLocaleStore((s) => s.locale); + const permissions = usePermissions(); + const permissionCatalog = useMemo( + () => + Array.from( + new Set([ + ...permissions.permissions, + "USER_MANAGE", + "WORKFLOW_APPROVE", + "ROLE_MANAGE", + "USER_ROLE_ADMIN" + ]) + ).sort(), + [permissions.permissions] + ); + + const load = async () => { + setLoading(true); + try { + const data = await getWorkflowRequests({ resourceType: RESOURCE_TYPE, limit: 100 }); + setRequests(data); + } catch (error) { + addToast((error as { message?: string })?.message || t("loadFailed", locale), "error"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const handleCreate = async (values: { code: string; name: string; permissionCodes: string[] }) => { + try { + await createRoleRequest(values); + addToast(t("roleCreateCreated", locale), "success"); + load(); + } catch (error) { + addToast((error as { message?: string })?.message || t("createFailed", locale), "error"); + } + }; + + const handleUpdate = async (values: { code: string; permissionCodes: string[] }) => { + try { + await updateRolePermissionRequest(values); + addToast(t("rolePermissionUpdated", locale), "success"); + load(); + } catch (error) { + addToast((error as { message?: string })?.message || t("updateFailed", locale), "error"); + } + }; + + return ( +
+ +
+
{t("createRoleRequest", locale)}
+
+ +
+
+ +
+
{t("updateRolePermissions", locale)}
+
+ +
+
+ +
+
{t("recentRoleRequests", locale)}
+
+ {requests.length === 0 && !loading && ( + + )} + + }, + { key: "createdAt", header: "Created" }, + { key: "payload", header: "Payload", render: (row) => {row.payload} } + ]} + data={requests} + /> +
+
+
+ ); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..b438d3c --- /dev/null +++ b/app/(dashboard)/settings/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import { logout } from "@/services/auth"; +import { useAuthStore } from "@/store/authStore"; +import { useLocaleStore } from "@/store/uiStore"; +import { useTenantStore } from "@/store/tenantStore"; +import { t } from "@/lib/locale"; +import PageHeader from "@/components/ui/PageHeader"; +import FileUpload from "@/components/ui/FileUpload"; + +export default function SettingsPage() { + const locale = useLocaleStore((s) => s.locale); + const setLocale = useLocaleStore((s) => s.setLocale); + const currentTenant = useTenantStore((s) => s.tenantId); + const clearAuth = useAuthStore((s) => s.clearAuth); + const [_, setUploadedFiles] = useState([]); + + const authMode = + process.env.NEXT_PUBLIC_AUTH_MODE?.toLowerCase?.() === "ldap" ? "LDAP" : "LOCAL"; + + const handleLogout = async () => { + await logout(); + clearAuth(); + window.location.replace("/login"); + }; + + const onUpload = (files: File[]) => { + setUploadedFiles(files.map((file) => file.name)); + }; + + return ( +
+ +
+
+
+ + +
+
+ {t("tenant", locale)}: {currentTenant} +
+
+ Auth Mode: {authMode} +
+
+ +
+ +
+
+
+ ); +} diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx new file mode 100644 index 0000000..4bfed31 --- /dev/null +++ b/app/(dashboard)/users/page.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { createUserRequest, updateUserRolesRequest } from "@/services/users"; +import { getWorkflowRequests } from "@/services/workflow"; +import { useApiStore } from "@/store/uiStore"; +import { useLocaleStore } from "@/store/uiStore"; +import { usePermissions } from "@/hooks/usePermissions"; +import UserCreateForm from "@/components/user/UserCreateForm"; +import UserRoleUpdateForm from "@/components/user/RoleUpdateForm"; +import DataTable from "@/components/ui/Table"; +import PageHeader from "@/components/ui/PageHeader"; +import EmptyState from "@/components/ui/EmptyState"; +import { WorkflowRequestItem, WorkflowResourceType } from "@/types/api"; +import { t } from "@/lib/locale"; +import StatusBadge from "@/components/workflow/StatusBadge"; + +const RESOURCE_TYPE = WorkflowResourceType.USER_MANAGEMENT; + +export default function UsersPage() { + const [requests, setRequests] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { canManageUsers, isAdminOrManager } = usePermissions(); + const addToast = useApiStore((s) => s.addToast); + const locale = useLocaleStore((s) => s.locale); + const authMode = + process.env.NEXT_PUBLIC_AUTH_MODE?.toLowerCase?.() === "ldap" ? "LDAP" : "LOCAL"; + + const loadRequests = async () => { + setIsLoading(true); + try { + const data = await getWorkflowRequests({ resourceType: RESOURCE_TYPE, limit: 100 }); + setRequests(data); + } catch (error: unknown) { + addToast((error as { message?: string })?.message || t("loadFailed", locale), "error"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadRequests(); + }, []); + + const rows = useMemo( + () => + requests.map((item) => ({ + ...item, + actionLabel: item.status === "PENDING" ? t("pending", locale) : t("done", locale) + })), + [requests, locale] + ); + + const onCreate = async (payload: { + username: string; + password?: string; + ldapDn?: string; + enabled?: boolean; + roleCodes: string[]; + }) => { + try { + await createUserRequest(payload); + addToast(t("userRequestCreated", locale), "success"); + loadRequests(); + } catch (error) { + addToast((error as { message?: string })?.message || t("createFailed", locale), "error"); + } + }; + + const onUpdateRoles = async (payload: { username: string; roleCodes: string[] }) => { + try { + await updateUserRolesRequest(payload); + addToast(t("userRoleRequestCreated", locale), "success"); + loadRequests(); + } catch (error) { + addToast((error as { message?: string })?.message || t("updateFailed", locale), "error"); + } + }; + + return ( +
+ + +
+
+
+
{t("createUserRequest", locale)}
+
+ +
+
+
+
+
+
{t("updateUserRolesRequest", locale)}
+
+ +
+
+
+
+ +
+
{t("recentRequests", locale)}
+
+ {requests.length === 0 && !isLoading && ( + + )} + + }, + { key: "createdAt", header: "Created" }, + { key: "updatedAt", header: "Updated" }, + { key: "actionLabel", header: "State" } + ]} + data={rows} + loading={isLoading} + noDataText={t("noUserRequests", locale)} + /> +
+
+
+ ); +} diff --git a/app/(dashboard)/workflow/page.tsx b/app/(dashboard)/workflow/page.tsx new file mode 100644 index 0000000..f7076da --- /dev/null +++ b/app/(dashboard)/workflow/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { + approveWorkflowRequest, + getWorkflowRequests, + rejectWorkflowRequest, + WorkflowFilters +} from "@/services/workflow"; +import { useApiStore } from "@/store/uiStore"; +import { useLocaleStore } from "@/store/uiStore"; +import { t } from "@/lib/locale"; +import DataTable from "@/components/ui/Table"; +import StatusBadge from "@/components/workflow/StatusBadge"; +import ApprovalActionModal from "@/components/workflow/ApprovalActionModal"; +import PageHeader from "@/components/ui/PageHeader"; +import EmptyState from "@/components/ui/EmptyState"; +import { WorkflowRequestItem, WorkflowStatus } from "@/types/api"; + +const DEFAULT_FILTERS: WorkflowFilters = { + status: "", + resourceType: "", + makerUsername: "", + limit: 50 +}; + +export default function WorkflowPage() { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(false); + const [approvalRequest, setApprovalRequest] = useState(null); + const [actionType, setActionType] = useState<"approve" | "reject">("approve"); + const [isOpen, setIsOpen] = useState(false); + const addToast = useApiStore((s) => s.addToast); + const locale = useLocaleStore((s) => s.locale); + + const filtered = useMemo( + () => + requests.filter((item) => { + const matchesStatus = !filters.status || item.status === filters.status; + const matchesType = !filters.resourceType || item.resourceType === filters.resourceType; + const matchesMaker = !filters.makerUsername || item.makerUsername.includes(filters.makerUsername); + return matchesStatus && matchesType && matchesMaker; + }), + [filters, requests] + ); + + const load = async () => { + setLoading(true); + try { + const response = await getWorkflowRequests(filters); + setRequests(response); + } catch (error) { + addToast((error as { message?: string })?.message || t("loadFailed", locale), "error"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, [filters]); + + const onSubmitAction = async (notes?: string, checkerRole?: string) => { + if (!approvalRequest) return; + try { + if (actionType === "approve") { + await approveWorkflowRequest(approvalRequest.id, { notes, checkerRole }); + addToast(t("requestApproved", locale), "success"); + } else { + await rejectWorkflowRequest(approvalRequest.id, { notes, checkerRole }); + addToast(t("requestRejected", locale), "success"); + } + setIsOpen(false); + load(); + } catch (error) { + addToast((error as { message?: string })?.message || t("actionFailed", locale), "error"); + } + }; + + const submitFilters = (e: FormEvent) => { + e.preventDefault(); + load(); + }; + + return ( +
+ +
+
+ + setFilters((prev) => ({ ...prev, resourceType: e.target.value }))} + /> + setFilters((prev) => ({ ...prev, makerUsername: e.target.value }))} + /> + setFilters((prev) => ({ ...prev, limit: Number(e.target.value) }))} + /> + +
+
+
+
+ {filtered.length === 0 && !loading && ( + + )} + + }, + { key: "currentStep", header: "Current Step" }, + { key: "requiredSteps", header: "Required Steps" }, + { key: "updatedAt", header: "Updated At" }, + { + key: "actions", + header: "Actions", + render: (row) => ( +
+ + +
+ ) + } + ]} + data={filtered} + /> +
+
+ + setIsOpen(false)} + title={actionType === "approve" ? t("approveRequest", locale) : t("rejectRequest", locale)} + /> +
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..8281230 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,67 @@ +@import "@tabler/core/dist/css/tabler.min.css"; + +:root { + --admin-accent: #1f4e79; +} + +body { + margin: 0; +} + +.app-shell { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; +} + +.app-sidebar { + background: #1f2937; + color: #fff; + min-height: 100vh; + padding: 1.25rem; +} + +.app-content { + padding: 1rem; + background: radial-gradient(circle at 10% 0%, #eef3ff 0%, #ffffff 60%, #f8fbff 100%); +} + +.toast-stack { + position: fixed; + right: 1rem; + top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 9999; +} + +.form-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); +} + +.truncate-cell { + max-width: 24ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-card { + background: rgba(255, 255, 255, 0.92); + border: 1px solid #e5e7eb; + border-radius: 0.75rem; +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + .app-sidebar { + min-height: auto; + margin-bottom: 1rem; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..8b9162a --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import "@/app/globals.css"; +import AppToasts from "@/components/layout/AppToasts"; + +export const metadata = { + title: "UTMS Admin", + description: "Production-ready admin dashboard for UTMS backend" +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..0119d78 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomeRedirect() { + redirect("/dashboard"); +} diff --git a/components/layout/AppToasts.tsx b/components/layout/AppToasts.tsx new file mode 100644 index 0000000..20df66c --- /dev/null +++ b/components/layout/AppToasts.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useApiStore } from "@/store/uiStore"; + +export default function AppToasts() { + const { toasts, removeToast } = useApiStore((s) => ({ + toasts: s.toasts, + removeToast: s.removeToast + })); + + return ( +
+ {toasts.map((toast) => ( +
+
+
{toast.message}
+ +
+
+ ))} +
+ ); +} + +function toastClass(type: "success" | "error" | "info") { + if (type === "success") return "bg-success text-white"; + if (type === "error") return "bg-danger text-white"; + return "bg-info text-white"; +} diff --git a/components/layout/DashboardShell.tsx b/components/layout/DashboardShell.tsx new file mode 100644 index 0000000..ea1d83c --- /dev/null +++ b/components/layout/DashboardShell.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { ReactNode } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { usePermissions } from "@/hooks/usePermissions"; +import { useAuthStore } from "@/store/authStore"; +import { useTenantStore } from "@/store/tenantStore"; +import { useLocaleStore } from "@/store/uiStore"; +import { logout } from "@/services/auth"; +import { t } from "@/lib/locale"; + +const navItems = [ + { href: "/dashboard", label: "Dashboard", key: "dashboard", permission: "ALL" as const }, + { href: "/dashboard/users", label: "Users", key: "users", permission: "USER_MANAGE_OR_ADMIN" as const }, + { href: "/dashboard/roles", label: "Roles", key: "roles", permission: "ROLE_MANAGE_OR_ADMIN" as const }, + { href: "/dashboard/workflow", label: "Workflow", key: "workflow", permission: "WORKFLOW_OR_ADMIN" as const }, + { href: "/dashboard/audit", label: "Audit", key: "audit", permission: "ADMIN_ONLY" as const }, + { href: "/dashboard/modules", label: "Modules", key: "modules", permission: "ADMIN_ONLY" as const }, + { href: "/dashboard/settings", label: "Settings", key: "settings", permission: "ALL" as const } +]; + +export default function DashboardShell({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const clearAuth = useAuthStore((s) => s.clearAuth); + const { username, tenantId } = useAuthStore((s) => ({ + username: s.profile?.username ?? "Unknown", + tenantId: s.tenantId + })); + const permissions = usePermissions(); + const locale = useLocaleStore((s) => s.locale); + const tenants = useTenantStore((s) => s.availableTenants); + const currentTenant = useTenantStore((s) => s.tenantId); + const setTenant = useTenantStore((s) => s.setTenantId); + const setLocale = useLocaleStore((s) => s.setLocale); + const authMode = + process.env.NEXT_PUBLIC_AUTH_MODE?.toLowerCase?.() === "ldap" ? "LDAP" : "Local"; + + const visibleNavItems = navItems.filter((item) => { + if (item.permission === "ALL") return true; + if (item.permission === "USER_MANAGE_OR_ADMIN") return permissions.canManageUsers || permissions.isAdminOrManager; + if (item.permission === "ROLE_MANAGE_OR_ADMIN") return permissions.canManageRoles || permissions.isAdminOrManager; + if (item.permission === "WORKFLOW_OR_ADMIN") return permissions.canApproveWorkflow || permissions.isAdminOrManager; + return permissions.isAdmin; + }); + + const onTenantChange = (value: string) => { + setTenant(value); + }; + + const handleLogout = async () => { + await logout(); + clearAuth(); + router.replace("/(auth)/login"); + }; + + return ( +
+ +
+
+
+
+ {t("welcome", locale)}, {username} + | Mode: {authMode} +
+
+ + + +
+
+
+
{children}
+
+
+ ); +} diff --git a/components/role/RoleCreateForm.tsx b/components/role/RoleCreateForm.tsx new file mode 100644 index 0000000..7dabaad --- /dev/null +++ b/components/role/RoleCreateForm.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import { useLocaleStore } from "@/store/uiStore"; +import { t } from "@/lib/locale"; + +export default function RoleCreateForm({ + disabled, + onSubmit +}: { + disabled: boolean; + onSubmit: (payload: { code: string; name: string; permissionCodes: string[] }) => Promise; +}) { + const locale = useLocaleStore((s) => s.locale); + const [code, setCode] = useState(""); + const [name, setName] = useState(""); + const [permissions, setPermissions] = useState(""); + const [loading, setLoading] = useState(false); + + const submit = async () => { + if (disabled || !code || !name) return; + setLoading(true); + try { + await onSubmit({ + code, + name, + permissionCodes: permissions + .split(",") + .map((it) => it.trim()) + .filter(Boolean) + }); + setCode(""); + setName(""); + setPermissions(""); + } finally { + setLoading(false); + } + }; + + return ( +
+ setCode(e.target.value)} + placeholder={t("code", locale)} + disabled={disabled} + /> + setName(e.target.value)} + placeholder={t("name", locale)} + disabled={disabled} + /> + setPermissions(e.target.value)} + placeholder="permission codes (comma separated)" + disabled={disabled} + /> + +
+ ); +} diff --git a/components/role/RolePermissionForm.tsx b/components/role/RolePermissionForm.tsx new file mode 100644 index 0000000..bfe406b --- /dev/null +++ b/components/role/RolePermissionForm.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import { useLocaleStore } from "@/store/uiStore"; +import { t } from "@/lib/locale"; + +export default function RolePermissionForm({ + disabled, + permissionCatalog, + onSubmit +}: { + disabled: boolean; + permissionCatalog: string[]; + onSubmit: (payload: { code: string; permissionCodes: string[] }) => Promise; +}) { + const locale = useLocaleStore((s) => s.locale); + const [code, setCode] = useState(""); + const [permissions, setPermissions] = useState(""); + const [loading, setLoading] = useState(false); + + const selected = permissions.split(",").map((i) => i.trim()).filter(Boolean); + const quickAdd = (value: string) => { + setPermissions((prev) => + prev + .split(",") + .map((v) => v.trim()) + .filter(Boolean) + .concat(selected.includes(value) ? [] : [value]) + .join(", ") + ); + }; + + const submit = async () => { + if (disabled || !code) return; + setLoading(true); + try { + await onSubmit({ code, permissionCodes: selected }); + setCode(""); + setPermissions(""); + } finally { + setLoading(false); + } + }; + + return ( +
+ setCode(e.target.value)} + placeholder={t("roleCode", locale)} + disabled={disabled} + /> + setPermissions(e.target.value)} + placeholder={t("permissions", locale)} + disabled={disabled} + /> +
+ {permissionCatalog.map((permission) => ( + + ))} +
+ +
+ ); +} diff --git a/components/ui/Alert.tsx b/components/ui/Alert.tsx new file mode 100644 index 0000000..d0cce81 --- /dev/null +++ b/components/ui/Alert.tsx @@ -0,0 +1,4 @@ +export default function Alert({ message, type = "info" }: { message: string; type?: "info" | "success" | "danger" }) { + const variant = type === "danger" ? "danger" : type === "success" ? "success" : "info"; + return
{message}
; +} diff --git a/components/ui/Badge.tsx b/components/ui/Badge.tsx new file mode 100644 index 0000000..6a16c6f --- /dev/null +++ b/components/ui/Badge.tsx @@ -0,0 +1,19 @@ +export default function Badge({ + variant, + children +}: { + variant: "success" | "danger" | "warning" | "secondary" | "info"; + children: string; +}) { + const cls = + variant === "success" + ? "bg-success" + : variant === "danger" + ? "bg-danger" + : variant === "warning" + ? "bg-warning" + : variant === "info" + ? "bg-info" + : "bg-secondary"; + return {children}; +} diff --git a/components/ui/Breadcrumb.tsx b/components/ui/Breadcrumb.tsx new file mode 100644 index 0000000..e916388 --- /dev/null +++ b/components/ui/Breadcrumb.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; + +export type BreadcrumbItem = { + label: string; + href?: string; +}; + +export default function Breadcrumb({ items }: { items: BreadcrumbItem[] }) { + if (!items.length) return null; + + return ( + + ); +} diff --git a/components/ui/Card.tsx b/components/ui/Card.tsx new file mode 100644 index 0000000..e65ed94 --- /dev/null +++ b/components/ui/Card.tsx @@ -0,0 +1,14 @@ +export default function Card({ + title, + children +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
{title}
+
{children}
+
+ ); +} diff --git a/components/ui/ConfirmDialog.tsx b/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..2bc6bf5 --- /dev/null +++ b/components/ui/ConfirmDialog.tsx @@ -0,0 +1,41 @@ +import Dialog from "./Dialog"; + +export default function ConfirmDialog({ + open, + title, + message, + onConfirm, + onCancel, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + loading +}: { + open: boolean; + title: string; + message: string; + onConfirm: () => void | Promise; + onCancel: () => void; + confirmLabel?: string; + cancelLabel?: string; + loading?: boolean; +}) { + return ( + + + + + } + > +

{message}

+
+ ); +} diff --git a/components/ui/DateRange.tsx b/components/ui/DateRange.tsx new file mode 100644 index 0000000..047c1a6 --- /dev/null +++ b/components/ui/DateRange.tsx @@ -0,0 +1,39 @@ +export type DateRangeValue = { + from?: string; + to?: string; +}; + +export default function DateRange({ + from, + to, + onFromChange, + onToChange +}: { + from?: string; + to?: string; + onFromChange: (value: string) => void; + onToChange: (value: string) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/components/ui/Dialog.tsx b/components/ui/Dialog.tsx new file mode 100644 index 0000000..f88d4f8 --- /dev/null +++ b/components/ui/Dialog.tsx @@ -0,0 +1,60 @@ +import { ReactNode, useEffect } from "react"; +import { createPortal } from "react-dom"; + +type DialogProps = { + open: boolean; + title: string; + description?: string; + children: ReactNode; + onClose: () => void; + footer?: ReactNode; + size?: "sm" | "lg" | "xl"; + loading?: boolean; +}; + +const sizeClass = { + sm: "modal-dialog modal-sm", + lg: "modal-dialog modal-lg", + xl: "modal-dialog modal-xl" +}; + +export default function Dialog({ + open, + title, + description, + children, + onClose, + footer, + size = "sm", + loading +}: DialogProps) { + useEffect(() => { + if (!open) return; + const onEsc = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onEsc); + return () => window.removeEventListener("keydown", onEsc); + }, [open, onClose]); + + if (!open) return null; + + return createPortal( +
+
+
+
+
{title}
+
+
+ {description &&

{description}

} + {loading ?
Loading...
: children} +
+ {footer &&
{footer}
} +
+
+
, + document.body + ); +} diff --git a/components/ui/Drawer.tsx b/components/ui/Drawer.tsx new file mode 100644 index 0000000..4812375 --- /dev/null +++ b/components/ui/Drawer.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from "react"; +import { createPortal } from "react-dom"; + +export default function Drawer({ + open, + title, + children, + onClose +}: { + open: boolean; + title: string; + children: ReactNode; + onClose: () => void; +}) { + if (!open) return null; + + return createPortal( +
+
+
+
+
{title}
+
+
+
{children}
+
+
+
+
, + document.body + ); +} diff --git a/components/ui/EmptyState.tsx b/components/ui/EmptyState.tsx new file mode 100644 index 0000000..4c83349 --- /dev/null +++ b/components/ui/EmptyState.tsx @@ -0,0 +1,18 @@ +export default function EmptyState({ + title, + description, + cta +}: { + title: string; + description?: string; + cta?: React.ReactNode; +}) { + return ( +
+
🎯
+

{title}

+ {description &&

{description}

} + {cta} +
+ ); +} diff --git a/components/ui/FileUpload.tsx b/components/ui/FileUpload.tsx new file mode 100644 index 0000000..3693956 --- /dev/null +++ b/components/ui/FileUpload.tsx @@ -0,0 +1,49 @@ +import { ChangeEvent, useRef } from "react"; + +export type FileUploadProps = { + label: string; + accept?: string; + multiple?: boolean; + maxBytes?: number; + onFileSelect: (files: File[]) => void; + disabled?: boolean; + helperText?: string; + resetToken?: number; +}; + +export default function FileUpload({ + label, + accept, + multiple, + maxBytes, + onFileSelect, + disabled, + helperText, + resetToken +}: FileUploadProps) { + const inputRef = useRef(null); + + const onChange = (event: ChangeEvent) => { + const targetFiles = Array.from(event.target.files ?? []); + const files = maxBytes ? targetFiles.filter((file) => file.size <= maxBytes) : targetFiles; + onFileSelect(files); + event.target.value = ""; + }; + + return ( + + ); +} diff --git a/components/ui/FormField.tsx b/components/ui/FormField.tsx new file mode 100644 index 0000000..b890728 --- /dev/null +++ b/components/ui/FormField.tsx @@ -0,0 +1,23 @@ +import { ReactNode } from "react"; + +type Option = { label: string; value: string }; + +type Props = { + label: string; + children?: ReactNode; + type?: "text" | "password" | "checkbox" | "textarea" | "select"; + value?: string | boolean; + onValueChange?: (value: string) => void; + options?: Option[]; + placeholder?: string; + rows?: number; +}; + +export default function FormField({ label, children, type = "text", ...props }: Props) { + return ( + + ); +} diff --git a/components/ui/Modal.tsx b/components/ui/Modal.tsx new file mode 100644 index 0000000..f6ca5c4 --- /dev/null +++ b/components/ui/Modal.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from "react"; + +type ModalProps = { + isOpen: boolean; + title: string; + children: ReactNode; + onClose: () => void; +}; + +export default function Modal({ isOpen, title, children, onClose }: ModalProps) { + if (!isOpen) return null; + return ( +
+
+
+
+
{title}
+
+
{children}
+
+
+
+ ); +} diff --git a/components/ui/PageHeader.tsx b/components/ui/PageHeader.tsx new file mode 100644 index 0000000..0053586 --- /dev/null +++ b/components/ui/PageHeader.tsx @@ -0,0 +1,27 @@ +import Breadcrumb, { BreadcrumbItem } from "./Breadcrumb"; +import { ReactNode } from "react"; + +export default function PageHeader({ + title, + description, + actions, + breadcrumb +}: { + title: string; + description?: string; + actions?: ReactNode; + breadcrumb?: BreadcrumbItem[]; +}) { + return ( +
+ {breadcrumb && } +
+
+

{title}

+ {description &&

{description}

} +
+ {actions} +
+
+ ); +} diff --git a/components/ui/Pagination.tsx b/components/ui/Pagination.tsx new file mode 100644 index 0000000..2ea051f --- /dev/null +++ b/components/ui/Pagination.tsx @@ -0,0 +1,29 @@ +type PaginationProps = { + page: number; + pageSize: number; + total: number; + onChange: (page: number) => void; +}; + +export default function Pagination({ page, pageSize, total, onChange }: PaginationProps) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (totalPages <= 1) return null; + + return ( +
+ + + Page {page} / {totalPages} + + +
+ ); +} diff --git a/components/ui/Select.tsx b/components/ui/Select.tsx new file mode 100644 index 0000000..aa92d68 --- /dev/null +++ b/components/ui/Select.tsx @@ -0,0 +1,38 @@ +import { ChangeEvent } from "react"; + +export type SelectOption = { + value: string; + label: string; +}; + +export default function Select({ + label, + options, + value, + onChange, + disabled +}: { + label: string; + options: SelectOption[]; + value: string; + onChange: (value: string) => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/components/ui/Spinner.tsx b/components/ui/Spinner.tsx new file mode 100644 index 0000000..c6ee578 --- /dev/null +++ b/components/ui/Spinner.tsx @@ -0,0 +1,8 @@ +export default function Spinner({ label = "Loading..." }: { label?: string }) { + return ( +
+
+ {label} +
+ ); +} diff --git a/components/ui/Table.tsx b/components/ui/Table.tsx new file mode 100644 index 0000000..08a2688 --- /dev/null +++ b/components/ui/Table.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from "react"; + +export type TableColumn> = { + key: keyof T | "actions" | string; + header: string; + render?: (row: T) => ReactNode; +}; + +type TableProps> = { + columns: TableColumn[]; + data: T[]; + loading?: boolean; + noDataText?: string; +}; + +export default function DataTable>({ + columns, + data, + loading, + noDataText = "No data" +}: TableProps) { + return ( +
+ {loading && ( +
Loading ...
+ )} + {!loading && data.length === 0 &&
{noDataText}
} + {!loading && data.length > 0 && ( + + + + {columns.map((col) => ( + + ))} + + + + {data.map((row, idx) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
{col.header}
+ {col.render ? col.render(row) : String((row as Record)[col.key as keyof T] ?? "-")} +
+ )} +
+ ); +} diff --git a/components/ui/Tabs.tsx b/components/ui/Tabs.tsx new file mode 100644 index 0000000..89205d8 --- /dev/null +++ b/components/ui/Tabs.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from "react"; + +type TabItem = { + key: string; + title: string; + content: ReactNode; +}; + +type TabsProps = { + items: TabItem[]; + active: string; + onChange: (key: string) => void; +}; + +export default function Tabs({ items, active, onChange }: TabsProps) { + return ( +
+
    + {items.map((item) => ( +
  • + +
  • + ))} +
+
+ {items.find((item) => item.key === active)?.content} +
+
+ ); +} diff --git a/components/user/RoleUpdateForm.tsx b/components/user/RoleUpdateForm.tsx new file mode 100644 index 0000000..6f454f7 --- /dev/null +++ b/components/user/RoleUpdateForm.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import { useLocaleStore } from "@/store/uiStore"; +import { t } from "@/lib/locale"; + +export default function UserRoleUpdateForm({ + disabled, + onSubmit +}: { + disabled: boolean; + onSubmit: (payload: { username: string; roleCodes: string[] }) => Promise; +}) { + const locale = useLocaleStore((s) => s.locale); + const [username, setUsername] = useState(""); + const [roles, setRoles] = useState(""); + const [loading, setLoading] = useState(false); + + const submit = async () => { + if (disabled || !username) return; + setLoading(true); + try { + await onSubmit({ + username, + roleCodes: roles + .split(",") + .map((role) => role.trim()) + .filter(Boolean) + }); + setUsername(""); + setRoles(""); + } finally { + setLoading(false); + } + }; + + return ( +
+ setUsername(e.target.value)} + placeholder={t("username", locale)} + disabled={disabled} + /> + setRoles(e.target.value)} + placeholder="Role codes (comma separated)" + disabled={disabled} + /> + +
+ ); +} diff --git a/components/user/UserCreateForm.tsx b/components/user/UserCreateForm.tsx new file mode 100644 index 0000000..fc910c8 --- /dev/null +++ b/components/user/UserCreateForm.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { useLocaleStore } from "@/store/uiStore"; +import { t } from "@/lib/locale"; + +type UserCreateFormValues = { + username: string; + password?: string; + ldapDn?: string; + enabled?: boolean; + roleCodes: string[]; +}; + +export default function UserCreateForm({ + disabled, + onSubmit, + authMode +}: { + disabled: boolean; + authMode: string; + onSubmit: (payload: UserCreateFormValues) => Promise; +}) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [ldapDn, setLdapDn] = useState(""); + const [roleCodesInput, setRoleCodesInput] = useState(""); + const [enabled, setEnabled] = useState(true); + const [loading, setLoading] = useState(false); + const locale = useLocaleStore((s) => s.locale); + const isLdap = authMode.toUpperCase() === "LDAP"; + + const submit = async () => { + if (disabled || !username || (!isLdap && !password)) return; + setLoading(true); + try { + await onSubmit({ + username: username.trim(), + password: isLdap ? undefined : password.trim(), + ldapDn: isLdap && ldapDn.trim() ? ldapDn.trim() : undefined, + enabled, + roleCodes: roleCodesInput + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + }); + setUsername(""); + setPassword(""); + setLdapDn(""); + setRoleCodesInput(""); + } finally { + setLoading(false); + } + }; + + return ( +
+ setUsername(e.target.value)} + placeholder={t("username", locale)} + disabled={disabled} + /> + {!isLdap && ( + setPassword(e.target.value)} + placeholder={t("password", locale)} + disabled={disabled} + /> + )} + {isLdap && ( + setLdapDn(e.target.value)} + placeholder="ldapDn" + disabled={disabled} + /> + )} + setRoleCodesInput(e.target.value)} + placeholder="roleCodes (comma separated)" + disabled={disabled} + /> + + +
+ ); +} diff --git a/components/workflow/ApprovalActionModal.tsx b/components/workflow/ApprovalActionModal.tsx new file mode 100644 index 0000000..8be3412 --- /dev/null +++ b/components/workflow/ApprovalActionModal.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import Modal from "../ui/Modal"; + +type ApprovalActionModalProps = { + isOpen: boolean; + mode: "approve" | "reject"; + title: string; + onSubmit: (notes?: string, checkerRole?: string) => Promise; + onClose: () => void; +}; + +export default function ApprovalActionModal({ + isOpen, + mode, + title, + onSubmit, + onClose +}: ApprovalActionModalProps) { + const [notes, setNotes] = useState(""); + const [checkerRole, setCheckerRole] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + await onSubmit(notes, checkerRole || undefined); + } finally { + setLoading(false); + } + }; + + return ( + +
+