ignore folder

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

View File

@ -0,0 +1,35 @@
"use client";
import { useApiStore } from "@/store/uiStore";
export default function AppToasts() {
const { toasts, removeToast } = useApiStore((s) => ({
toasts: s.toasts,
removeToast: s.removeToast
}));
return (
<div className="toast-stack">
{toasts.map((toast) => (
<div key={toast.id} className={`toast show align-items-center border-0 ${toastClass(toast.type)}`}>
<div className="d-flex">
<div className="toast-body text-white">{toast.message}</div>
<button
className="btn btn-sm btn-link text-white"
onClick={() => removeToast(toast.id)}
aria-label="dismiss"
>
×
</button>
</div>
</div>
))}
</div>
);
}
function toastClass(type: "success" | "error" | "info") {
if (type === "success") return "bg-success text-white";
if (type === "error") return "bg-danger text-white";
return "bg-info text-white";
}

View File

@ -0,0 +1,111 @@
"use client";
import { ReactNode } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { usePermissions } from "@/hooks/usePermissions";
import { useAuthStore } from "@/store/authStore";
import { useTenantStore } from "@/store/tenantStore";
import { useLocaleStore } from "@/store/uiStore";
import { logout } from "@/services/auth";
import { t } from "@/lib/locale";
const navItems = [
{ href: "/dashboard", label: "Dashboard", key: "dashboard", permission: "ALL" as const },
{ href: "/dashboard/users", label: "Users", key: "users", permission: "USER_MANAGE_OR_ADMIN" as const },
{ href: "/dashboard/roles", label: "Roles", key: "roles", permission: "ROLE_MANAGE_OR_ADMIN" as const },
{ href: "/dashboard/workflow", label: "Workflow", key: "workflow", permission: "WORKFLOW_OR_ADMIN" as const },
{ href: "/dashboard/audit", label: "Audit", key: "audit", permission: "ADMIN_ONLY" as const },
{ href: "/dashboard/modules", label: "Modules", key: "modules", permission: "ADMIN_ONLY" as const },
{ href: "/dashboard/settings", label: "Settings", key: "settings", permission: "ALL" as const }
];
export default function DashboardShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const clearAuth = useAuthStore((s) => s.clearAuth);
const { username, tenantId } = useAuthStore((s) => ({
username: s.profile?.username ?? "Unknown",
tenantId: s.tenantId
}));
const permissions = usePermissions();
const locale = useLocaleStore((s) => s.locale);
const tenants = useTenantStore((s) => s.availableTenants);
const currentTenant = useTenantStore((s) => s.tenantId);
const setTenant = useTenantStore((s) => s.setTenantId);
const setLocale = useLocaleStore((s) => s.setLocale);
const authMode =
process.env.NEXT_PUBLIC_AUTH_MODE?.toLowerCase?.() === "ldap" ? "LDAP" : "Local";
const visibleNavItems = navItems.filter((item) => {
if (item.permission === "ALL") return true;
if (item.permission === "USER_MANAGE_OR_ADMIN") return permissions.canManageUsers || permissions.isAdminOrManager;
if (item.permission === "ROLE_MANAGE_OR_ADMIN") return permissions.canManageRoles || permissions.isAdminOrManager;
if (item.permission === "WORKFLOW_OR_ADMIN") return permissions.canApproveWorkflow || permissions.isAdminOrManager;
return permissions.isAdmin;
});
const onTenantChange = (value: string) => {
setTenant(value);
};
const handleLogout = async () => {
await logout();
clearAuth();
router.replace("/(auth)/login");
};
return (
<div className="app-shell">
<aside className="app-sidebar">
<h2 className="text-white mb-4">UTMS Admin</h2>
<p className="small text-white-50 mb-4">
{t("tenant", locale)}: {tenantId || currentTenant}
</p>
<nav className="vstack gap-1">
{visibleNavItems.map((item) => (
<Link
key={item.key}
href={item.href}
className={`text-decoration-none ${pathname === item.href ? "text-primary fw-bold" : "text-white"}`}
>
{item.label}
</Link>
))}
</nav>
</aside>
<main className="app-content w-100">
<header className="card page-card p-3 mb-3">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div className="d-flex align-items-center gap-2">
<span className="text-muted">{t("welcome", locale)}, {username}</span>
<span className="text-muted d-none d-sm-inline">| Mode: {authMode}</span>
</div>
<div className="d-flex gap-2">
<select className="form-select" value={currentTenant ?? ""} onChange={(e) => onTenantChange(e.target.value)}>
<option value="">Tenant</option>
{tenants.map((tenant) => (
<option key={tenant} value={tenant}>
{tenant}
</option>
))}
</select>
<select
className="form-select"
value={locale}
onChange={(e) => setLocale(e.target.value as "en" | "id")}
>
<option value="en">en-US</option>
<option value="id">id-ID</option>
</select>
<button className="btn btn-outline-danger" onClick={handleLogout}>
{t("logout", locale)}
</button>
</div>
</div>
</header>
<section className="pb-3">{children}</section>
</main>
</div>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import { useLocaleStore } from "@/store/uiStore";
import { t } from "@/lib/locale";
export default function RoleCreateForm({
disabled,
onSubmit
}: {
disabled: boolean;
onSubmit: (payload: { code: string; name: string; permissionCodes: string[] }) => Promise<void>;
}) {
const locale = useLocaleStore((s) => s.locale);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [permissions, setPermissions] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (disabled || !code || !name) return;
setLoading(true);
try {
await onSubmit({
code,
name,
permissionCodes: permissions
.split(",")
.map((it) => it.trim())
.filter(Boolean)
});
setCode("");
setName("");
setPermissions("");
} finally {
setLoading(false);
}
};
return (
<div className="vstack gap-2">
<input
className="form-control"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("code", locale)}
disabled={disabled}
/>
<input
className="form-control"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name", locale)}
disabled={disabled}
/>
<input
className="form-control"
value={permissions}
onChange={(e) => setPermissions(e.target.value)}
placeholder="permission codes (comma separated)"
disabled={disabled}
/>
<button className="btn btn-primary" disabled={disabled || loading} onClick={submit} type="button">
{loading ? t("submitting", locale) : t("create", locale)}
</button>
</div>
);
}

