ignore folder

This commit is contained in:
2026-04-21 06:30:48 +07:00
commit ca00b36f19
70 changed files with 3871 additions and 0 deletions

5
.env.example Normal file
View File

@ -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

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
"node_modules/"

1
.next/dev/cache/.rscinfo vendored Normal file
View File

@ -0,0 +1 @@
{"encryption.key":"f2nGpaARRR6dtwAzL0EpxpoT3r4WsgTylRhv8FaEF5A=","encryption.expire_at":1777936177168}

Binary file not shown.

View File

3
.next/dev/package.json Normal file
View File

@ -0,0 +1,3 @@
{
"type": "commonjs"
}

36
SETUP.md Normal file
View File

@ -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.

111
app/(auth)/login/page.tsx Normal file
View File

@ -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 (
<main className="min-vh-100 d-flex align-items-center justify-content-center">
<section className="card p-4 w-100" style={{ maxWidth: 420 }}>
<div className="mb-3 text-center">
<h1 className="h3">UTMS Admin</h1>
<p className="text-muted">Sign in with tenant context</p>
</div>
<form className="vstack gap-3" onSubmit={onSubmit}>
<select
className="form-select"
value={selectedTenant}
onChange={(e) => setSelectedTenant(e.target.value)}
>
<option value="">Select tenant</option>
{availableTenants.map((tenant) => (
<option key={tenant} value={tenant}>
{tenant}
</option>
))}
</select>
<input
className="form-control"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className="form-control"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button className="btn btn-primary" type="submit" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
</section>
</main>
);
}

View File

@ -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<AuditItem[]>([]);
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 (
<div className="alert alert-warning">
{t("forbidden", locale)}
</div>
);
}
return (
<main className="vstack gap-3">
<PageHeader
title={t("audit", locale)}
breadcrumb={[
{ label: t("dashboard", locale), href: "/dashboard" },
{ label: t("audit", locale), href: "/dashboard/audit" }
]}
/>
{loading && <Spinner label={t("loading", locale)} />}
{!loading && items.length === 0 ? (
<EmptyState
title={t("noData", locale)}
description={t("noData", locale)}
/>
) : (
<div className="card page-card">
<div className="card-body">
<DataTable
columns={[
{ key: "id", header: "Id" },
{ key: "actor", header: "Actor" },
{ key: "action", header: "Action" },
{ key: "resourceType", header: "Resource Type" },
{ key: "resourceId", header: "Resource Id" },
{ key: "outcome", header: "Outcome" },
{ key: "correlationId", header: "Correlation" },
{ key: "createdAt", header: "Created" }
]}
data={items}
/>
</div>
</div>
)}
</main>
);
}

View File

@ -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 <DashboardShell>{children}</DashboardShell>;
}

View File

@ -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<ModuleItem[]>([]);
const [pendingModule, setPendingModule] = useState<ModuleItem | null>(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 <div className="alert alert-warning">{t("forbidden", locale)}</div>;
}
return (
<main className="vstack gap-3">
<PageHeader
title={t("modules", locale)}
breadcrumb={[
{ label: t("dashboard", locale), href: "/dashboard" },
{ label: t("modules", locale), href: "/dashboard/modules" }
]}
/>
<div className="card page-card">
<div className="card-body">
<DataTable
columns={[
{ key: "code", header: "Code" },
{ key: "name", header: "Name" },
{
key: "enabled",
header: "Enabled",
render: (row) => (
<span className="badge bg-azure">
{row.enabled ? t("enabled", locale) : t("disabled", locale)}
</span>
)
},
{
key: "actions",
header: t("actions", locale),
render: (row) => (
<button className="btn btn-outline-primary btn-sm" onClick={() => onToggle(row)}>
{row.enabled ? t("disable", locale) : t("enable", locale)}
</button>
)
}
]}
data={modules}
/>
</div>
</div>
<ConfirmDialog
open={!!pendingModule}
title={pendingModule ? `${pendingModule.enabled ? t("disable", locale) : t("enable", locale)} ${pendingModule.code}` : ""}
message={t("moduleToggled", locale)}
onConfirm={onConfirmToggle}
onCancel={() => setPendingModule(null)}
confirmLabel={t("save", locale)}
cancelLabel="Cancel"
loading={confirmLoading}
/>
</main>
);
}

84
app/(dashboard)/page.tsx Normal file
View File

@ -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<DashboardSummary | null>(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 (
<main className="vstack gap-3">
<PageHeader
title={t("dashboard", locale)}
description={t("checkerWorkload", locale)}
breadcrumb={[
{ label: t("dashboard", locale), href: "/dashboard" }
]}
/>
{loading && <Spinner label={t("loading", locale)} />}
<div className="row g-3">
<section className="col-12 col-md-4">
<div className="card page-card">
<div className="card-body">
<h3 className="card-title h5">{t("pendingWorkflows", locale)}</h3>
<p className="display-6 fw-bold">{summary?.pendingWorkflows ?? "--"}</p>
</div>
</div>
</section>
<section className="col-12 col-md-4">
<div className="card page-card">
<div className="card-body">
<h3 className="card-title h5">{t("auditSnapshots", locale)}</h3>
<p className="display-6 fw-bold">{summary?.totalAudits ?? "--"}</p>
</div>
</div>
</section>
<section className="col-12 col-md-4">
<div className="card page-card">
<div className="card-body">
<h3 className="card-title h5">{t("recentAudits", locale)}</h3>
<p className="display-6 fw-bold">{summary?.recentAuditCount ?? "--"}</p>
</div>
</div>
</section>
</div>
</main>
);
}

View File

@ -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<WorkflowRequestItem[]>([]);
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 (
<main className="vstack gap-4">
<PageHeader
title={t("roles", locale)}
breadcrumb={[
{ label: t("dashboard", locale), href: "/dashboard" },
{ label: t("roles", locale), href: "/dashboard/roles" }
]}
/>
<section className="card page-card">
<div className="card-header fw-bold">{t("createRoleRequest", locale)}</div>
<div className="card-body">
<RoleCreateForm
disabled={!(permissions.canManageRoles || permissions.isAdminOrManager)}
onSubmit={handleCreate}
/>
</div>
</section>
<section className="card page-card">
<div className="card-header fw-bold">{t("updateRolePermissions", locale)}</div>
<div className="card-body">
<RolePermissionForm
disabled={!(permissions.canManageRoles || permissions.isAdminOrManager)}
permissionCatalog={permissionCatalog}
onSubmit={handleUpdate}
/>
</div>
</section>
<section className="card page-card">
<div className="card-header fw-bold">{t("recentRoleRequests", locale)}</div>
<div className="card-body">
{requests.length === 0 && !loading && (
<EmptyState
title={t("noRoleRequests", locale)}
description={t("noData", locale)}
/>
)}
<DataTable
loading={loading}
noDataText={t("noRoleRequests", locale)}
columns={[
{ key: "id", header: "Request Id" },
{ key: "resourceId", header: "Role Code" },
{ key: "makerUsername", header: "Maker" },
{
key: "status",
header: "Status",
render: (row) => <StatusBadge status={row.status} />
},
{ key: "createdAt", header: "Created" },
{ key: "payload", header: "Payload", render: (row) => <code>{row.payload}</code> }
]}
data={requests}
/>
</div>
</section>
</main>
);
}

