ignore folder
This commit is contained in:
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");
|
||||
}
|
||||
Reference in New Issue
Block a user