View File

@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useLocaleStore } from "@/store/uiStore";
import { t } from "@/lib/locale";
export default function RolePermissionForm({
disabled,
permissionCatalog,
onSubmit
}: {
disabled: boolean;
permissionCatalog: string[];
onSubmit: (payload: { code: string; permissionCodes: string[] }) => Promise<void>;
}) {
const locale = useLocaleStore((s) => s.locale);
const [code, setCode] = useState("");
const [permissions, setPermissions] = useState("");
const [loading, setLoading] = useState(false);
const selected = permissions.split(",").map((i) => i.trim()).filter(Boolean);
const quickAdd = (value: string) => {
setPermissions((prev) =>
prev
.split(",")
.map((v) => v.trim())
.filter(Boolean)
.concat(selected.includes(value) ? [] : [value])
.join(", ")
);
};
const submit = async () => {
if (disabled || !code) return;
setLoading(true);
try {
await onSubmit({ code, permissionCodes: selected });
setCode("");
setPermissions("");
} finally {
setLoading(false);
}
};
return (
<div className="vstack gap-2">
<input
className="form-control"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("roleCode", locale)}
disabled={disabled}
/>
<input
className="form-control"
value={permissions}
onChange={(e) => setPermissions(e.target.value)}
placeholder={t("permissions", locale)}
disabled={disabled}
/>
<div className="d-flex flex-wrap gap-2">
{permissionCatalog.map((permission) => (
<button
key={permission}
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => quickAdd(permission)}
disabled={disabled}
>
{permission}
</button>
))}
</div>
<button className="btn btn-outline-primary" disabled={disabled || loading} onClick={submit} type="button">
{loading ? t("submitting", locale) : t("save", locale)}
</button>
</div>
);
}

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

@ -0,0 +1,4 @@
export default function Alert({ message, type = "info" }: { message: string; type?: "info" | "success" | "danger" }) {
const variant = type === "danger" ? "danger" : type === "success" ? "success" : "info";
return <div className={`alert alert-${variant}`}>{message}</div>;
}

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