View File

@ -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<string[]>([]);
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 (
<main className="vstack gap-3">
<PageHeader
title={t("settings", locale)}
breadcrumb={[
{ label: t("dashboard", locale), href: "/dashboard" },
{ label: t("settings", locale), href: "/dashboard/settings" }
]}
/>
<section className="card page-card">
<div className="card-body vstack gap-3">
<div>
<label className="form-label">Locale</label>
<select
className="form-select"
value={locale}
onChange={(e) => setLocale(e.target.value as "en" | "id")}
>
<option value="en">en-US</option>
<option value="id">id-ID</option>
</select>
</div>
<div>
<strong>{t("tenant", locale)}:</strong> {currentTenant}
</div>
<div>
<strong>Auth Mode:</strong> {authMode}
</div>
<div>
<FileUpload
label="Upload attachment (optional)"
accept=".txt,.csv,.json"
onFileSelect={onUpload}
helperText="Optional local file preview upload for admin workflows."
/>
</div>
<button className="btn btn-outline-danger w-auto" onClick={handleLogout}>
{t("logout", locale)}
</button>
</div>
</section>
</main>
);
}

View File

@ -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<WorkflowRequestItem[]>([]);
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 (
<main className="vstack gap-4">
<PageHeader
title={t("users", locale)}
description={`${t("ldapModeIndicator", locale)}: ${authMode}`}
breadcrumb={[
{ label: t("dashboard", locale), href: "/dashboard" },
{ label: t("users", locale), href: "/dashboard/users" }
]}
/>
<section className="row g-4">
<div className="col-12 col-lg-6">
<div className="card page-card">
<div className="card-header fw-bold">{t("createUserRequest", locale)}</div>
<div className="card-body">
<UserCreateForm
disabled={!(canManageUsers || isAdminOrManager)}
onSubmit={onCreate}
authMode={authMode}
/>
</div>
</div>
</div>
<div className="col-12 col-lg-6">
<div className="card page-card">
<div className="card-header fw-bold">{t("updateUserRolesRequest", locale)}</div>
<div className="card-body">
<UserRoleUpdateForm disabled={!(canManageUsers || isAdminOrManager)} onSubmit={onUpdateRoles} />
</div>
</div>
</div>
</section>
<section className="card page-card">
<div className="card-header fw-bold">{t("recentRequests", locale)}</div>
<div className="card-body">
{requests.length === 0 && !isLoading && (
<EmptyState
title={t("noUserRequests", locale)}
description={t("noData", locale)}
/>
)}
<DataTable
columns={[
{ key: "id", header: "Request ID" },
{ key: "resourceId", header: "Username" },
{ key: "makerUsername", header: "Maker" },
{
key: "status",
header: "Status",
render: (item) => <StatusBadge status={item.status} />
},
{ key: "createdAt", header: "Created" },
{ key: "updatedAt", header: "Updated" },
{ key: "actionLabel", header: "State" }
]}
data={rows}
loading={isLoading}
noDataText={t("noUserRequests", locale)}
/>
</div>
</section>
</main>
);
}

View File

@ -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<WorkflowFilters>(DEFAULT_FILTERS);
const [requests, setRequests] = useState<WorkflowRequestItem[]>([]);
const [loading, setLoading] = useState(false);
const [approvalRequest, setApprovalRequest] = useState<WorkflowRequestItem | null>(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 (
<main className="vstack gap-3">
<PageHeader
title={t("workflow", locale)}
breadcrumb={[
{ label: t("dashboard", locale), href: "/dashboard" },
{ label: t("workflow", locale), href: "/dashboard/workflow" }
]}
/>
<form className="card page-card card-body" onSubmit={submitFilters}>
<div className="form-grid">
<select
className="form-select"
value={filters.status ?? ""}
onChange={(e) => setFilters((prev) => ({ ...prev, status: e.target.value as WorkflowStatus | "" }))}
>
<option value="">{t("allStatuses", locale)}</option>
<option value="DRAFT">DRAFT</option>
<option value="PENDING">PENDING</option>
<option value="APPROVED">APPROVED</option>
<option value="REJECTED">REJECTED</option>
</select>
<input
className="form-control"
placeholder="Resource type"
value={filters.resourceType ?? ""}
onChange={(e) => setFilters((prev) => ({ ...prev, resourceType: e.target.value }))}
/>
<input
className="form-control"
placeholder="Maker username"
value={filters.makerUsername ?? ""}
onChange={(e) => setFilters((prev) => ({ ...prev, makerUsername: e.target.value }))}
/>
<input
className="form-control"
type="number"
min={1}
max={200}
value={filters.limit ?? 50}
onChange={(e) => setFilters((prev) => ({ ...prev, limit: Number(e.target.value) }))}
/>
<button className="btn btn-outline-primary">Apply</button>
</div>
</form>
<section className="card page-card">
<div className="card-body">
{filtered.length === 0 && !loading && (
<EmptyState
title={t("noData", locale)}
description={t("noData", locale)}
/>
)}
<DataTable
loading={loading}
columns={[
{ key: "id", header: "Request Id" },
{ key: "resourceType", header: "Resource Type" },
{ key: "resourceId", header: "Resource Id" },
{ key: "makerUsername", header: "Maker" },
{
key: "status",
header: "Status",
render: (row) => <StatusBadge status={row.status} />
},
{ key: "currentStep", header: "Current Step" },
{ key: "requiredSteps", header: "Required Steps" },
{ key: "updatedAt", header: "Updated At" },
{
key: "actions",
header: "Actions",
render: (row) => (
<div className="d-flex gap-2">
<button
className="btn btn-success btn-sm"
onClick={() => {
setApprovalRequest(row);
setActionType("approve");
setIsOpen(true);
}}
>
{t("approve", locale)}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => {
setApprovalRequest(row);
setActionType("reject");
setIsOpen(true);
}}
>
{t("reject", locale)}
</button>
</div>
)
}
]}
data={filtered}
/>
</div>
</section>
<ApprovalActionModal
isOpen={isOpen}
mode={actionType}
onSubmit={onSubmitAction}
onClose={() => setIsOpen(false)}
title={actionType === "approve" ? t("approveRequest", locale) : t("rejectRequest", locale)}
/>
</main>
);
}

