ignore folder
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal 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
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
"node_modules/"
|
||||||
1
.next/dev/cache/.rscinfo
vendored
Normal file
1
.next/dev/cache/.rscinfo
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"encryption.key":"f2nGpaARRR6dtwAzL0EpxpoT3r4WsgTylRhv8FaEF5A=","encryption.expire_at":1777936177168}
|
||||||
BIN
.next/dev/cache/turbopack/23c46498/CURRENT
vendored
Normal file
BIN
.next/dev/cache/turbopack/23c46498/CURRENT
vendored
Normal file
Binary file not shown.
0
.next/dev/logs/next-development.log
Normal file
0
.next/dev/logs/next-development.log
Normal file
3
.next/dev/package.json
Normal file
3
.next/dev/package.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
36
SETUP.md
Normal file
36
SETUP.md
Normal 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
111
app/(auth)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/(dashboard)/audit/page.tsx
Normal file
85
app/(dashboard)/audit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/(dashboard)/layout.tsx
Normal file
32
app/(dashboard)/layout.tsx
Normal 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>;
|
||||||
|
}
|
||||||
112
app/(dashboard)/modules/page.tsx
Normal file
112
app/(dashboard)/modules/page.tsx
Normal 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
84
app/(dashboard)/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
app/(dashboard)/roles/page.tsx
Normal file
135
app/(dashboard)/roles/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
app/(dashboard)/settings/page.tsx
Normal file
75
app/(dashboard)/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
app/(dashboard)/users/page.tsx
Normal file
145
app/(dashboard)/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
app/(dashboard)/workflow/page.tsx
Normal file
197
app/(dashboard)/workflow/page.tsx
Normal 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
67
app/globals.css
Normal 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
19
app/layout.tsx
Normal 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
5
app/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function HomeRedirect() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
35
components/layout/AppToasts.tsx
Normal file
35
components/layout/AppToasts.tsx
Normal 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";
|
||||||
|
}
|
||||||
111
components/layout/DashboardShell.tsx
Normal file
111
components/layout/DashboardShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
components/role/RoleCreateForm.tsx
Normal file
68
components/role/RoleCreateForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
components/role/RolePermissionForm.tsx
Normal file
79
components/role/RolePermissionForm.tsx
Normal 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
4
components/ui/Alert.tsx
Normal 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
19
components/ui/Badge.tsx
Normal 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>;
|
||||||
|
}
|
||||||
31
components/ui/Breadcrumb.tsx
Normal file
31
components/ui/Breadcrumb.tsx
Normal 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
14
components/ui/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/ui/ConfirmDialog.tsx
Normal file
41
components/ui/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/ui/DateRange.tsx
Normal file
39
components/ui/DateRange.tsx
Normal 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
60
components/ui/Dialog.tsx
Normal 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
33
components/ui/Drawer.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/ui/EmptyState.tsx
Normal file
18
components/ui/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
components/ui/FileUpload.tsx
Normal file
49
components/ui/FileUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
components/ui/FormField.tsx
Normal file
23
components/ui/FormField.tsx
Normal 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
25
components/ui/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/ui/PageHeader.tsx
Normal file
27
components/ui/PageHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
components/ui/Pagination.tsx
Normal file
29
components/ui/Pagination.tsx
Normal 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
38
components/ui/Select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
components/ui/Spinner.tsx
Normal file
8
components/ui/Spinner.tsx
Normal 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
52
components/ui/Table.tsx
Normal 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
35
components/ui/Tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/user/RoleUpdateForm.tsx
Normal file
58
components/user/RoleUpdateForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
components/user/UserCreateForm.tsx
Normal file
105
components/user/UserCreateForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
components/workflow/ApprovalActionModal.tsx
Normal file
62
components/workflow/ApprovalActionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
components/workflow/ApprovalTable.tsx
Normal file
36
components/workflow/ApprovalTable.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
12
components/workflow/StatusBadge.tsx
Normal file
12
components/workflow/StatusBadge.tsx
Normal 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
11
hooks/useApi.ts
Normal 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
5
hooks/useAuth.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useAuthStore((state) => state);
|
||||||
|
}
|
||||||
23
hooks/usePermissions.ts
Normal file
23
hooks/usePermissions.ts
Normal 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
6
hooks/useTenantHeader.ts
Normal 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
138
lib/locale.ts
Normal 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
8
lib/permissions.ts
Normal 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
5
next-env.d.ts
vendored
Normal 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
9
next.config.mjs
Normal 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
897
package-lock.json
generated
Normal 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
27
package.json
Normal 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
59
services/api.ts
Normal 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
10
services/audit.ts
Normal 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
41
services/auth.ts
Normal 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
15
services/modules.ts
Normal 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
24
services/roles.ts
Normal 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
8
services/tenant.ts
Normal 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
22
services/users.ts
Normal 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
43
services/workflow.ts
Normal 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
87
store/authStore.ts
Normal 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
16
store/permissionStore.ts
Normal 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
31
store/tenantStore.ts
Normal 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
38
store/uiStore.ts
Normal 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
23
tsconfig.json
Normal 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
101
types/api.ts
Normal 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 };
|
||||||
Reference in New Issue
Block a user