@ -0,0 +1,19 @@
export default function Badge({
variant,
children
}: {
variant: "success" | "danger" | "warning" | "secondary" | "info";
children: string;
}) {
const cls =
variant === "success"
? "bg-success"
: variant === "danger"
? "bg-danger"
: variant === "warning"
? "bg-warning"
: variant === "info"
? "bg-info"
: "bg-secondary";
return <span className={`badge ${cls}`}>{children}</span>;
}

View File

@ -0,0 +1,31 @@
import Link from "next/link";
export type BreadcrumbItem = {
label: string;
href?: string;
};
export default function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
if (!items.length) return null;
return (
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
{items.map((item, index) => (
<li
key={item.label + index}
className={`breadcrumb-item ${index === items.length - 1 ? "active" : ""}`}
>
{index === items.length - 1 || !item.href ? (
<span>{item.label}</span>
) : (
<Link href={item.href} className="text-decoration-none">
{item.label}
</Link>
)}
</li>
))}
</ol>
</nav>
);
}

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

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

View File

@ -0,0 +1,41 @@
import Dialog from "./Dialog";
export default function ConfirmDialog({
open,
title,
message,
onConfirm,
onCancel,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
loading
}: {
open: boolean;
title: string;
message: string;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
confirmLabel?: string;
cancelLabel?: string;
loading?: boolean;
}) {
return (
<Dialog
open={open}
title={title}
onClose={onCancel}
footer={
<div className="d-flex justify-content-end gap-2">
<button className="btn btn-outline-secondary" onClick={onCancel} disabled={loading}>
{cancelLabel}
</button>
<button className="btn btn-danger" onClick={onConfirm} disabled={loading}>
{loading ? "Working..." : confirmLabel}
</button>
</div>
}
>
<p>{message}</p>
</Dialog>
);
}

View File

@ -0,0 +1,39 @@
export type DateRangeValue = {
from?: string;
to?: string;
};
export default function DateRange({
from,
to,
onFromChange,
onToChange
}: {
from?: string;
to?: string;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
}) {
return (
<div className="d-flex gap-2">
<label className="flex-fill">
<span className="form-label">From</span>
<input
type="datetime-local"
className="form-control"
value={from ?? ""}
onChange={(event) => onFromChange(event.target.value)}
/>
</label>
<label className="flex-fill">
<span className="form-label">To</span>
<input
type="datetime-local"
className="form-control"
value={to ?? ""}
onChange={(event) => onToChange(event.target.value)}
/>
</label>
</div>
);
}

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