67
app/globals.css Normal file
View File

@ -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;
}
}

19
app/layout.tsx Normal file
View File

@ -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 (
<html lang="en">
<body>
{children}
<AppToasts />
</body>
</html>
);
}

5
app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomeRedirect() {
redirect("/dashboard");
}

View File

@ -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 (
<div className="toast-stack">
{toasts.map((toast) => (
<div key={toast.id} className={`toast show align-items-center border-0 ${toastClass(toast.type)}`}>
<div className="d-flex">
<div className="toast-body text-white">{toast.message}</div>
<button
className="btn btn-sm btn-link text-white"
onClick={() => removeToast(toast.id)}
aria-label="dismiss"
>
×
</button>
</div>
</div>
))}
</div>
);
}
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";
}

View File

@ -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 (
<div className="app-shell">
<aside className="app-sidebar">
<h2 className="text-white mb-4">UTMS Admin</h2>
<p className="small text-white-50 mb-4">
{t("tenant", locale)}: {tenantId || currentTenant}
</p>
<nav className="vstack gap-1">
{visibleNavItems.map((item) => (
<Link
key={item.key}
href={item.href}
className={`text-decoration-none ${pathname === item.href ? "text-primary fw-bold" : "text-white"}`}
>
{item.label}
</Link>
))}
</nav>
</aside>
<main className="app-content w-100">
<header className="card page-card p-3 mb-3">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div className="d-flex align-items-center gap-2">
<span className="text-muted">{t("welcome", locale)}, {username}</span>
<span className="text-muted d-none d-sm-inline">| Mode: {authMode}</span>
</div>
<div className="d-flex gap-2">
<select className="form-select" value={currentTenant ?? ""} onChange={(e) => onTenantChange(e.target.value)}>
<option value="">Tenant</option>
{tenants.map((tenant) => (
<option key={tenant} value={tenant}>
{tenant}
</option>
))}
</select>
<select
className="form-select"
value={locale}
onChange={(e) => setLocale(e.target.value as "en" | "id")}
>
<option value="en">en-US</option>
<option value="id">id-ID</option>
</select>
<button className="btn btn-outline-danger" onClick={handleLogout}>
{t("logout", locale)}
</button>
</div>
</div>
</header>
<section className="pb-3">{children}</section>
</main>
</div>
);
}

View File

@ -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<void>;
}) {
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 (
<div className="vstack gap-2">
<input
className="form-control"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("code", locale)}
disabled={disabled}
/>
<input
className="form-control"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name", locale)}
disabled={disabled}
/>
<input
className="form-control"
value={permissions}
onChange={(e) => setPermissions(e.target.value)}
placeholder="permission codes (comma separated)"
disabled={disabled}
/>
<button className="btn btn-primary" disabled={disabled || loading} onClick={submit} type="button">
{loading ? t("submitting", locale) : t("create", locale)}
</button>
</div>
);
}

View File

@ -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<void>;
}) {
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 (
<div className="vstack gap-2">
<input
className="form-control"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("roleCode", locale)}
disabled={disabled}
/>
<input
className="form-control"
value={permissions}
onChange={(e) => setPermissions(e.target.value)}
placeholder={t("permissions", locale)}
disabled={disabled}
/>
<div className="d-flex flex-wrap gap-2">
{permissionCatalog.map((permission) => (
<button
key={permission}
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => quickAdd(permission)}
disabled={disabled}
>
{permission}
</button>
))}
</div>
<button className="btn btn-outline-primary" disabled={disabled || loading} onClick={submit} type="button">
{loading ? t("submitting", locale) : t("save", locale)}
</button>
</div>
);
}

4
components/ui/Alert.tsx Normal file
View File

@ -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 <div className={`alert alert-${variant}`}>{message}</div>;
}

19
components/ui/Badge.tsx Normal file
View File

@ -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 <span className={`badge ${cls}`}>{children}</span>;
}

View File

@ -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 (
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
{items.map((item, index) => (
<li
key={item.label + index}
className={`breadcrumb-item ${index === items.length - 1 ? "active" : ""}`}
>
{index === items.length - 1 || !item.href ? (
<span>{item.label}</span>
) : (
<Link href={item.href} className="text-decoration-none">
{item.label}
</Link>
)}
</li>
))}
</ol>
</nav>
);
}

14
components/ui/Card.tsx Normal file
View File

@ -0,0 +1,14 @@
export default function Card({
title,
children
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="card page-card">
<div className="card-header fw-bold">{title}</div>
<div className="card-body">{children}</div>
</div>
);
}

View File

@ -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<void>;
onCancel: () => void;
confirmLabel?: string;
cancelLabel?: string;
loading?: boolean;
}) {
return (
<Dialog
open={open}
title={title}
onClose={onCancel}
footer={
<div className="d-flex justify-content-end gap-2">
<button className="btn btn-outline-secondary" onClick={onCancel} disabled={loading}>
{cancelLabel}
</button>
<button className="btn btn-danger" onClick={onConfirm} disabled={loading}>
{loading ? "Working..." : confirmLabel}
</button>
</div>
}
>
<p>{message}</p>
</Dialog>
);
}

View File

@ -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 (
<div className="d-flex gap-2">
<label className="flex-fill">
<span className="form-label">From</span>
<input
type="datetime-local"
className="form-control"
value={from ?? ""}
onChange={(event) => onFromChange(event.target.value)}
/>
</label>
<label className="flex-fill">
<span className="form-label">To</span>
<input
type="datetime-local"
className="form-control"
value={to ?? ""}
onChange={(event) => onToChange(event.target.value)}
/>
</label>
</div>
);
}

60
components/ui/Dialog.tsx Normal file
View File

@ -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(
<div className="modal d-block" style={{ background: "rgba(0,0,0,.4)" }}>
<div className={sizeClass[size]} style={{ marginTop: "6rem" }}>
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body">
{description && <p className="text-muted mb-3">{description}</p>}
{loading ? <div className="text-center text-muted">Loading...</div> : children}
</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
</div>,
document.body
);
}

