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