@ -0,0 +1,60 @@
import { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
type DialogProps = {
open: boolean;
title: string;
description?: string;
children: ReactNode;
onClose: () => void;
footer?: ReactNode;
size?: "sm" | "lg" | "xl";
loading?: boolean;
};
const sizeClass = {
sm: "modal-dialog modal-sm",
lg: "modal-dialog modal-lg",
xl: "modal-dialog modal-xl"
};
export default function Dialog({
open,
title,
description,
children,
onClose,
footer,
size = "sm",
loading
}: DialogProps) {
useEffect(() => {
if (!open) return;
const onEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onEsc);
return () => window.removeEventListener("keydown", onEsc);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="modal d-block" style={{ background: "rgba(0,0,0,.4)" }}>
<div className={sizeClass[size]} style={{ marginTop: "6rem" }}>
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body">
{description && <p className="text-muted mb-3">{description}</p>}
{loading ? <div className="text-center text-muted">Loading...</div> : children}
</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
</div>,
document.body
);
}

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

@ -0,0 +1,33 @@
import { ReactNode } from "react";
import { createPortal } from "react-dom";
export default function Drawer({
open,
title,
children,
onClose
}: {
open: boolean;
title: string;
children: ReactNode;
onClose: () => void;
}) {
if (!open) return null;
return createPortal(
<div className="modal d-block" style={{ background: "rgba(0,0,0,.35)" }} role="dialog">
<div className="modal-dialog modal-dialog-end">
<div className="modal-content h-100">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body p-0">
<div className="p-3">{children}</div>
</div>
</div>
</div>
</div>,
document.body
);
}

View File

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

View File

@ -0,0 +1,49 @@
import { ChangeEvent, useRef } from "react";
export type FileUploadProps = {
label: string;
accept?: string;
multiple?: boolean;
maxBytes?: number;
onFileSelect: (files: File[]) => void;
disabled?: boolean;
helperText?: string;
resetToken?: number;
};
export default function FileUpload({
label,
accept,
multiple,
maxBytes,
onFileSelect,
disabled,
helperText,
resetToken
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
const targetFiles = Array.from(event.target.files ?? []);
const files = maxBytes ? targetFiles.filter((file) => file.size <= maxBytes) : targetFiles;
onFileSelect(files);
event.target.value = "";
};
return (
<label className="d-block">
<span className="form-label">{label}</span>
<input
key={resetToken}
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="form-control"
onChange={onChange}
/>
{helperText && <small className="text-muted">{helperText}</small>}
</label>
);
}

View File

@ -0,0 +1,23 @@
import { ReactNode } from "react";
type Option = { label: string; value: string };
type Props = {
label: string;
children?: ReactNode;
type?: "text" | "password" | "checkbox" | "textarea" | "select";
value?: string | boolean;
onValueChange?: (value: string) => void;
options?: Option[];
placeholder?: string;
rows?: number;
};
export default function FormField({ label, children, type = "text", ...props }: Props) {
return (
<label className="form-label">
{label}
{children}
</label>
);
}

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

@ -0,0 +1,25 @@
import { ReactNode } from "react";
type ModalProps = {
isOpen: boolean;
title: string;
children: ReactNode;
onClose: () => void;
};
export default function Modal({ isOpen, title, children, onClose }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal d-block" style={{ background: "rgba(0,0,0,0.4)" }} role="dialog">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body">{children}</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import Breadcrumb, { BreadcrumbItem } from "./Breadcrumb";
import { ReactNode } from "react";
export default function PageHeader({
title,
description,
actions,
breadcrumb
}: {
title: string;
description?: string;
actions?: ReactNode;
breadcrumb?: BreadcrumbItem[];
}) {
return (
<div className="mb-3">
{breadcrumb && <Breadcrumb items={breadcrumb} />}
<div className="d-flex justify-content-between align-items-center">
<div>
<h2 className="mb-1">{title}</h2>
{description && <p className="text-muted mb-0">{description}</p>}
</div>
{actions}
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
type PaginationProps = {
page: number;
pageSize: number;
total: number;
onChange: (page: number) => void;
};
export default function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
return (
<div className="d-flex justify-content-center gap-1">
<button className="btn btn-outline-secondary btn-sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
Prev
</button>
<span className="px-2 d-flex align-items-center">
Page {page} / {totalPages}
</span>
<button
className="btn btn-outline-secondary btn-sm"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
>
Next
</button>
</div>
);
}

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

@ -0,0 +1,38 @@
import { ChangeEvent } from "react";
export type SelectOption = {
value: string;
label: string;
};
export default function Select({
label,
options,
value,
onChange,
disabled
}: {
label: string;
options: SelectOption[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<label className="form-label">
{label}
<select
className="form-select"
value={value}
onChange={(event: ChangeEvent<HTMLSelectElement>) => onChange(event.target.value)}
disabled={disabled}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}

View File

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

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

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

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

@ -0,0 +1,35 @@
import { ReactNode } from "react";
type TabItem = {
key: string;
title: string;
content: ReactNode;
};
type TabsProps = {
items: TabItem[];
active: string;
onChange: (key: string) => void;
};
export default function Tabs({ items, active, onChange }: TabsProps) {
return (
<div>
<ul className="nav nav-tabs mb-3">
{items.map((item) => (
<li key={item.key} className="nav-item">
<button
className={`nav-link ${item.key === active ? "active" : ""}`}
onClick={() => onChange(item.key)}
>
{item.title}
</button>
</li>
))}
</ul>
<div>
{items.find((item) => item.key === active)?.content}
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useLocaleStore } from "@/store/uiStore";
import { t } from "@/lib/locale";
export default function UserRoleUpdateForm({
disabled,
onSubmit
}: {
disabled: boolean;
onSubmit: (payload: { username: string; roleCodes: string[] }) => Promise<void>;
}) {
const locale = useLocaleStore((s) => s.locale);
const [username, setUsername] = useState("");
const [roles, setRoles] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (disabled || !username) return;
setLoading(true);
try {
await onSubmit({
username,
roleCodes: roles
.split(",")
.map((role) => role.trim())
.filter(Boolean)
});
setUsername("");
setRoles("");
} finally {
setLoading(false);
}
};
return (
<div className="vstack gap-2">
<input
className="form-control"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("username", locale)}
disabled={disabled}
/>
<input
className="form-control"
value={roles}
onChange={(e) => setRoles(e.target.value)}
placeholder="Role codes (comma separated)"
disabled={disabled}
/>
<button className="btn btn-outline-primary" onClick={submit} disabled={disabled || loading} type="button">
{loading ? t("submitting", locale) : t("update", locale)}
</button>
</div>
);
}

View File

@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { useLocaleStore } from "@/store/uiStore";
import { t } from "@/lib/locale";
type UserCreateFormValues = {
username: string;
password?: string;
ldapDn?: string;
enabled?: boolean;
roleCodes: string[];
};
export default function UserCreateForm({
disabled,
onSubmit,
authMode
}: {
disabled: boolean;
authMode: string;
onSubmit: (payload: UserCreateFormValues) => Promise<void>;
}) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [ldapDn, setLdapDn] = useState("");
const [roleCodesInput, setRoleCodesInput] = useState("");
const [enabled, setEnabled] = useState(true);
const [loading, setLoading] = useState(false);
const locale = useLocaleStore((s) => s.locale);
const isLdap = authMode.toUpperCase() === "LDAP";
const submit = async () => {
if (disabled || !username || (!isLdap && !password)) return;
setLoading(true);
try {
await onSubmit({
username: username.trim(),
password: isLdap ? undefined : password.trim(),
ldapDn: isLdap && ldapDn.trim() ? ldapDn.trim() : undefined,
enabled,
roleCodes: roleCodesInput
.split(",")
.map((s) => s.trim())
.filter(Boolean)
});
setUsername("");
setPassword("");
setLdapDn("");
setRoleCodesInput("");
} finally {
setLoading(false);
}
};
return (
<div className="vstack gap-2">
<input
className="form-control"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("username", locale)}
disabled={disabled}
/>
{!isLdap && (
<input
className="form-control"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("password", locale)}
disabled={disabled}
/>
)}
{isLdap && (
<input
className="form-control"
value={ldapDn}
onChange={(e) => setLdapDn(e.target.value)}
placeholder="ldapDn"
disabled={disabled}
/>
)}
<input
className="form-control"
value={roleCodesInput}
onChange={(e) => setRoleCodesInput(e.target.value)}
placeholder="roleCodes (comma separated)"
disabled={disabled}
/>
<label className="d-flex align-items-center gap-2">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
disabled={disabled}
/>
{t("enabled", locale)}
</label>
<button className="btn btn-primary" type="button" onClick={submit} disabled={disabled || loading}>
{loading ? t("submitting", locale) : t("create", locale)}
</button>
</div>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { FormEvent, useState } from "react";
import Modal from "../ui/Modal";
type ApprovalActionModalProps = {
isOpen: boolean;
mode: "approve" | "reject";
title: string;
onSubmit: (notes?: string, checkerRole?: string) => Promise<void>;
onClose: () => void;
};
export default function ApprovalActionModal({
isOpen,
mode,
title,
onSubmit,
onClose
}: ApprovalActionModalProps) {
const [notes, setNotes] = useState("");
const [checkerRole, setCheckerRole] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await onSubmit(notes, checkerRole || undefined);
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} title={title} onClose={onClose}>
<form className="vstack gap-3" onSubmit={handleSubmit}>
<textarea
className="form-control"
rows={4}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes"
/>
<input
className="form-control"
value={checkerRole}
onChange={(e) => setCheckerRole(e.target.value)}
placeholder="Optional checker role (defaults to CHECKER)"
/>
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button className="btn btn-primary" disabled={loading} type="submit">
{loading ? "Saving..." : mode === "approve" ? "Approve" : "Reject"}
</button>
</div>
</form>
</Modal>
);
}

View File

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

View File

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