33
components/ui/Drawer.tsx Normal file
View File

@ -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(
<div className="modal d-block" style={{ background: "rgba(0,0,0,.35)" }} role="dialog">
<div className="modal-dialog modal-dialog-end">
<div className="modal-content h-100">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body p-0">
<div className="p-3">{children}</div>
</div>
</div>
</div>
</div>,
document.body
);
}

View File

@ -0,0 +1,18 @@
export default function EmptyState({
title,
description,
cta
}: {
title: string;
description?: string;
cta?: React.ReactNode;
}) {
return (
<div className="text-center py-5">
<div className="display-6 text-muted mb-2">🎯</div>
<h4>{title}</h4>
{description && <p className="text-muted">{description}</p>}
{cta}
</div>
);
}

View File

@ -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<HTMLInputElement | null>(null);
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
const targetFiles = Array.from(event.target.files ?? []);
const files = maxBytes ? targetFiles.filter((file) => file.size <= maxBytes) : targetFiles;
onFileSelect(files);
event.target.value = "";
};
return (
<label className="d-block">
<span className="form-label">{label}</span>
<input
key={resetToken}
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="form-control"
onChange={onChange}
/>
{helperText && <small className="text-muted">{helperText}</small>}
</label>
);
}

View File

@ -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 (
<label className="form-label">
{label}
{children}
</label>
);
}

25
components/ui/Modal.tsx Normal file
View File

@ -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 (
<div className="modal d-block" style={{ background: "rgba(0,0,0,0.4)" }} role="dialog">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body">{children}</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="mb-3">
{breadcrumb && <Breadcrumb items={breadcrumb} />}
<div className="d-flex justify-content-between align-items-center">
<div>
<h2 className="mb-1">{title}</h2>
{description && <p className="text-muted mb-0">{description}</p>}
</div>
{actions}
</div>
</div>
);
}

View File

@ -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 (
<div className="d-flex justify-content-center gap-1">
<button className="btn btn-outline-secondary btn-sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
Prev
</button>
<span className="px-2 d-flex align-items-center">
Page {page} / {totalPages}
</span>
<button
className="btn btn-outline-secondary btn-sm"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
>
Next
</button>
</div>
);
}

38
components/ui/Select.tsx Normal file
View File

@ -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 (
<label className="form-label">
{label}
<select
className="form-select"
value={value}
onChange={(event: ChangeEvent<HTMLSelectElement>) => onChange(event.target.value)}
disabled={disabled}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}

View File

@ -0,0 +1,8 @@
export default function Spinner({ label = "Loading..." }: { label?: string }) {
return (
<div className="d-flex align-items-center gap-2">
<div className="spinner-border spinner-border-sm text-primary" role="status" />
<span className="text-muted">{label}</span>
</div>
);
}

52
components/ui/Table.tsx Normal file
View File

@ -0,0 +1,52 @@
import { ReactNode } from "react";
export type TableColumn<T extends Record<string, unknown>> = {
key: keyof T | "actions" | string;
header: string;
render?: (row: T) => ReactNode;
};
type TableProps<T extends Record<string, unknown>> = {
columns: TableColumn<T>[];
data: T[];
loading?: boolean;
noDataText?: string;
};
export default function DataTable<T extends Record<string, unknown>>({
columns,
data,
loading,
noDataText = "No data"
}: TableProps<T>) {
return (
<div className="table-responsive">
{loading && (
<div className="py-4 text-center text-muted">Loading ...</div>
)}
{!loading && data.length === 0 && <div className="py-4 text-center text-muted">{noDataText}</div>}
{!loading && data.length > 0 && (
<table className="table table-striped table-hover">
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, idx) => (
<tr key={String((row as { id?: string })?.id ?? idx)}>
{columns.map((col) => (
<td key={String(col.key)} className="align-middle">
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key as keyof T] ?? "-")}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

35
components/ui/Tabs.tsx Normal file
View File

@ -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 (
<div>
<ul className="nav nav-tabs mb-3">
{items.map((item) => (
<li key={item.key} className="nav-item">
<button
className={`nav-link ${item.key === active ? "active" : ""}`}
onClick={() => onChange(item.key)}
>
{item.title}
</button>
</li>
))}
</ul>
<div>
{items.find((item) => item.key === active)?.content}
</div>
</div>
);
}

View File

@ -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<void>;
}) {
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 (
<div className="vstack gap-2">
<input
className="form-control"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("username", locale)}
disabled={disabled}
/>
<input
className="form-control"
value={roles}
onChange={(e) => setRoles(e.target.value)}
placeholder="Role codes (comma separated)"
disabled={disabled}
/>
<button className="btn btn-outline-primary" onClick={submit} disabled={disabled || loading} type="button">
{loading ? t("submitting", locale) : t("update", locale)}
</button>
</div>
);
}

View File

@ -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<void>;
}) {
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 (
<div className="vstack gap-2">
<input
className="form-control"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("username", locale)}
disabled={disabled}
/>
{!isLdap && (
<input
className="form-control"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("password", locale)}
disabled={disabled}
/>
)}
{isLdap && (
<input
className="form-control"
value={ldapDn}
onChange={(e) => setLdapDn(e.target.value)}
placeholder="ldapDn"
disabled={disabled}
/>
)}
<input
className="form-control"
value={roleCodesInput}
onChange={(e) => setRoleCodesInput(e.target.value)}
placeholder="roleCodes (comma separated)"
disabled={disabled}
/>
<label className="d-flex align-items-center gap-2">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
disabled={disabled}
/>
{t("enabled", locale)}
</label>
<button className="btn btn-primary" type="button" onClick={submit} disabled={disabled || loading}>
{loading ? t("submitting", locale) : t("create", locale)}
</button>
</div>
);
}

View File

@ -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<void>;
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 (
<Modal isOpen={isOpen} title={title} onClose={onClose}>
<form className="vstack gap-3" onSubmit={handleSubmit}>
<textarea
className="form-control"
rows={4}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes"
/>
<input
className="form-control"
value={checkerRole}
onChange={(e) => setCheckerRole(e.target.value)}
placeholder="Optional checker role (defaults to CHECKER)"
/>
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button className="btn btn-primary" disabled={loading} type="submit">
{loading ? "Saving..." : mode === "approve" ? "Approve" : "Reject"}
</button>
</div>
</form>
</Modal>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import DataTable, { TableColumn } from "../ui/Table";
import { WorkflowRequestItem } from "@/types/api";
import StatusBadge from "./StatusBadge";
type ApprovalTableProps = {
data: WorkflowRequestItem[];
onApprove: (request: WorkflowRequestItem) => void;
onReject: (request: WorkflowRequestItem) => void;
};
export default function ApprovalTable({ data, onApprove, onReject }: ApprovalTableProps) {
const columns: TableColumn<WorkflowRequestItem>[] = [
{ key: "resourceType", header: "Resource Type" },
{ key: "resourceId", header: "Resource Id" },
{ key: "makerUsername", header: "Maker" },
{ key: "status", header: "Status", render: (row) => <StatusBadge status={row.status} /> },
{
key: "actions",
header: "Actions",
render: (row) => (
<div className="d-flex gap-2">
<button className="btn btn-success btn-sm" onClick={() => onApprove(row)}>
Approve
</button>
<button className="btn btn-danger btn-sm" onClick={() => onReject(row)}>
Reject
</button>
</div>
)
}
];
return <DataTable columns={columns} data={data} />;
}

View File

@ -0,0 +1,12 @@
import Badge from "../ui/Badge";
import { WorkflowStatus } from "@/types/api";
export default function StatusBadge({ status }: { status: WorkflowStatus }) {
const map = {
PENDING: "warning",
APPROVED: "success",
REJECTED: "danger",
DRAFT: "info"
} as const;
return <Badge variant={map[status]}>{status}</Badge>;
}

11
hooks/useApi.ts Normal file
View File

@ -0,0 +1,11 @@
import { AxiosError } from "axios";
export async function useApi<T>(promise: Promise<{ data: T }>): Promise<T> {
try {
const response = await promise;
return response.data;
} catch (error) {
const message = (error as AxiosError<{ message?: string }>).response?.data?.message ?? "Unexpected error";
throw new Error(message);
}
}

5
hooks/useAuth.ts Normal file
View File

@ -0,0 +1,5 @@
import { useAuthStore } from "@/store/authStore";
export function useAuth() {
return useAuthStore((state) => state);
}

23
hooks/usePermissions.ts Normal file
View File

@ -0,0 +1,23 @@
import { usePermissionStore } from "@/store/permissionStore";
import { useAuthStore } from "@/store/authStore";
export function usePermissions() {
const roles = usePermissionStore((s) => s.roles);
const permissions = usePermissionStore((s) => s.permissions);
const isAuthenticated = useAuthStore((s) => !!s.accessToken);
const hasPermission = (permission: string) => permissions.includes(permission);
const hasRole = (role: string) => roles.includes(role);
return {
roles,
permissions,
isAuthenticated,
isAdmin: hasRole("ADMIN"),
isAdminOrManager: hasRole("ADMIN") || hasRole("USER_ROLE_ADMIN"),
canManageUsers: hasPermission("USER_MANAGE") || hasRole("USER_ROLE_ADMIN"),
canManageRoles: hasPermission("ROLE_MANAGE") || hasRole("USER_ROLE_ADMIN"),
canApproveWorkflow: hasPermission("WORKFLOW_APPROVE") || hasRole("CHECKER"),
canReadProfile: hasPermission("USER_READ") || hasRole("ADMIN")
};
}

6
hooks/useTenantHeader.ts Normal file
View File

@ -0,0 +1,6 @@
import { useTenantStore } from "@/store/tenantStore";
export function useTenantHeader() {
const tenantId = useTenantStore((s) => s.tenantId);
return tenantId;
}

138
lib/locale.ts Normal file
View File

@ -0,0 +1,138 @@
const translations = {
en: {
dashboard: "Dashboard",
checkerWorkload: "Pending checker workload",
pendingWorkflows: "Pending workflow count",
auditSnapshots: "Audit/approval snapshot",
recentAudits: "Recent audits",
users: "Users",
ldapModeIndicator: "Auth mode",
createUserRequest: "Create user request",
updateUserRolesRequest: "Update user roles request",
recentRequests: "Recent user requests",
noUserRequests: "No user requests",
roles: "Roles",
createRoleRequest: "Create role request",
updateRolePermissions: "Update role permissions",
recentRoleRequests: "Recent role requests",
noRoleRequests: "No role requests",
workflow: "Workflow",
audit: "Audit",
modules: "Modules",
settings: "Settings",
forbidden: "You are not authorized",
actions: "Actions",
enabled: "Enabled",
disabled: "Disabled",
enable: "Enable",
disable: "Disable",
loading: "Loading",
noData: "No data",
username: "Username",
password: "Password",
tenant: "Tenant",
create: "Create",
update: "Update",
save: "Save",
code: "Code",
name: "Name",
roleCode: "Role code",
permissions: "Permissions",
enabledLabel: "Enabled",
submitting: "Submitting",
pending: "Pending",
done: "Done",
allStatuses: "All statuses",
approve: "Approve",
reject: "Reject",
approveRequest: "Approve request",
rejectRequest: "Reject request",
requestApproved: "Request approved",
requestRejected: "Request rejected",
actionFailed: "Action failed",
loadFailed: "Failed to load data",
createFailed: "Create failed",
userRequestCreated: "User request created",
userRoleRequestCreated: "User role update request created",
roleCreateCreated: "Role request created",
rolePermissionUpdated: "Role permission request created",
updateFailed: "Update failed",
moduleToggled: "Module toggled",
logout: "Logout",
welcome: "Welcome",
loginSuccess: "Login successful",
unknownError: "Something went wrong",
unknown: "Unknown"
},
id: {
dashboard: "Dasbor",
checkerWorkload: "Beban pengecekan tertunda",
pendingWorkflows: "Permintaan alur kerja tertunda",
auditSnapshots: "Ringkasan audit/perizinan",
recentAudits: "Audit terbaru",
users: "Pengguna",
ldapModeIndicator: "Mode autentikasi",
createUserRequest: "Buat permintaan pengguna",
updateUserRolesRequest: "Perbarui peran pengguna",
recentRequests: "Permintaan terbaru pengguna",
noUserRequests: "Belum ada permintaan pengguna",
roles: "Peran",
createRoleRequest: "Buat permintaan peran",
updateRolePermissions: "Perbarui hak akses peran",
recentRoleRequests: "Permintaan peran terbaru",
noRoleRequests: "Belum ada permintaan peran",
workflow: "Alur kerja",
audit: "Audit",
modules: "Modul",
settings: "Pengaturan",
forbidden: "Tidak berwenang",
actions: "Aksi",
enabled: "Aktif",
disabled: "Nonaktif",
enable: "Aktifkan",
disable: "Nonaktifkan",
loading: "Memuat",
noData: "Tidak ada data",
username: "Nama pengguna",
password: "Kata sandi",
tenant: "Tenant",
create: "Buat",
update: "Perbarui",
save: "Simpan",
code: "Kode",
name: "Nama",
roleCode: "Kode peran",
permissions: "Izin",
enabledLabel: "Aktif",
submitting: "Mengirim",
pending: "Menunggu",
done: "Selesai",
allStatuses: "Semua status",
approve: "Setujui",
reject: "Tolak",
approveRequest: "Setujui permintaan",
rejectRequest: "Tolak permintaan",
requestApproved: "Permintaan disetujui",
requestRejected: "Permintaan ditolak",
actionFailed: "Aksi gagal",
loadFailed: "Gagal memuat data",
createFailed: "Gagal membuat",
userRequestCreated: "Permintaan pembuatan pengguna dibuat",
userRoleRequestCreated: "Permintaan update peran pengguna dibuat",
roleCreateCreated: "Permintaan peran dibuat",
rolePermissionUpdated: "Permintaan hak akses peran dibuat",
updateFailed: "Update gagal",
moduleToggled: "Status modul berubah",
logout: "Keluar",
welcome: "Selamat datang",
loginSuccess: "Login berhasil",
unknownError: "Terjadi kesalahan",
unknown: "Tidak diketahui"
}
} as const;
export type Locale = keyof typeof translations;
export function t(key: keyof (typeof translations)["en"], locale: keyof typeof translations = "en") {
return translations[locale][key] ?? key;
}

8
lib/permissions.ts Normal file
View File

@ -0,0 +1,8 @@
export const PERMISSIONS = {
USER_MANAGE: "USER_MANAGE",
ROLE_MANAGE: "ROLE_MANAGE",
WORKFLOW_APPROVE: "WORKFLOW_APPROVE",
USER_READ: "USER_READ"
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file is automatically generated and used by Next.js

9
next.config.mjs Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
typedRoutes: false
}
}
export default nextConfig

897
package-lock.json generated Normal file
View File

@ -0,0 +1,897 @@
{
"name": "utms-ng-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "utms-ng-frontend",
"version": "0.1.0",
"dependencies": {
"@tabler/core": "^1.0.0-beta19",
"@tabler/icons-react": "^3.28.0",
"axios": "^1.7.7",
"next": "^14.2.5",
"react": "18.3.1",
"react-dom": "18.3.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/node": "^20.14.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.1",
"typescript": "^5.5.4"
}
},
"node_modules/@next/env": {
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
"integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
"integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
"integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
"integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
"integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
"integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
"integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
"integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
"integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.33",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
"integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
}
},
"node_modules/@tabler/core": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.4.0.tgz",
"integrity": "sha512-5BigzOlbOH9N0Is4u0rYNRCiwtnUXWO57K9zwuscygcicAa8UV9MGaS4zTgQsZEtZ9tsNANhN/YD8gCBGKYCiw==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "5.3.7"
},
"engines": {
"node": ">=20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons": {
"version": "3.41.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz",
"integrity": "sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.41.1",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.41.1.tgz",
"integrity": "sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.41.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
"integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/bootstrap": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
"integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001788",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
"integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "14.2.35",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz",
"integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.35",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.33",
"@next/swc-darwin-x64": "14.2.33",
"@next/swc-linux-arm64-gnu": "14.2.33",
"@next/swc-linux-arm64-musl": "14.2.33",
"@next/swc-linux-x64-gnu": "14.2.33",
"@next/swc-linux-x64-musl": "14.2.33",
"@next/swc-win32-arm64-msvc": "14.2.33",
"@next/swc-win32-ia32-msvc": "14.2.33",
"@next/swc-win32-x64-msvc": "14.2.33"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "utms-ng-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"axios": "^1.7.7",
"next": "^14.2.5",
"react": "18.3.1",
"react-dom": "18.3.1",
"zustand": "^5.0.3",
"@tabler/icons-react": "^3.28.0",
"@tabler/core": "^1.0.0-beta19"
},
"devDependencies": {
"typescript": "^5.5.4",
"@types/node": "^20.14.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.1"
}
}

59
services/api.ts Normal file
View File

@ -0,0 +1,59 @@
import axios from "axios";
import { useAuthStore } from "@/store/authStore";
import { useTenantStore } from "@/store/tenantStore";
import { useLocaleStore } from "@/store/uiStore";
import { useApiStore } from "@/store/uiStore";
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:9191",
timeout: 25000
});
apiClient.interceptors.request.use((config) => {
const auth = useAuthStore.getState();
const tenantId = useTenantStore.getState().tenantId || auth.tenantId;
const locale = useLocaleStore.getState().locale;
if (auth.accessToken) {
config.headers.Authorization = `${auth.tokenType ?? "Bearer"} ${auth.accessToken}`;
}
if (tenantId) {
config.headers["X-Tenant-Id"] = tenantId;
}
if (locale) {
config.headers["Accept-Language"] = locale === "id" ? "id-ID" : "en-US";
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status;
const message = error?.response?.data?.message || error.message;
const ui = useApiStore.getState();
if (status === 401) {
ui.addToast(message || "Unauthorized", "error");
useAuthStore.getState().clearAuth();
if (typeof window !== "undefined") {
window.location.href = "/(auth)/login";
}
}
if (status === 403) {
ui.addToast(message || "Forbidden", "error");
}
return Promise.reject(new Error(message ?? "Request failed"));
}
);
export async function unwrap<T>(promise: ReturnType<typeof apiClient.request>): Promise<T> {
const response = await promise;
const payload = response.data;
if (payload && typeof payload.success === "boolean" && payload.success === false) {
throw new Error(payload.message ?? "Business error");
}
return payload.data as T;
}

10
services/audit.ts Normal file
View File

@ -0,0 +1,10 @@
import { ApiResponse, AuditItem } from "@/types/api";
import { apiClient } from "./api";
export async function getAudit(limit = 50): Promise<AuditItem[]> {
const response = await apiClient.get<ApiResponse<AuditItem[]>>("/api/audit", {
params: { limit }
});
if (!response.data.success) throw new Error(response.data.message);
return response.data.data;
}

41
services/auth.ts Normal file
View File

@ -0,0 +1,41 @@
import { ApiResponse, LoginRequest, LoginResponseData, UserProfile, TenantContextResponse } from "@/types/api";
import { apiClient, unwrap } from "./api";
export async function login(request: LoginRequest, tenantId: string): Promise<LoginResponseData> {
const response = await apiClient.post<ApiResponse<LoginResponseData>>(
"/api/auth/login",
request,
{
headers: { "X-Tenant-Id": tenantId }
}
);
const payload = response.data;
if (!payload.success) {
throw new Error(payload.message);
}
return payload.data;
}
export async function refreshToken(refreshToken: string) {
const response = await apiClient.post<ApiResponse<LoginResponseData>>("/api/auth/refresh", {
refreshToken
});
const payload = response.data;
if (!payload.success) throw new Error(payload.message);
return payload.data;
}
export async function logout() {
await unwrap<null>(apiClient.post<ApiResponse<null>>("/api/auth/logout"));
}
export async function getCurrentUser(): Promise<ApiResponse<UserProfile>> {
const response = await apiClient.get<ApiResponse<UserProfile>>("/api/users/me");
return response.data;
}
export async function getTenantContext(): Promise<TenantContextResponse> {
const response = await apiClient.get<ApiResponse<TenantContextResponse>>("/api/tenant/context");
if (!response.data.success) throw new Error(response.data.message);
return response.data.data;
}

15
services/modules.ts Normal file
View File

@ -0,0 +1,15 @@
import { ApiResponse, ModuleItem, ToggleModuleRequest } from "@/types/api";
import { apiClient } from "./api";
export async function getModules(): Promise<ModuleItem[]> {
const response = await apiClient.get<ApiResponse<ModuleItem[]>>("/api/modules");
if (!response.data.success) throw new Error(response.data.message);
return response.data.data;
}
export async function toggleModule(code: string, payload: ToggleModuleRequest): Promise<ModuleItem> {
const response = await apiClient.post<ApiResponse<ModuleItem>>(`/api/modules/${code}/toggle`, payload);
const data = response.data;
if (!data.success) throw new Error(data.message);
return data.data;
}

24
services/roles.ts Normal file
View File

@ -0,0 +1,24 @@
import { ApiResponse, RoleCreateRequest, UpdateRolePermissionRequest, WorkflowRequestItem } from "@/types/api";
import { apiClient } from "./api";
export async function createRoleRequest(payload: RoleCreateRequest): Promise<WorkflowRequestItem> {
const response = await apiClient.post<ApiResponse<WorkflowRequestItem>>(
"/api/roles/management/requests/create",
payload
);
const data = response.data;
if (!data.success) throw new Error(data.message);
return data.data;
}
export async function updateRolePermissionRequest(
payload: UpdateRolePermissionRequest
): Promise<WorkflowRequestItem> {
const response = await apiClient.post<ApiResponse<WorkflowRequestItem>>(
"/api/roles/management/requests/update-permissions",
payload
);
const data = response.data;
if (!data.success) throw new Error(data.message);
return data.data;
}

8
services/tenant.ts Normal file
View File

@ -0,0 +1,8 @@
import { ApiResponse, TenantContextResponse } from "@/types/api";
import { apiClient } from "./api";
export async function getTenantContext(): Promise<TenantContextResponse> {
const response = await apiClient.get<ApiResponse<TenantContextResponse>>("/api/tenant/context");
if (!response.data.success) throw new Error(response.data.message);
return response.data.data;
}

22
services/users.ts Normal file
View File

@ -0,0 +1,22 @@
import { ApiResponse, UpdateUserRolesRequest, UserCreateRequest, WorkflowRequestItem, WorkflowCreateRequest } from "@/types/api";
import { apiClient, unwrap } from "./api";
export async function createUserRequest(payload: UserCreateRequest): Promise<WorkflowRequestItem> {
const response = await apiClient.post<ApiResponse<WorkflowRequestItem>>(
"/api/users/management/requests/create",
payload
);
const data = response.data;
if (!data.success) throw new Error(data.message);
return data.data;
}
export async function updateUserRolesRequest(payload: UpdateUserRolesRequest): Promise<WorkflowRequestItem> {
const response = await apiClient.post<ApiResponse<WorkflowRequestItem>>(
"/api/users/management/requests/update-roles",
payload
);
const data = response.data;
if (!data.success) throw new Error(data.message);
return data.data;
}

43
services/workflow.ts Normal file
View File

@ -0,0 +1,43 @@
import {
ApiResponse,
WorkflowActionPayload,
WorkflowCreateRequest,
WorkflowFilters,
WorkflowRequestItem
} from "@/types/api";
import { apiClient } from "./api";
export async function getWorkflowRequests(filters: WorkflowFilters = {}): Promise<WorkflowRequestItem[]> {
const response = await apiClient.get<ApiResponse<WorkflowRequestItem[]>>("/api/workflow/requests", {
params: filters
});
if (!response.data.success) throw new Error(response.data.message);
return response.data.data;
}
export async function createWorkflowRequest(payload: WorkflowCreateRequest): Promise<WorkflowRequestItem> {
const response = await apiClient.post<ApiResponse<WorkflowRequestItem>>("/api/workflow/request", payload);
const data = response.data;
if (!data.success) throw new Error(data.message);
return data.data;
}
export async function approveWorkflowRequest(id: string, payload: WorkflowActionPayload): Promise<WorkflowRequestItem> {
const response = await apiClient.post<ApiResponse<WorkflowRequestItem>>(
`/api/workflow/${id}/approve`,
payload
);
const data = response.data;
if (!data.success) throw new Error(data.message);
return data.data;
}
export async function rejectWorkflowRequest(id: string, payload: WorkflowActionPayload): Promise<WorkflowRequestItem> {
const response = await apiClient.post<ApiResponse<WorkflowRequestItem>>(
`/api/workflow/${id}/reject`,
payload
);
const data = response.data;
if (!data.success) throw new Error(data.message);
return data.data;
}

87
store/authStore.ts Normal file
View File

@ -0,0 +1,87 @@
import { create } from "zustand";
import { UserProfile } from "@/types/api";
import { usePermissionStore } from "./permissionStore";
export type AuthState = {
accessToken?: string;
refreshToken?: string;
tokenType?: string;
expiresInSeconds?: number;
tenantId?: string;
profile?: UserProfile;
};
type AuthAction = {
setAuthFromLogin: (payload: {
tokenType: string;
accessToken: string;
refreshToken: string;
expiresInSeconds: number;
}) => void;
setProfile: (profile: UserProfile) => void;
setTenantId: (tenantId: string) => void;
clearAuth: () => void;
hydrate: () => void;
};
const STORAGE_KEY = "utms-ng-auth";
export const useAuthStore = create<AuthState & AuthAction>((set, get) => ({
accessToken: undefined,
refreshToken: undefined,
tokenType: undefined,
expiresInSeconds: undefined,
tenantId: process.env.NEXT_PUBLIC_DEFAULT_TENANT,
profile: undefined,
setAuthFromLogin: ({ tokenType, accessToken, refreshToken, expiresInSeconds }) => {
const tenantId = get().tenantId || process.env.NEXT_PUBLIC_DEFAULT_TENANT || "acme";
const next = {
tokenType,
accessToken,
refreshToken,
expiresInSeconds,
tenantId
};
set(next);
persist(next);
},
setProfile: (profile) => {
set({ profile, tenantId: profile.tenantId });
usePermissionStore.getState().setProfile(profile.roles, profile.permissions);
const next = { ...get(), profile };
persist(next);
},
setTenantId: (tenantId) => {
set({ tenantId });
const next = { ...get(), tenantId };
persist(next);
},
clearAuth: () => {
set({
accessToken: undefined,
refreshToken: undefined,
tokenType: undefined,
expiresInSeconds: undefined,
profile: undefined
});
usePermissionStore.getState().setProfile([], []);
if (typeof window !== "undefined") localStorage.removeItem(STORAGE_KEY);
},
hydrate: () => {
if (typeof window === "undefined") return;
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as AuthState;
if (parsed) set(parsed);
usePermissionStore.getState().setProfile(parsed?.profile?.roles ?? [], parsed?.profile?.permissions ?? []);
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
}));
function persist(state: AuthState) {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}

16
store/permissionStore.ts Normal file
View File

@ -0,0 +1,16 @@
import { create } from "zustand";
type PermissionState = {
roles: string[];
permissions: string[];
};
type PermissionActions = {
setProfile: (roles: string[], permissions: string[]) => void;
};
export const usePermissionStore = create<PermissionState & PermissionActions>((set) => ({
roles: [],
permissions: [],
setProfile: (roles, permissions) => set({ roles, permissions })
}));

31
store/tenantStore.ts Normal file
View File

@ -0,0 +1,31 @@
import { create } from "zustand";
type TenantState = {
tenantId: string;
availableTenants: string[];
};
type TenantAction = {
setTenantId: (tenantId: string) => void;
hydrate: () => void;
};
const STORAGE_KEY = "utms-ng-tenant";
const fallbackTenants = ["acme", "global", "demo"];
export const useTenantStore = create<TenantState & TenantAction>((set, get) => ({
tenantId: process.env.NEXT_PUBLIC_DEFAULT_TENANT || "acme",
availableTenants: Array.from(
new Set([process.env.NEXT_PUBLIC_DEFAULT_TENANT || "acme", ...fallbackTenants])
),
setTenantId: (tenantId) => {
set({ tenantId });
if (typeof window !== "undefined") localStorage.setItem(STORAGE_KEY, tenantId);
},
hydrate: () => {
if (typeof window === "undefined") return;
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) set({ tenantId: raw });
else set({ tenantId: get().tenantId });
}
}));

38
store/uiStore.ts Normal file
View File

@ -0,0 +1,38 @@
import { create } from "zustand";
export type ToastType = "success" | "error" | "info";
export type ToastItem = {
id: number;
message: string;
type: ToastType;
};
type UiState = {
locale: "en" | "id";
toasts: ToastItem[];
};
type UiAction = {
setLocale: (locale: "en" | "id") => void;
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: number) => void;
clearToasts: () => void;
};
let toastCounter = 1;
export const useApiStore = create<UiState & UiAction>((set) => ({
locale: "en",
toasts: [],
setLocale: (locale) => set({ locale }),
addToast: (message, type = "info") =>
set((state) => ({
toasts: [...state.toasts, { id: toastCounter++, message, type }]
})),
removeToast: (id) => set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })),
clearToasts: () => set({ toasts: [] })
}));
export const useLocaleStore = <T>(selector: (s: UiState & UiAction) => T): T =>
useApiStore(selector);

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

101
types/api.ts Normal file
View File

@ -0,0 +1,101 @@
export type ApiResponse<T> = {
success: boolean;
message: string;
data: T;
timestamp: string;
};
export type LoginRequest = { username: string; password: string };
export type LoginResponseData = {
tokenType: string;
accessToken: string;
refreshToken: string;
expiresInSeconds: number;
};
export type UserProfile = {
tenantId: string;
username: string;
roles: string[];
permissions: string[];
};
export type TenantContextResponse = { tenantId: string };
export type UserCreateRequest = {
username: string;
password?: string;
ldapDn?: string;
enabled?: boolean;
roleCodes: string[];
};
export type UpdateUserRolesRequest = { username: string; roleCodes: string[] };
export type RoleCreateRequest = { code: string; name: string; permissionCodes: string[] };
export type UpdateRolePermissionRequest = { code: string; permissionCodes: string[] };
export type WorkflowStatus = "DRAFT" | "PENDING" | "APPROVED" | "REJECTED";
export enum WorkflowResourceType {
USER_MANAGEMENT = "USER_MANAGEMENT",
ROLE_MANAGEMENT = "ROLE_MANAGEMENT",
OTHER = "OTHER"
}
export type WorkflowRequestItem = {
id: string;
tenantId: string;
resourceType: string;
resourceId: string;
makerUsername: string;
payload: string;
status: WorkflowStatus;
requiredSteps: number;
currentStep: number;
createdAt: string;
updatedAt: string;
};
export type WorkflowFilters = {
status?: WorkflowStatus | "";
resourceType?: string;
makerUsername?: string;
limit?: number;
};
export type WorkflowActionPayload = { notes?: string; checkerRole?: string };
export type ApprovalResponse = {
id: string;
status: WorkflowStatus;
requiredSteps: number;
currentStep: number;
};
export type WorkflowCreateRequest = {
resourceType: string;
resourceId: string;
payload?: string;
requiredSteps: number;
};
export type AuditItem = {
id: string;
tenantId: string;
actor: string;
correlationId: string;
action: string;
domain: string;
resourceType: string;
resourceId: string;
outcome: string;
httpMethod: string;
requestPath: string;
beforeState?: string;
afterState?: string;
details?: string;
createdAt: string;
};
export type ModuleItem = { code: string; name: string; enabled: boolean };
export type ToggleModuleRequest = { enabled: boolean };