Commit 30587791 by krds-arun

feat(panel): add module dashboards and header module search

- Add Modules page with search/filter and card grid
- Add header center module search with autocomplete dropdown
- Add module dashboards (admin/incidents/permit/audits/inspection/checklist/suggestion/tracking)
- Add module dashboard menu navigation component and stories

Made-with: Cursor
parent 7d260d1c
import { AdminDashboard } from "@/components/panel/admin/AdminDashboard/AdminDashboard";
export default function Page() {
return <AdminDashboard />;
}
import { AuditsDashboard } from "@/components/panel/audits/AuditsDashboard/AuditsDashboard";
export default function Page() {
return <AuditsDashboard />;
}
import { ChecklistDashboard } from "@/components/panel/checklist/ChecklistDashboard/ChecklistDashboard";
export default function Page() {
return <ChecklistDashboard />;
}
import { IncidentsDashboard } from "@/components/panel/incidents/IncidentsDashboard/IncidentsDashboard";
export default function Page() {
return <IncidentsDashboard />;
}
import { InspectionDashboard } from "@/components/panel/inspection/InspectionDashboard/InspectionDashboard";
export default function Page() {
return <InspectionDashboard />;
}
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Sidebar } from "@/components/layout/Sidebar/Sidebar";
import { Header } from "@/components/layout/Header/Header";
import { Footer } from "@/components/layout/Footer/Footer";
import { BottomTab } from "@/components/layout/BottomTab/BottomTab";
import { NotificationsPanel } from "@/components/widgets/NotificationsPanel/NotificationsPanel";
import { ProfilePanelHeader } from "@/components/widgets/ProfilePanelHeader/ProfilePanelHeader";
import { QuickAction } from "@/components/widgets/QuickAction/QuickAction";
import { LocationSelector } from "@/components/widgets/LocationSelector/LocationSelector";
import { PanelModuleSearch } from "@/components/widgets/PanelModuleSearch/PanelModuleSearch";
import { panelNotificationSections } from "./panel-notifications";
import "./panel.css";
export default function PanelLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const isInteractiveTarget = useMemo(() => {
return (target: EventTarget | null) => {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("a, button, input, textarea, select, [role='button']"));
};
}, []);
useEffect(() => {
let startX = 0;
let startY = 0;
let tracking = false;
const onTouchStart = (event: TouchEvent) => {
if (mobileSidebarOpen) return;
const touch = event.touches[0];
if (!touch) return;
// Left-edge swipe zone (tablet + mobile friendly)
if (touch.clientX > 32) return;
startX = touch.clientX;
startY = touch.clientY;
tracking = true;
};
const onTouchMove = (event: TouchEvent) => {
if (!tracking) return;
const touch = event.touches[0];
if (!touch) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (dx > 64 && Math.abs(dy) < 42) {
tracking = false;
const useDrawer = window.matchMedia("(max-width: 1024px)").matches;
if (useDrawer) {
setMobileSidebarOpen(true);
} else if (sidebarCollapsed) {
setSidebarCollapsed(false);
}
}
};
const onTouchEnd = () => {
tracking = false;
};
window.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
window.addEventListener("touchend", onTouchEnd, { passive: true });
window.addEventListener("touchcancel", onTouchEnd, { passive: true });
return () => {
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
window.removeEventListener("touchcancel", onTouchEnd);
};
}, [mobileSidebarOpen, sidebarCollapsed]);
const panelHeaderRight = (
<>
<LocationSelector />
<NotificationsPanel
count={5}
sections={panelNotificationSections}
onMarkAllRead={() => {}}
onViewAll={() => {}}
onGoToPage={() => {}}
onForward={() => {}}
/>
<ProfilePanelHeader
name="Nino Lester Cruz"
role="ADMIN"
initials="NL"
onSignOut={() => router.push("/")}
/>
</>
);
return (
<div className={`maf-panel ${sidebarCollapsed ? "maf-panel--sidebar-collapsed" : ""}`.trim()}>
<aside className="maf-panel__sidebarRail">
<Sidebar />
<aside
className="maf-panel__sidebarRail"
onClick={(event) => {
if (!sidebarCollapsed) return;
if (isInteractiveTarget(event.target)) return;
setSidebarCollapsed(false);
}}
aria-label="Sidebar rail"
>
<Sidebar onLogout={() => router.push("/")} />
</aside>
<div className="maf-panel__main">
<Header hideNavMenu hideActions logoSrc="/panel-monogram-logo.png" />
<Header
hideNavMenu
hideActions
logoSrc="/maf-logo.png"
centerSlot={<PanelModuleSearch />}
panelRight={panelHeaderRight}
/>
<button
type="button"
......@@ -78,7 +179,7 @@ export default function PanelLayout({
>
×
</button>
<Sidebar />
<Sidebar onLogout={() => router.push("/")} />
</aside>
</>
) : null}
......
import { ModulesPage } from "@/components/panel/modules/ModulesPage/ModulesPage";
export default function Page() {
return <ModulesPage />;
}
import { PanelDashboard } from "@/components/panel/dashboard/PanelDashboard/PanelDashboard";
export default function Page() {
return (
<div>
<h1>Panel</h1>
</div>
);
return <PanelDashboard />;
}
\ No newline at end of file
import type { NotificationSection } from "@/components/widgets/NotificationsPanel/notifications-types";
export const panelNotificationSections: NotificationSection[] = [
{
label: "Today",
items: [
{
id: "n1",
title: "Incident #INC-2024-001 assigned to you",
description: "Requires review by 15 Apr 2024",
timeLabel: "2m ago",
tone: "alert",
},
{
id: "n2",
title: "User added successfully",
description: "Ahmed.AlAlawi@maf.ae is now active",
timeLabel: "1h ago",
tone: "success",
},
],
},
{
label: "Yesterday",
items: [
{
id: "n3",
title: "Audit submission pending approval",
description: "LEC Cashier Spot Audit — VOX AHM",
timeLabel: "Yesterday",
tone: "info",
},
],
},
{
label: "Older",
items: [
{
id: "n4",
title: "System maintenance scheduled",
description: "Planned window 02:00–04:00 GST",
timeLabel: "Mar 1",
tone: "warning",
},
],
},
];
.maf-panel {
/* MAF Theme tokens (scoped to panel) — LIGHT (white + brown) */
--maroon: #8b1538;
--maroon-deep: #6b0f2a;
--maroon-bright: #a91d47;
--gold: #c9a961;
--gold-light: #dfc07a;
--gold-pale: rgba(201, 169, 97, 0.12);
--gold-border: rgba(201, 169, 97, 0.2);
--gold-border-strong: rgba(201, 169, 97, 0.45);
/* Light neutrals to match sidebar */
--ink: #f8f6f2;
--ink-2: #ffffff;
--ink-3: #faf6f0;
--ink-4: #f1ece4;
/* Text becomes brown/ink on light surfaces */
--text-1: rgba(110, 90, 68, 0.96);
--text-2: rgba(110, 90, 68, 0.78);
--text-3: rgba(110, 90, 68, 0.62);
/* Surface tints (subtle) */
--white: rgba(110, 90, 68, 0.06);
--white-2: rgba(110, 90, 68, 0.1);
--surface-base: var(--ink);
--surface-raised: var(--ink-2);
--surface-overlay: var(--ink-3);
--border-subtle: var(--gold-border);
--border-strong: var(--gold-border-strong);
--shadow-maroon: 0 8px 32px rgba(139, 21, 56, 0.4);
--shadow-deep: 0 24px 64px rgba(16, 12, 8, 0.14);
--shadow-focus: 0 0 0 3px rgba(201, 169, 97, 0.35);
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-full: 9999px;
height: 100dvh;
min-height: 100dvh;
display: grid;
grid-template-columns: 280px 1fr;
background: #070a12;
background:
radial-gradient(720px 300px at 12% 0%, rgba(167, 138, 104, 0.14), transparent 62%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 246, 240, 0.98) 100%);
overflow: hidden;
}
......@@ -26,7 +68,9 @@
grid-template-rows: auto 1fr auto;
position: relative;
overflow: visible;
background: #05070d;
background:
radial-gradient(860px 340px at 18% 0%, rgba(167, 138, 104, 0.12), transparent 60%),
linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 246, 240, 0.98) 100%);
}
.maf-panel .landing-footer {
......@@ -53,14 +97,40 @@
}
.maf-panel .landing-header__logoWrap {
width: 34px;
height: 34px;
/* Panel header uses full wordmark logo */
width: 150px;
height: 36px;
}
.maf-panel .landing-header__brand {
gap: 0.4rem;
}
/* Panel header: three-column grid (brand + module search + right widgets) */
.maf-panel .landing-header__inner {
grid-template-columns: auto 1fr auto;
}
.maf-panel .landing-header__center {
display: flex;
justify-content: center;
}
.maf-panel .landing-header__centerSlot {
width: 100%;
display: flex;
justify-content: center;
}
.maf-panel .landing-header__right {
width: 100%;
justify-content: flex-end;
}
.maf-panel .landing-header__panelRight {
flex-wrap: nowrap;
}
/* Panel footer should not show any refresh action/icon */
.maf-panel .landing-footer [aria-label*="refresh" i],
.maf-panel .landing-footer [title*="refresh" i],
......@@ -84,10 +154,10 @@
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.3);
border: 1px solid var(--gold-border);
background: rgba(255, 255, 255, 0.96);
color: rgba(110, 90, 68, 0.9);
box-shadow: 0 8px 20px rgba(110, 90, 68, 0.18);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
z-index: 20;
cursor: pointer;
}
......@@ -97,21 +167,19 @@
min-height: 0;
padding: 1.35rem 1.5rem 2rem;
background: transparent;
color: rgba(255, 255, 255, 0.9);
color: var(--text-1);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.maf-panel__contentInner {
width: min(1180px, 100%);
width: 100%;
min-height: fit-content;
margin: 0 auto;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.07);
background:
radial-gradient(880px 320px at 10% 0%, rgba(196, 165, 116, 0.08), transparent 58%),
rgba(7, 10, 18, 0.62);
padding: 1rem 1.1rem;
margin: 0;
border-radius: 0;
border: 0;
background: transparent;
padding: 0;
}
@media (max-width: 1366px) and (orientation: landscape) {
......@@ -191,17 +259,48 @@
}
.maf-panel .landing-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 52px;
padding-left: 48px;
padding-right: 10px;
padding: 10px 12px 10px 48px;
box-sizing: border-box;
overflow: visible;
}
/* Hide nav column when no desktop nav (panel mode) — more reliable than :empty */
.maf-panel .landing-header__center:not(:has(nav)) {
display: none;
}
.maf-panel .landing-header__left {
flex: 0 0 auto;
min-width: 0;
}
.maf-panel .landing-header__right {
flex: 1 1 auto;
min-width: 0;
justify-content: flex-end;
}
.maf-panel .landing-header__panelRight {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
max-width: min(100%, calc(100vw - 56px));
}
.maf-panel .landing-header {
z-index: 46;
overflow: visible;
}
.maf-panel .landing-header__logoWrap {
width: 32px;
width: 132px;
height: 32px;
}
......@@ -248,12 +347,16 @@
}
.maf-panel__content {
padding: 0.8rem 0.65rem 6.2rem;
/*
Safe scroll space for the floating QuickAction.
Ensures the last dashboard row can scroll fully above the FAB.
*/
padding: 0.8rem 0.65rem 10.2rem;
}
.maf-panel__contentInner {
border-radius: 10px;
padding: 0.75rem 0.8rem;
border-radius: 0;
padding: 0;
}
.maf-panel .landing-footer {
......@@ -262,7 +365,8 @@
.maf-panel .maf-quick-action__floatingWrap {
right: 0.9rem;
bottom: calc(5.7rem + env(safe-area-inset-bottom));
/* Lift well above bottom tab + content */
bottom: calc(9.2rem + env(safe-area-inset-bottom));
}
}
import { PermitDashboard } from "@/components/panel/permit/PermitDashboard/PermitDashboard";
export default function Page() {
return <PermitDashboard />;
}
import { SuggestionDashboard } from "@/components/panel/suggestion/SuggestionDashboard/SuggestionDashboard";
export default function Page() {
return <SuggestionDashboard />;
}
import { TrackingDashboard } from "@/components/panel/tracking/TrackingDashboard/TrackingDashboard";
export default function Page() {
return <TrackingDashboard />;
}
......@@ -24,6 +24,10 @@ export type HeaderProps = {
};
hideNavMenu?: boolean;
hideActions?: boolean;
/** Optional center slot (e.g. global search). Overrides nav when provided. */
centerSlot?: React.ReactNode;
/** Right-side slot for panel mode (e.g. notifications + profile). */
panelRight?: React.ReactNode;
};
function SupportGlyph({ className }: { className?: string }) {
......@@ -54,6 +58,8 @@ export function Header({
cta = { label: "Login", href: "#" },
hideNavMenu = false,
hideActions = false,
centerSlot,
panelRight,
}: HeaderProps) {
const [open, setOpen] = React.useState(false);
const openedAtRef = React.useRef(0);
......@@ -96,7 +102,9 @@ export function Header({
</div>
<div className="landing-header__center">
{!hideNavMenu ? (
{centerSlot ? (
<div className="landing-header__centerSlot">{centerSlot}</div>
) : !hideNavMenu ? (
<nav
className="landing-header__nav landing-header__nav--desktop"
aria-label="Primary"
......@@ -115,6 +123,7 @@ export function Header({
</div>
<div className="landing-header__right">
{panelRight ? <div className="landing-header__panelRight">{panelRight}</div> : null}
{!hideActions && supportCta ? (
supportCta.onClick ? (
<button
......
......@@ -71,6 +71,14 @@
gap: 10px;
}
.landing-header__panelRight {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.landing-header__support {
gap: 10px;
}
......
......@@ -10,6 +10,10 @@ export type SidebarItem = {
export type SidebarProps = {
items?: SidebarItem[];
/** Called when logout is clicked; if omitted, logout is a link to `logoutHref`. */
onLogout?: () => void;
logoutHref?: string;
logoutLabel?: string;
};
function SidebarIcon({ label }: { label: string }) {
......@@ -103,6 +107,14 @@ function SidebarIcon({ label }: { label: string }) {
</svg>
);
}
if (icon === "logout") {
return (
<svg className="maf-sidebar__iconSvg" viewBox="0 0 24 24" aria-hidden>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
return (
<svg className="maf-sidebar__iconSvg" viewBox="0 0 24 24" aria-hidden>
......@@ -113,17 +125,20 @@ function SidebarIcon({ label }: { label: string }) {
export function Sidebar({
items = [
{ label: "Home", href: "#", active: true },
{ label: "Modules", href: "#" },
{ label: "Admin", href: "#" },
{ label: "Incidents", href: "#" },
{ label: "Permit", href: "#" },
{ label: "Audits", href: "#" },
{ label: "Inspection", href: "#" },
{ label: "Checklist", href: "#" },
{ label: "Suggestion", href: "#" },
{ label: "Tracking", href: "#" },
{ label: "Home", href: "/panel", active: true },
{ label: "Modules", href: "/panel/modules" },
{ label: "Admin", href: "/panel/admin" },
{ label: "Incidents", href: "/panel/incidents" },
{ label: "Permit", href: "/panel/permit" },
{ label: "Audits", href: "/panel/audits" },
{ label: "Inspection", href: "/panel/inspection" },
{ label: "Checklist", href: "/panel/checklist" },
{ label: "Suggestion", href: "/panel/suggestion" },
{ label: "Tracking", href: "/panel/tracking" },
],
onLogout,
logoutHref = "/",
logoutLabel = "Logout",
}: SidebarProps) {
return (
<aside className="maf-sidebar" aria-label="Sidebar navigation">
......@@ -149,6 +164,21 @@ export function Sidebar({
</span>
<span>Settings</span>
</a>
{onLogout ? (
<button type="button" className="maf-sidebar__item maf-sidebar__logout" onClick={onLogout}>
<span className="maf-sidebar__icon" aria-hidden>
<SidebarIcon label="logout" />
</span>
<span>{logoutLabel}</span>
</button>
) : (
<a className="maf-sidebar__item maf-sidebar__logout" href={logoutHref}>
<span className="maf-sidebar__icon" aria-hidden>
<SidebarIcon label="logout" />
</span>
<span>{logoutLabel}</span>
</a>
)}
</div>
</aside>
);
......
......@@ -71,5 +71,23 @@
padding-top: 0.75rem;
border-top: 1px solid rgba(110, 90, 68, 0.2);
background: linear-gradient(180deg, rgba(250, 246, 240, 0), rgba(250, 246, 240, 0.98) 30%);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.maf-sidebar__logout {
width: 100%;
border: 0;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
color: rgba(110, 90, 68, 0.92);
}
.maf-sidebar__logout:hover {
background: rgba(180, 60, 60, 0.08);
color: rgba(130, 45, 45, 0.95);
}
......@@ -19,6 +19,8 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Interactive: Story = {
// Render-only story; satisfy Storybook/TS when `args` is required on the inferred type.
args: {} as Story["args"],
render: () => {
const [query, setQuery] = useState("");
const [countries, setCountries] = useState<string[]>(["UAE"]);
......
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { AdminDashboard } from "./AdminDashboard";
const meta = {
title: "Panel/Modules/Admin/Dashboard",
component: AdminDashboard,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AdminDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = { parameters: { viewport: { defaultViewport: "tablet" } } };
export const Mobile: Story = { parameters: { viewport: { defaultViewport: "mobile1" } } };
"use client";
import React from "react";
import { ModuleCard } from "@/components/panel/modules/ModuleCard/ModuleCard";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import "./admin-dashboard.css";
type AdminTile = {
title: string;
description: string;
href: string;
badge?: string;
};
const tiles: AdminTile[] = [
{
title: "Manage Structure & Hierarchy",
description: "Org tree, locations and access boundaries (root admin only).",
href: "/panel/admin/structure",
badge: "Root",
},
{
title: "Manage Forms, Groups & Questions",
description: "Create and maintain forms with versioned question sets.",
href: "/panel/admin/forms",
},
{
title: "Manage Incidents",
description: "Incident types, severity matrix and workflow rules.",
href: "/panel/admin/incidents",
},
{
title: "Manage Contractors",
description: "Contractor profiles, approvals, and compliance requirements.",
href: "/panel/admin/contractors",
},
{
title: "Manage Suggestion Group",
description: "Suggestion categories, routing rules and moderation settings.",
href: "/panel/admin/suggestions",
},
{
title: "Manage Notifications",
description: "Templates, recipients and delivery channels.",
href: "/panel/admin/notifications",
},
{
title: "Manage User Role",
description: "Roles, permissions and assignment policies.",
href: "/panel/admin/roles",
},
{
title: "Manage Approval Schema",
description: "Approval steps, escalation paths and SLA thresholds.",
href: "/panel/admin/approvals",
},
{
title: "Manage Training & Feedback",
description: "Training catalog, feedback loops and follow-ups.",
href: "/panel/admin/training",
},
];
export function AdminDashboard() {
return (
<div className="maf-adminDash">
<header className="maf-adminDash__header">
<div>
<h1 className="maf-adminDash__title">Admin Management</h1>
<p className="maf-adminDash__subtitle">
Configure modules, policies and system behavior. Changes here affect everyone.
</p>
</div>
</header>
<ModuleDashboardMenu
ariaLabel="Admin navigation"
items={[
{ label: "Dashboard", href: "/panel/admin", active: true },
{ label: "Users listing", href: "/panel/admin/users" },
{ label: "Add user", href: "/panel/admin/users/new" },
]}
/>
<section className="maf-adminDash__grid" aria-label="Admin management tiles">
{tiles.map((t) => (
<ModuleCard
key={t.title}
title={t.title}
description={t.description}
href={t.href}
badge={t.badge}
meta="Admin tools"
/>
))}
</section>
</div>
);
}
.maf-adminDash {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-adminDash__header {
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-adminDash__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-adminDash__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-adminDash__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
}
@media (max-width: 1024px) {
.maf-adminDash__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.maf-adminDash {
padding: 0.9rem 0.65rem 1.1rem;
}
.maf-adminDash__grid {
grid-template-columns: 1fr;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { AuditsDashboard } from "./AuditsDashboard";
const meta = {
title: "Panel/Modules/Audits/Dashboard",
component: AuditsDashboard,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AuditsDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = { parameters: { viewport: { defaultViewport: "tablet" } } };
export const Mobile: Story = { parameters: { viewport: { defaultViewport: "mobile1" } } };
"use client";
import React from "react";
import { TopStatCard } from "@/components/panel/dashboard/TopStatCard/TopStatCard";
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
import { DashboardTrendChart } from "@/components/shared/charts/DashboardTrendChart/DashboardTrendChart";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import "./audits-dashboard.css";
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
export function AuditsDashboard() {
const completed = [4300, 3600, 3520, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const incomplete = [120, 140, 360, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const upcoming = [
{ name: "FIRE EXTINGUISHER", location: "MAF CITY CENTRE MALL", date: "01-01-2023", status: "ACTIVE" },
{ name: "MALL", location: "NAZAR 02", date: "19-03-2023", status: "ACTIVE" },
{ name: "FIRE ALARM", location: "MAF CITY CENTRE MALL", date: "27-01-2023", status: "ACTIVE" },
];
return (
<div className="maf-auditDash">
<header className="maf-auditDash__header">
<div>
<h1 className="maf-auditDash__title">Audits</h1>
<p className="maf-auditDash__subtitle">Progress, actions and upcoming audits at a glance.</p>
</div>
</header>
<ModuleDashboardMenu
ariaLabel="Audits navigation"
items={[
{ label: "Dashboard", href: "/panel/audits", active: true },
{ label: "Conduct audit", href: "/panel/audits/conduct" },
{ label: "Audit tracking", href: "/panel/audits/tracking" },
{ label: "Audit listing", href: "/panel/audits/list" },
]}
/>
<section className="maf-auditDash__kpis" aria-label="Audit KPIs">
<TopStatCard label="Total audits" value={12036} helper="All time" underlineTone="neutral" />
<TopStatCard label="Completed audits" value={11271} helper="All time" underlineTone="good" />
<TopStatCard label="Incomplete audits" value={765} helper="All time" underlineTone="warn" />
<TopStatCard label="Actions" value={1420} helper="All time" underlineTone="info" />
<TopStatCard label="Open actions" value={1041} helper="Pending" underlineTone="warn" />
<TopStatCard label="Closed actions" value={379} helper="Resolved" underlineTone="good" />
</section>
<section className="maf-auditDash__grid" aria-label="Audits dashboard content">
<DashboardCard title="Audit Dashboard Progress" subtitle="Completed vs incomplete">
<DashboardTrendChart
title="Audit Dashboard Progress"
labels={months}
completed={completed}
incomplete={incomplete}
height={320}
showSeriesLabels
/>
</DashboardCard>
<DashboardCard title="Upcoming audits" subtitle="Next scheduled">
<div className="maf-auditDash__table" role="table" aria-label="Upcoming audits table">
<div className="maf-auditDash__thead" role="row">
<div role="columnheader">Audit</div>
<div role="columnheader">Location</div>
<div role="columnheader">Date</div>
<div role="columnheader">Status</div>
</div>
{upcoming.map((u) => (
<div key={`${u.name}-${u.date}`} className="maf-auditDash__row" role="row">
<div role="cell" className="maf-auditDash__cellTitle">{u.name}</div>
<div role="cell">{u.location}</div>
<div role="cell">{u.date}</div>
<div role="cell">
<span className="maf-auditDash__pill">{u.status}</span>
</div>
</div>
))}
</div>
</DashboardCard>
</section>
</div>
);
}
.maf-auditDash {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-auditDash__header {
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-auditDash__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-auditDash__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-auditDash__kpis {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
}
.maf-auditDash__grid {
margin-top: 12px;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 12px;
padding: 0.2rem;
align-items: start;
}
.maf-auditDash__table {
display: grid;
gap: 8px;
}
.maf-auditDash__thead {
display: grid;
grid-template-columns: 1.1fr 1fr 0.7fr 0.55fr;
gap: 10px;
font-size: 0.75rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(110, 90, 68, 0.62);
}
.maf-auditDash__row {
display: grid;
grid-template-columns: 1.1fr 1fr 0.7fr 0.55fr;
gap: 10px;
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.84);
padding: 10px 10px;
color: rgba(110, 90, 68, 0.86);
}
.maf-auditDash__cellTitle {
font-weight: 900;
color: rgba(46, 39, 32, 0.96);
}
.maf-auditDash__pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(34, 197, 94, 0.24);
background: rgba(34, 197, 94, 0.1);
color: rgba(21, 128, 61, 0.92);
font-size: 0.75rem;
font-weight: 900;
letter-spacing: 0.04em;
}
@media (max-width: 1200px) {
.maf-auditDash__kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.maf-auditDash__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-auditDash__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.maf-auditDash__thead {
display: none;
}
.maf-auditDash__row {
grid-template-columns: 1fr;
gap: 6px;
}
.maf-auditDash__pill {
justify-self: start;
}
}
@media (max-width: 520px) {
.maf-auditDash {
padding: 0.9rem 0.65rem 1.1rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ChecklistDashboard } from "./ChecklistDashboard";
const meta = {
title: "Panel/Modules/Checklist/Dashboard",
component: ChecklistDashboard,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof ChecklistDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = { parameters: { viewport: { defaultViewport: "tablet" } } };
export const Mobile: Story = { parameters: { viewport: { defaultViewport: "mobile1" } } };
"use client";
import React from "react";
import { TopStatCard } from "@/components/panel/dashboard/TopStatCard/TopStatCard";
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
import { DashboardTrendChart } from "@/components/shared/charts/DashboardTrendChart/DashboardTrendChart";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import "./checklist-dashboard.css";
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
export function ChecklistDashboard() {
const completed = [5000, 4700, 4750, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const incomplete = [80, 70, 85, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const upcoming = [
{ name: "HSE DAILY", location: "MAF CITY CENTRE MALL", date: "01-01-2023", status: "ACTIVE" },
{ name: "NEW STORE CHECK", location: "MAF CITY CENTRE MALL", date: "19-03-2023", status: "ACTIVE" },
{ name: "FOOD COURT", location: "MAF CITY CENTRE MALL", date: "27-01-2023", status: "ACTIVE" },
];
return (
<div className="maf-checkDash">
<header className="maf-checkDash__header">
<div>
<h1 className="maf-checkDash__title">Checklist</h1>
<p className="maf-checkDash__subtitle">Checklist completion, action plan status and upcoming checks.</p>
</div>
</header>
<ModuleDashboardMenu
ariaLabel="Checklist navigation"
items={[
{ label: "Dashboard", href: "/panel/checklist", active: true },
{ label: "Conduct checklist", href: "/panel/checklist/conduct" },
{ label: "Checklist action plan", href: "/panel/checklist/action-plan" },
{ label: "Checklist status", href: "/panel/checklist/status" },
]}
/>
<section className="maf-checkDash__kpis" aria-label="Checklist KPIs">
<TopStatCard label="Total checklist" value={14330} helper="All time" underlineTone="neutral" />
<TopStatCard label="Completed checklist" value={14246} helper="All time" underlineTone="good" />
<TopStatCard label="Incomplete checklist" value={84} helper="All time" underlineTone="warn" />
<TopStatCard label="Actions" value={44} helper="All time" underlineTone="info" />
<TopStatCard label="Open actions" value={43} helper="Pending" underlineTone="warn" />
<TopStatCard label="Closed actions" value={1} helper="Resolved" underlineTone="good" />
</section>
<section className="maf-checkDash__grid" aria-label="Checklist dashboard content">
<DashboardCard title="Checklist Dashboard Progress" subtitle="Completed vs incomplete">
<DashboardTrendChart
title="Checklist Dashboard Progress"
labels={months}
completed={completed}
incomplete={incomplete}
height={320}
showSeriesLabels
/>
</DashboardCard>
<DashboardCard title="Upcoming checklist" subtitle="Next scheduled">
<div className="maf-checkDash__table" role="table" aria-label="Upcoming checklist table">
<div className="maf-checkDash__thead" role="row">
<div role="columnheader">Checklist</div>
<div role="columnheader">Location</div>
<div role="columnheader">Date</div>
<div role="columnheader">Status</div>
</div>
{upcoming.map((u) => (
<div key={`${u.name}-${u.date}`} className="maf-checkDash__row" role="row">
<div role="cell" className="maf-checkDash__cellTitle">{u.name}</div>
<div role="cell">{u.location}</div>
<div role="cell">{u.date}</div>
<div role="cell">
<span className="maf-checkDash__pill">{u.status}</span>
</div>
</div>
))}
</div>
</DashboardCard>
</section>
</div>
);
}
.maf-checkDash {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-checkDash__header {
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-checkDash__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-checkDash__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-checkDash__kpis {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
}
.maf-checkDash__grid {
margin-top: 12px;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 12px;
padding: 0.2rem;
align-items: start;
}
.maf-checkDash__table {
display: grid;
gap: 8px;
}
.maf-checkDash__thead {
display: grid;
grid-template-columns: 1.1fr 1fr 0.7fr 0.55fr;
gap: 10px;
font-size: 0.75rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(110, 90, 68, 0.62);
}
.maf-checkDash__row {
display: grid;
grid-template-columns: 1.1fr 1fr 0.7fr 0.55fr;
gap: 10px;
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.84);
padding: 10px 10px;
color: rgba(110, 90, 68, 0.86);
}
.maf-checkDash__cellTitle {
font-weight: 900;
color: rgba(46, 39, 32, 0.96);
}
.maf-checkDash__pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(34, 197, 94, 0.24);
background: rgba(34, 197, 94, 0.1);
color: rgba(21, 128, 61, 0.92);
font-size: 0.75rem;
font-weight: 900;
letter-spacing: 0.04em;
}
@media (max-width: 1200px) {
.maf-checkDash__kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.maf-checkDash__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-checkDash__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.maf-checkDash__thead {
display: none;
}
.maf-checkDash__row {
grid-template-columns: 1fr;
gap: 6px;
}
.maf-checkDash__pill {
justify-self: start;
}
}
@media (max-width: 520px) {
.maf-checkDash {
padding: 0.9rem 0.65rem 1.1rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ActivityFeed } from "./ActivityFeed";
const meta = {
title: "Panel/Dashboard/ActivityFeed",
component: ActivityFeed,
parameters: { layout: "padded" },
} satisfies Meta<typeof ActivityFeed>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [
{
id: "a1",
title: "Incident reported — HVAC: Camera room",
meta: "Priority escalated · Awaiting acknowledgement",
time: "2m",
tone: "warn",
},
{
id: "a2",
title: "Monthly audit completed — Carrefour",
meta: "All controls passed · Export available",
time: "1h",
tone: "good",
},
{
id: "a3",
title: "Corrective action overdue — Fire exit checks",
meta: "Assigned to EHS · Reminder sent",
time: "3h",
tone: "bad",
},
],
},
render: (args) => (
<div style={{ maxWidth: 620 }}>
<ActivityFeed {...args} />
</div>
),
};
export const Mobile: Story = {
args: {
items: [
{
id: "a1",
title: "Incident reported — HVAC: Camera room",
meta: "Priority escalated · Awaiting acknowledgement",
time: "2m",
tone: "warn",
},
{
id: "a2",
title: "Monthly audit completed — Carrefour",
meta: "All controls passed · Export available",
time: "1h",
tone: "good",
},
{
id: "a3",
title: "Corrective action overdue — Fire exit checks",
meta: "Assigned to EHS · Reminder sent",
time: "3h",
tone: "bad",
},
{
id: "a4",
title: "Supervisor assigned — Safety improvement",
meta: "Due date updated · Waiting for approval",
time: "Yesterday",
tone: "info",
},
],
},
parameters: { viewport: { defaultViewport: "mobile1" } },
render: (args) => (
<div style={{ maxWidth: 620 }}>
<ActivityFeed {...args} />
</div>
),
};
import "./activity-feed.css";
export type ActivityTone = "neutral" | "good" | "warn" | "bad" | "info";
export type ActivityItem = {
id: string;
title: string;
meta?: string;
time: string;
tone?: ActivityTone;
};
type ActivityFeedProps = {
title?: string;
items: ActivityItem[];
};
export function ActivityFeed({ title = "Recent activity", items }: ActivityFeedProps) {
return (
<section className="maf-activity">
<header className="maf-activity__head">
<h3 className="maf-activity__title">{title}</h3>
<span className="maf-activity__count">{items.length} items</span>
</header>
<ol className="maf-activity__list">
{items.map((item) => (
<li key={item.id} className="maf-activity__item">
<span
className={`maf-activity__dot maf-activity__dot--${item.tone ?? "neutral"}`.trim()}
aria-hidden="true"
/>
<div className="maf-activity__content">
<div className="maf-activity__row">
<span className="maf-activity__itemTitle">{item.title}</span>
<span className="maf-activity__time">{item.time}</span>
</div>
{item.meta ? <span className="maf-activity__meta">{item.meta}</span> : null}
</div>
</li>
))}
</ol>
</section>
);
}
.maf-activity {
display: grid;
gap: 12px;
min-width: 0;
}
.maf-activity__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.maf-activity__title {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--text-1, rgba(255, 255, 255, 0.9));
}
.maf-activity__count {
font-size: 0.8rem;
color: var(--text-3, rgba(255, 255, 255, 0.55));
}
.maf-activity__list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px;
}
.maf-activity__item {
display: grid;
grid-template-columns: 10px 1fr;
gap: 10px;
padding: 10px 10px;
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.8);
}
.maf-activity__dot {
width: 10px;
height: 10px;
border-radius: 999px;
margin-top: 6px;
box-shadow: 0 0 0 3px rgba(167, 138, 104, 0.12);
}
.maf-activity__dot--neutral {
background: rgba(110, 90, 68, 0.78);
}
.maf-activity__dot--good {
background: rgba(34, 197, 94, 0.95);
}
.maf-activity__dot--info {
background: rgba(59, 130, 246, 0.95);
}
.maf-activity__dot--warn {
background: rgba(245, 158, 11, 0.98);
}
.maf-activity__dot--bad {
background: rgba(239, 68, 68, 0.98);
}
.maf-activity__content {
min-width: 0;
display: grid;
gap: 4px;
}
.maf-activity__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.maf-activity__itemTitle {
font-size: 0.9rem;
line-height: 1.25;
color: var(--text-1, rgba(255, 255, 255, 0.88));
font-weight: 650;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.maf-activity__time {
font-size: 0.78rem;
color: var(--text-3, rgba(255, 255, 255, 0.55));
flex: 0 0 auto;
}
.maf-activity__meta {
font-size: 0.82rem;
color: var(--text-2, rgba(255, 255, 255, 0.62));
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { DashboardCard } from "./DashboardCard";
const meta = {
title: "Panel/Dashboard/DashboardCard",
component: DashboardCard,
parameters: { layout: "padded" },
} satisfies Meta<typeof DashboardCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Card title",
description: "A refined container for dashboard sections.",
children: (
<div style={{ display: "grid", gap: 10 }}>
<div
style={{
height: 64,
borderRadius: 14,
border: "1px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.03)",
}}
/>
<div
style={{
height: 64,
borderRadius: 14,
border: "1px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.03)",
}}
/>
</div>
),
},
render: (args) => (
<div style={{ maxWidth: 760 }}>
<DashboardCard {...args} />
</div>
),
};
import type React from "react";
import "./dashboard-card.css";
type DashboardCardProps = {
title?: string;
description?: string;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
};
export function DashboardCard({
title,
description,
actions,
children,
className = "",
}: DashboardCardProps) {
return (
<section className={`maf-dash-card ${className}`.trim()}>
{title || actions ? (
<header className="maf-dash-card__head">
<div className="maf-dash-card__headText">
{title ? <h2 className="maf-dash-card__title">{title}</h2> : null}
{description ? (
<p className="maf-dash-card__desc">{description}</p>
) : null}
</div>
{actions ? <div className="maf-dash-card__actions">{actions}</div> : null}
</header>
) : null}
<div className="maf-dash-card__body">{children}</div>
</section>
);
}
.maf-dash-card {
border-radius: var(--r-md, 16px);
border: 1px solid rgba(110, 90, 68, 0.18);
background:
radial-gradient(700px 220px at 0% 0%, rgba(167, 138, 104, 0.14), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 246, 240, 0.98));
box-shadow:
0 18px 50px rgba(16, 12, 8, 0.12),
0 1px 0 rgba(255, 255, 255, 0.6) inset;
overflow: hidden;
min-width: 0;
}
.maf-dash-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 0;
}
.maf-dash-card__headText {
min-width: 0;
}
.maf-dash-card__title {
margin: 0;
font-size: 0.92rem;
line-height: 1.2;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-1, rgba(110, 90, 68, 0.96));
}
.maf-dash-card__desc {
margin: 6px 0 0;
font-size: 0.84rem;
line-height: 1.45;
color: var(--text-2, rgba(110, 90, 68, 0.78));
max-width: 60ch;
}
.maf-dash-card__actions {
flex: 0 0 auto;
display: flex;
gap: 8px;
}
.maf-dash-card__body {
padding: 12px 16px 16px;
min-width: 0;
}
@media (max-width: 768px) {
.maf-dash-card {
border-radius: var(--r-md, 14px);
}
.maf-dash-card__head {
padding: 12px 12px 0;
}
.maf-dash-card__body {
padding: 10px 12px 12px;
}
.maf-dash-card__title {
font-size: 0.85rem;
}
.maf-dash-card__desc {
font-size: 0.8rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { DonutChart } from "./DonutChart";
const meta = {
title: "Panel/Dashboard/DonutChart",
component: DonutChart,
parameters: { layout: "padded" },
} satisfies Meta<typeof DonutChart>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Incidents by type",
slices: [
{ label: "Slip & fall", value: 38, color: "#2f80ed" },
{ label: "Fire/safety", value: 27, color: "#f2994a" },
{ label: "Food poisoning", value: 19, color: "#f2c94c" },
{ label: "Equipment", value: 34, color: "#27ae60" },
{ label: "Other", value: 24, color: "#9b51e0" },
],
},
render: (args) => (
<div style={{ maxWidth: 520 }}>
<DonutChart {...args} />
</div>
),
};
export const Mobile: Story = {
args: {
title: "Incidents by type",
slices: [
{ label: "Slip & fall", value: 38, color: "#2f80ed" },
{ label: "Fire/safety", value: 27, color: "#f2994a" },
{ label: "Food poisoning", value: 19, color: "#f2c94c" },
{ label: "Equipment", value: 34, color: "#27ae60" },
{ label: "Other", value: 24, color: "#9b51e0" },
],
},
parameters: { viewport: { defaultViewport: "mobile1" } },
render: (args) => (
<div style={{ maxWidth: 520 }}>
<DonutChart {...args} />
</div>
),
};
import "./donut-chart.css";
export type DonutSlice = {
label: string;
value: number;
color: string;
};
type DonutChartProps = {
title?: string;
slices: DonutSlice[];
size?: number;
thickness?: number;
};
function sum(values: number[]) {
return values.reduce((acc, v) => acc + v, 0);
}
export function DonutChart({
title = "Incidents by type",
slices,
size = 126,
thickness = 18,
}: DonutChartProps) {
const total = Math.max(0, sum(slices.map((s) => s.value)));
const radius = (size - thickness) / 2;
const circumference = 2 * Math.PI * radius;
let offset = 0;
const rendered = slices.map((slice) => {
const pct = total === 0 ? 0 : slice.value / total;
const dash = circumference * pct;
const dashArray = `${dash} ${Math.max(0, circumference - dash)}`;
const dashOffset = -offset;
offset += dash;
return {
...slice,
dashArray,
dashOffset,
};
});
return (
<div className="maf-donut">
<div className="maf-donut__top">
<h3 className="maf-donut__title">{title.toUpperCase()}</h3>
</div>
<div className="maf-donut__content">
<svg
className="maf-donut__svg"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
role="img"
aria-label={`${title}. Total ${total}.`}
>
<g transform={`translate(${size / 2}, ${size / 2}) rotate(-90)`}>
<circle
r={radius}
cx="0"
cy="0"
fill="transparent"
stroke="rgba(110, 90, 68, 0.14)"
strokeWidth={thickness}
/>
{rendered.map((slice) => (
<circle
key={slice.label}
r={radius}
cx="0"
cy="0"
fill="transparent"
stroke={slice.color}
strokeWidth={thickness}
strokeDasharray={slice.dashArray}
strokeDashoffset={slice.dashOffset}
strokeLinecap="butt"
/>
))}
</g>
</svg>
<ul className="maf-donut__legend" aria-label="Legend">
{slices.map((slice) => (
<li key={slice.label} className="maf-donut__legendItem">
<span
className="maf-donut__swatch"
style={{ background: slice.color }}
aria-hidden="true"
/>
<span className="maf-donut__legendLabel">{slice.label}</span>
<span className="maf-donut__legendValue">
{slice.value.toLocaleString()}
</span>
</li>
))}
</ul>
</div>
</div>
);
}
.maf-donut {
display: grid;
gap: 10px;
}
.maf-donut__title {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-1, rgba(110, 90, 68, 0.96));
}
.maf-donut__content {
display: grid;
grid-template-columns: auto 1fr;
gap: 14px;
align-items: center;
}
.maf-donut__svg {
display: block;
filter: drop-shadow(0 10px 22px rgba(16, 12, 8, 0.08));
}
.maf-donut__legend {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 8px;
min-width: 0;
}
.maf-donut__legendItem {
display: grid;
grid-template-columns: 10px 1fr auto;
align-items: center;
gap: 8px;
min-width: 0;
}
.maf-donut__swatch {
width: 10px;
height: 10px;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.16);
}
.maf-donut__legendLabel {
font-size: 0.86rem;
color: var(--text-2, rgba(110, 90, 68, 0.78));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.maf-donut__legendValue {
font-size: 0.86rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-1, rgba(110, 90, 68, 0.96));
}
@media (max-width: 768px) {
.maf-donut__content {
grid-template-columns: 1fr;
justify-items: start;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { IncidentsByCountry } from "./IncidentsByCountry";
const meta = {
title: "Panel/Dashboard/IncidentsByCountry",
component: IncidentsByCountry,
parameters: { layout: "padded" },
} satisfies Meta<typeof IncidentsByCountry>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [
{ country: "UAE", value: 38 },
{ country: "KSA", value: 29 },
{ country: "Egypt", value: 21 },
{ country: "Oman", value: 12 },
{ country: "Lebanon", value: 7 },
],
},
render: (args) => (
<div style={{ maxWidth: 520 }}>
<IncidentsByCountry {...args} />
</div>
),
};
export const Mobile: Story = {
args: {
items: [
{ country: "UAE", value: 38 },
{ country: "KSA", value: 29 },
{ country: "Egypt", value: 21 },
{ country: "Oman", value: 12 },
{ country: "Lebanon", value: 7 },
],
},
parameters: { viewport: { defaultViewport: "mobile1" } },
render: (args) => (
<div style={{ maxWidth: 520 }}>
<IncidentsByCountry {...args} />
</div>
),
};
import "./incidents-by-country.css";
export type CountryIncident = {
country: string;
value: number;
};
type IncidentsByCountryProps = {
title?: string;
items: CountryIncident[];
};
export function IncidentsByCountry({
title = "Incidents by country",
items,
}: IncidentsByCountryProps) {
const max = Math.max(1, ...items.map((i) => i.value));
return (
<section className="maf-country">
<header className="maf-country__head">
<h3 className="maf-country__title">{title.toUpperCase()}</h3>
</header>
<ul className="maf-country__list" aria-label={title}>
{items.map((item) => (
<li key={item.country} className="maf-country__row">
<span className="maf-country__name">{item.country}</span>
<span className="maf-country__barWrap" aria-hidden="true">
<span
className="maf-country__bar"
style={{ width: `${(item.value / max) * 100}%` }}
/>
</span>
<span className="maf-country__value">{item.value}</span>
</li>
))}
</ul>
</section>
);
}
.maf-country {
display: grid;
gap: 10px;
}
.maf-country__title {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-1, rgba(110, 90, 68, 0.96));
}
.maf-country__list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px;
}
.maf-country__row {
display: grid;
grid-template-columns: 64px 1fr 36px;
gap: 10px;
align-items: center;
min-width: 0;
}
.maf-country__name {
font-size: 0.86rem;
color: var(--text-2, rgba(110, 90, 68, 0.78));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.maf-country__barWrap {
height: 10px;
border-radius: 999px;
background: rgba(110, 90, 68, 0.1);
border: 1px solid rgba(110, 90, 68, 0.12);
overflow: hidden;
}
.maf-country__bar {
height: 100%;
display: block;
border-radius: 999px;
background: linear-gradient(90deg, rgba(110, 90, 68, 0.68), rgba(201, 169, 97, 0.7));
}
.maf-country__value {
text-align: right;
font-size: 0.86rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--text-1, rgba(110, 90, 68, 0.96));
}
@media (max-width: 768px) {
.maf-country__row {
grid-template-columns: 56px 1fr 34px;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { KpiTile } from "./KpiTile";
const meta = {
title: "Panel/Dashboard/KpiTile",
component: KpiTile,
parameters: { layout: "padded" },
} satisfies Meta<typeof KpiTile>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Neutral: Story = {
args: {
label: "Avg. audit score",
value: "84%",
hint: "Across sites",
tone: "neutral",
sparkline: [78, 81, 79, 83, 85, 84, 84],
},
render: (args) => (
<div style={{ maxWidth: 360 }}>
<KpiTile {...args} />
</div>
),
};
export const Good: Story = {
args: {
label: "Audits completed",
value: 317,
hint: "Last 30 days",
tone: "good",
sparkline: [210, 235, 248, 265, 289, 302, 317],
},
render: (args) => (
<div style={{ maxWidth: 360 }}>
<KpiTile {...args} />
</div>
),
};
export const Mobile: Story = {
args: {
label: "Open actions",
value: 89,
hint: "Needs attention",
tone: "warn",
sparkline: [72, 74, 80, 86, 83, 88, 89],
},
parameters: { viewport: { defaultViewport: "mobile1" } },
render: (args) => (
<div style={{ maxWidth: 360 }}>
<KpiTile {...args} />
</div>
),
};
import type React from "react";
import "./kpi-tile.css";
type KpiTone = "neutral" | "good" | "warn" | "bad" | "info";
type KpiTileProps = {
label: string;
value: string | number;
hint?: string;
tone?: KpiTone;
icon?: React.ReactNode;
sparkline?: number[];
};
function formatValue(value: string | number) {
if (typeof value === "number") return value.toLocaleString();
return value;
}
function Sparkline({ values, tone }: { values: number[]; tone: KpiTone }) {
const width = 96;
const height = 26;
const pad = 2;
const min = Math.min(...values);
const max = Math.max(...values);
const span = Math.max(1, max - min);
const points = values
.map((v, index) => {
const x = (index / Math.max(1, values.length - 1)) * (width - pad * 2) + pad;
const y = height - pad - ((v - min) / span) * (height - pad * 2);
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
return (
<svg
className={`maf-kpi__spark maf-kpi__spark--${tone}`}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden="true"
>
<polyline points={points} fill="none" />
</svg>
);
}
export function KpiTile({
label,
value,
hint,
tone = "neutral",
icon,
sparkline,
}: KpiTileProps) {
return (
<article className={`maf-kpi maf-kpi--${tone}`.trim()}>
<div className="maf-kpi__top">
<div className="maf-kpi__meta">
<span className="maf-kpi__label">{label}</span>
{hint ? <span className="maf-kpi__hint">{hint}</span> : null}
</div>
{icon ? <div className="maf-kpi__icon">{icon}</div> : null}
</div>
<div className="maf-kpi__bottom">
<span className="maf-kpi__value">{formatValue(value)}</span>
{sparkline && sparkline.length >= 2 ? (
<Sparkline values={sparkline} tone={tone} />
) : null}
</div>
</article>
);
}
.maf-kpi {
border-radius: var(--r-md, 16px);
border: 1px solid rgba(110, 90, 68, 0.18);
background:
radial-gradient(520px 220px at 20% 0%, rgba(167, 138, 104, 0.14), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 246, 240, 0.98));
padding: 14px 14px 12px;
min-width: 0;
display: grid;
gap: 10px;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.maf-kpi:hover {
transform: translateY(-1px);
border-color: rgba(110, 90, 68, 0.28);
background:
radial-gradient(560px 240px at 20% 0%, rgba(167, 138, 104, 0.18), transparent 62%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 246, 240, 0.98));
}
.maf-kpi__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.maf-kpi__meta {
min-width: 0;
display: grid;
gap: 4px;
}
.maf-kpi__label {
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3, rgba(255, 255, 255, 0.62));
}
.maf-kpi__hint {
font-size: 0.82rem;
line-height: 1.2;
color: var(--text-2, rgba(255, 255, 255, 0.82));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 20rem;
}
.maf-kpi__icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
background: rgba(167, 138, 104, 0.12);
border: 1px solid rgba(110, 90, 68, 0.18);
color: rgba(110, 90, 68, 0.92);
flex: 0 0 auto;
}
.maf-kpi__bottom {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 10px;
}
.maf-kpi__value {
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
font-size: 1.9rem;
line-height: 1;
font-weight: 800;
color: var(--text-1, rgba(110, 90, 68, 0.96));
}
.maf-kpi__spark {
opacity: 0.95;
}
.maf-kpi__spark polyline {
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.maf-kpi__spark--neutral polyline {
stroke: rgba(110, 90, 68, 0.82);
}
.maf-kpi__spark--good polyline {
stroke: rgba(34, 197, 94, 0.95);
}
.maf-kpi__spark--info polyline {
stroke: rgba(59, 130, 246, 0.95);
}
.maf-kpi__spark--warn polyline {
stroke: rgba(245, 158, 11, 0.98);
}
.maf-kpi__spark--bad polyline {
stroke: rgba(239, 68, 68, 0.98);
}
.maf-kpi--good {
border-color: rgba(34, 197, 94, 0.22);
background:
radial-gradient(520px 220px at 20% 0%, rgba(34, 197, 94, 0.08), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 246, 240, 0.98));
}
.maf-kpi--warn {
border-color: rgba(245, 158, 11, 0.28);
background:
radial-gradient(520px 220px at 20% 0%, rgba(245, 158, 11, 0.09), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 246, 240, 0.98));
}
.maf-kpi--bad {
border-color: rgba(239, 68, 68, 0.26);
background:
radial-gradient(520px 220px at 20% 0%, rgba(239, 68, 68, 0.08), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 246, 240, 0.98));
}
.maf-kpi--info {
border-color: rgba(59, 130, 246, 0.24);
background:
radial-gradient(520px 220px at 20% 0%, rgba(59, 130, 246, 0.07), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 246, 240, 0.98));
}
@media (max-width: 768px) {
.maf-kpi {
border-radius: var(--r-md, 14px);
padding: 12px 12px 11px;
}
.maf-kpi__value {
font-size: 1.55rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { MetricCard } from "./MetricCard";
const meta = {
title: "Panel/Dashboard/MetricCard",
component: MetricCard,
parameters: { layout: "padded" },
} satisfies Meta<typeof MetricCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Incidents: Story = {
args: {
title: "Incidents",
value: 142,
sublabel: "this month",
badge: "14 LTI",
tone: "info",
icon: "△",
},
render: (args) => (
<div style={{ maxWidth: 340 }}>
<MetricCard {...args} />
</div>
),
};
export const Mobile: Story = {
args: {
title: "Tracking",
value: 89,
sublabel: "open actions",
badge: "31 overdue",
tone: "warn",
icon: "▣",
},
parameters: { viewport: { defaultViewport: "mobile1" } },
render: (args) => (
<div style={{ maxWidth: 340 }}>
<MetricCard {...args} />
</div>
),
};
import "./metric-card.css";
type MetricTone =
| "neutral"
| "info"
| "good"
| "warn"
| "bad"
| "lavender"
| "teal"
| "pink";
type MetricCardProps = {
title: string;
value: number | string;
sublabel?: string;
badge?: string;
tone?: MetricTone;
icon?: string;
};
function formatValue(value: number | string) {
if (typeof value === "number") return value.toLocaleString();
return value;
}
export function MetricCard({
title,
value,
sublabel,
badge,
tone = "neutral",
icon,
}: MetricCardProps) {
return (
<article className={`maf-metric maf-metric--${tone}`.trim()}>
<div className="maf-metric__head">
<span className="maf-metric__icon" aria-hidden="true">
{icon ?? "●"}
</span>
<span className="maf-metric__title">{title}</span>
</div>
<div className="maf-metric__body">
<span className="maf-metric__value">{formatValue(value)}</span>
<div className="maf-metric__metaRow">
{sublabel ? <span className="maf-metric__sublabel">{sublabel}</span> : null}
{badge ? <span className="maf-metric__badge">{badge}</span> : null}
</div>
</div>
</article>
);
}
.maf-metric {
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(255, 255, 255, 0.92);
padding: 12px 12px;
display: grid;
gap: 10px;
min-width: 0;
box-shadow: 0 14px 34px rgba(16, 12, 8, 0.08);
}
.maf-metric__head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.maf-metric__icon {
width: 30px;
height: 30px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 0.95rem;
font-weight: 800;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(167, 138, 104, 0.14);
color: rgba(110, 90, 68, 0.9);
flex: 0 0 auto;
}
.maf-metric__title {
font-size: 0.92rem;
font-weight: 700;
color: var(--text-1, rgba(110, 90, 68, 0.96));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.maf-metric__body {
display: grid;
gap: 6px;
}
.maf-metric__value {
font-size: 1.35rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-metric__metaRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.maf-metric__sublabel {
font-size: 0.78rem;
color: var(--text-2, rgba(110, 90, 68, 0.78));
}
.maf-metric__badge {
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(167, 138, 104, 0.12);
color: rgba(110, 90, 68, 0.9);
font-weight: 700;
flex: 0 0 auto;
}
/* Tones (approx like screenshot chips) */
.maf-metric--info .maf-metric__icon {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.22);
color: rgba(37, 99, 235, 0.9);
}
.maf-metric--good .maf-metric__icon {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.2);
color: rgba(22, 163, 74, 0.9);
}
.maf-metric--warn .maf-metric__icon {
background: rgba(245, 158, 11, 0.14);
border-color: rgba(245, 158, 11, 0.22);
color: rgba(217, 119, 6, 0.9);
}
.maf-metric--bad .maf-metric__icon {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.2);
color: rgba(220, 38, 38, 0.9);
}
.maf-metric--lavender .maf-metric__icon {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.2);
color: rgba(79, 70, 229, 0.92);
}
.maf-metric--teal .maf-metric__icon {
background: rgba(20, 184, 166, 0.12);
border-color: rgba(20, 184, 166, 0.2);
color: rgba(13, 148, 136, 0.92);
}
.maf-metric--pink .maf-metric__icon {
background: rgba(236, 72, 153, 0.1);
border-color: rgba(236, 72, 153, 0.2);
color: rgba(219, 39, 119, 0.92);
}
@media (max-width: 768px) {
.maf-metric {
padding: 11px 11px;
}
.maf-metric__value {
font-size: 1.25rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { PanelDashboard } from "./PanelDashboard";
const meta = {
title: "Panel/Dashboard/PanelDashboard",
component: PanelDashboard,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof PanelDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = {
parameters: {
viewport: { defaultViewport: "tablet" },
},
};
export const Mobile: Story = {
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
"use client";
import React from "react";
import { DashboardTrendChart } from "@/components/shared/charts/DashboardTrendChart/DashboardTrendChart";
import { DashboardCard } from "../DashboardCard/DashboardCard";
import { DonutChart } from "../DonutChart/DonutChart";
import { MetricCard } from "../MetricCard/MetricCard";
import { TopStatCard } from "../TopStatCard/TopStatCard";
import { ActivityFeed } from "../ActivityFeed/ActivityFeed";
import { IncidentsByCountry } from "../IncidentsByCountry/IncidentsByCountry";
import "./panel-dashboard.css";
function IconBox({ children }: { children: React.ReactNode }) {
return (
<span className="maf-dash-icon" aria-hidden="true">
{children}
</span>
);
}
function MiniIcon({ d }: { d: string }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d={d}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
type PanelDashboardProps = {
title?: string;
subtitle?: string;
};
export function PanelDashboard({
title = "MAF Gateway",
subtitle = "Incidents overview and insights",
}: PanelDashboardProps) {
const now = React.useMemo(() => new Date(), []);
const rangeLabel = now.toLocaleString(undefined, {
month: "short",
year: "numeric",
});
const incidentDonut = React.useMemo(
() => [
{ label: "Slip & fall", value: 38, color: "#2f80ed" },
{ label: "Fire/safety", value: 27, color: "#f2994a" },
{ label: "Food poisoning", value: 19, color: "#f2c94c" },
{ label: "Equipment", value: 34, color: "#27ae60" },
{ label: "Other", value: 24, color: "#9b51e0" },
],
[],
);
const activity = React.useMemo(
() => [
{
id: "a1",
title: "Incident reported — HVAC: Camera room",
meta: "Priority escalated · Awaiting acknowledgement",
time: "2m",
tone: "warn" as const,
},
{
id: "a2",
title: "Incident closed — Slip & fall",
meta: "Resolution logged · Follow-up scheduled",
time: "1h",
tone: "good" as const,
},
{
id: "a3",
title: "Corrective action overdue — Fire exit checks",
meta: "Assigned to EHS · Reminder sent",
time: "3h",
tone: "bad" as const,
},
{
id: "a4",
title: "New incident type added — Equipment",
meta: "Classification updated · Reporting aligned",
time: "Yesterday",
tone: "info" as const,
},
],
[],
);
const incidentsByCountry = React.useMemo(
() => [
{ country: "UAE", value: 38 },
{ country: "KSA", value: 29 },
{ country: "Egypt", value: 21 },
{ country: "Oman", value: 12 },
{ country: "Lebanon", value: 7 },
],
[],
);
return (
<div className="maf-panel-dashboard">
<header className="maf-panel-dashboard__header">
<div className="maf-panel-dashboard__headerText">
<p className="maf-panel-dashboard__eyebrow">
Dashboard · {rangeLabel}
</p>
<h1 className="maf-panel-dashboard__title">{title}</h1>
<p className="maf-panel-dashboard__subtitle">{subtitle}</p>
</div>
<div className="maf-panel-dashboard__headerMeta" aria-label="Summary meta">
<div className="maf-panel-dashboard__status" aria-label="Status: Live">
<span className="maf-panel-dashboard__liveDot" aria-hidden="true" />
<span className="maf-panel-dashboard__statusText">Live</span>
</div>
</div>
</header>
<section className="maf-panel-dashboard__topStats" aria-label="Top summary cards">
<TopStatCard
label="Incidents"
value={142}
helper="this month"
deltaText="▲ 12 vs last month"
deltaTone="danger"
underlineTone="danger"
/>
<TopStatCard
label="Open actions"
value={89}
helper=""
deltaText="31 overdue"
deltaTone="danger"
underlineTone="warn"
/>
<TopStatCard
label="Audits done"
value={317}
helper=""
deltaText="▲ 9% vs last month"
deltaTone="good"
underlineTone="good"
/>
<TopStatCard
label="Avg audit score"
value="84%"
helper=""
deltaText="▲ 3pts improvement"
deltaTone="good"
underlineTone="info"
/>
</section>
<section className="maf-panel-dashboard__charts" aria-label="Incident charts">
<DashboardCard title="FR trend — Major vs LTI (rolling 7 months)">
<DashboardTrendChart
title="FR Trend — Major vs LTI (rolling 7 months)"
labels={["Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"]}
completed={[2.2, 1.9, 2.4, 2.0, 2.2, 1.7, 1.5]}
incomplete={[1.2, 0.9, 1.1, 1.4, 1.0, 0.8, 0.7]}
height={260}
showSeriesLabels={false}
/>
</DashboardCard>
<DashboardCard title="Incidents by type">
<DonutChart title="Incidents by type" slices={incidentDonut} />
</DashboardCard>
</section>
<section className="maf-panel-dashboard__secondary" aria-label="Additional insights">
<DashboardCard title="Recent activity">
<ActivityFeed items={activity} />
</DashboardCard>
<DashboardCard title="Incident by country">
<IncidentsByCountry items={incidentsByCountry} />
</DashboardCard>
</section>
<section className="maf-panel-dashboard__metricGrid" aria-label="Module summary cards">
<MetricCard
title="Incidents"
value={142}
sublabel="this month"
badge="14 LTI"
tone="info"
icon="△"
/>
<MetricCard
title="Audits"
value={317}
sublabel="completed"
badge="84% avg"
tone="good"
icon="✓"
/>
<MetricCard
title="Inspections"
value={188}
sublabel="completed"
badge="5 pending"
tone="warn"
icon="🔎"
/>
<MetricCard
title="Checklists"
value={94}
sublabel="completed"
badge="7 blocked"
tone="lavender"
icon="▦"
/>
<MetricCard
title="Contractors"
value={23}
sublabel="active permits"
badge="4 pending"
tone="bad"
icon="⌁"
/>
<MetricCard
title="Suggestions"
value={11}
sublabel="in review"
badge="3 approved"
tone="teal"
icon="✳"
/>
<MetricCard
title="Tracking"
value={89}
sublabel="open actions"
badge="31 overdue"
tone="warn"
icon="▣"
/>
<MetricCard
title="Notifications"
value={6}
sublabel="unread"
badge="2 escalated"
tone="pink"
icon="✉"
/>
</section>
</div>
);
}
.maf-panel-dashboard {
display: grid;
gap: 16px;
width: min(1180px, 100%);
margin: 0 auto;
}
.maf-panel-dashboard__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 6px 2px 2px;
}
.maf-panel-dashboard__headerText {
min-width: 0;
}
.maf-panel-dashboard__eyebrow {
margin: 0 0 8px;
font-size: 0.78rem;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-3, rgba(255, 255, 255, 0.56));
}
.maf-panel-dashboard__title {
margin: 0;
font-family: var(--font-serif, inherit);
letter-spacing: -0.02em;
font-size: clamp(1.55rem, 2.2vw, 2.25rem);
line-height: 1.1;
color: var(--text-1, rgba(255, 255, 255, 0.94));
}
.maf-panel-dashboard__subtitle {
margin: 10px 0 0;
font-size: 0.95rem;
line-height: 1.4;
color: var(--text-2, rgba(255, 255, 255, 0.66));
max-width: 72ch;
}
.maf-panel-dashboard__headerMeta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex: 0 0 auto;
}
.maf-panel-dashboard__status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.16);
background: rgba(255, 255, 255, 0.72);
}
.maf-panel-dashboard__liveDot {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(34, 197, 94, 0.95);
box-shadow:
0 0 0 3px rgba(34, 197, 94, 0.14),
0 6px 16px rgba(34, 197, 94, 0.18);
}
.maf-panel-dashboard__statusText {
font-size: 0.86rem;
font-weight: 750;
color: var(--text-1, rgba(110, 90, 68, 0.96));
}
.maf-panel-dashboard__kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.maf-panel-dashboard__charts {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 12px;
align-items: start;
}
.maf-panel-dashboard__topStats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.maf-panel-dashboard__secondary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
}
.maf-panel-dashboard__metricGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.maf-dash-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
}
@media (max-width: 1100px) {
.maf-panel-dashboard__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-panel-dashboard__charts {
grid-template-columns: 1fr;
}
.maf-panel-dashboard__topStats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-panel-dashboard__secondary {
grid-template-columns: 1fr;
}
.maf-panel-dashboard__metricGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.maf-panel-dashboard {
gap: 14px;
}
.maf-panel-dashboard__header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.maf-panel-dashboard__headerMeta {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.maf-panel-dashboard__pill {
min-width: unset;
flex: 1 1 auto;
}
.maf-panel-dashboard__kpis {
gap: 10px;
}
.maf-panel-dashboard__metricGrid {
gap: 10px;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { QuickLinkTile } from "./QuickLinkTile";
const meta = {
title: "Panel/Dashboard/QuickLinkTile",
component: QuickLinkTile,
parameters: { layout: "padded" },
} satisfies Meta<typeof QuickLinkTile>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Incidents",
description: "Report, triage and resolve",
href: "/panel/incidents",
badge: "new",
},
render: (args) => (
<div style={{ maxWidth: 520 }}>
<QuickLinkTile {...args} />
</div>
),
};
export const Mobile: Story = {
args: {
title: "Reports",
description: "Exports and analytics",
href: "/panel/reports",
badge: "beta",
},
parameters: { viewport: { defaultViewport: "mobile1" } },
render: (args) => (
<div style={{ maxWidth: 520 }}>
<QuickLinkTile {...args} />
</div>
),
};
import Link from "next/link";
import type React from "react";
import "./quick-link-tile.css";
type QuickLinkTileProps = {
title: string;
description?: string;
href: string;
icon?: React.ReactNode;
badge?: string;
};
export function QuickLinkTile({
title,
description,
href,
icon,
badge,
}: QuickLinkTileProps) {
return (
<Link href={href} className="maf-quick-tile">
<div className="maf-quick-tile__icon">{icon}</div>
<div className="maf-quick-tile__text">
<div className="maf-quick-tile__row">
<span className="maf-quick-tile__title">{title}</span>
{badge ? <span className="maf-quick-tile__badge">{badge}</span> : null}
</div>
{description ? (
<span className="maf-quick-tile__desc">{description}</span>
) : null}
</div>
</Link>
);
}
.maf-quick-tile {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: var(--r-md, 16px);
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(255, 255, 255, 0.86);
text-decoration: none;
color: inherit;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
min-width: 0;
}
.maf-quick-tile:hover {
transform: translateY(-1px);
border-color: rgba(110, 90, 68, 0.28);
background: rgba(250, 246, 240, 0.98);
}
.maf-quick-tile:focus-visible {
outline: none;
box-shadow: var(--shadow-focus, 0 0 0 3px rgba(201, 169, 97, 0.25));
}
.maf-quick-tile__icon {
width: 44px;
height: 44px;
border-radius: var(--r-md, 14px);
display: grid;
place-items: center;
background: rgba(167, 138, 104, 0.14);
border: 1px solid rgba(110, 90, 68, 0.18);
color: rgba(110, 90, 68, 0.9);
flex: 0 0 auto;
}
.maf-quick-tile__text {
min-width: 0;
display: grid;
gap: 4px;
}
.maf-quick-tile__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.maf-quick-tile__title {
font-size: 0.95rem;
line-height: 1.2;
font-weight: 700;
color: var(--text-1, rgba(110, 90, 68, 0.96));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.maf-quick-tile__badge {
font-size: 0.74rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 6px 9px;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(167, 138, 104, 0.14);
color: rgba(110, 90, 68, 0.86);
flex: 0 0 auto;
}
.maf-quick-tile__desc {
font-size: 0.84rem;
line-height: 1.35;
color: var(--text-2, rgba(110, 90, 68, 0.78));
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
@media (max-width: 768px) {
.maf-quick-tile {
border-radius: var(--r-md, 14px);
padding: 11px 12px;
}
.maf-quick-tile__icon {
width: 42px;
height: 42px;
border-radius: var(--r-md, 13px);
}
.maf-quick-tile__title {
font-size: 0.92rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { TopStatCard } from "./TopStatCard";
const meta = {
title: "Panel/Dashboard/TopStatCard",
component: TopStatCard,
parameters: { layout: "padded" },
} satisfies Meta<typeof TopStatCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Incidents: Story = {
args: {
label: "Incidents",
value: 142,
helper: "this month",
deltaText: "▲ 12 vs last month",
deltaTone: "danger",
underlineTone: "danger",
},
render: (args) => (
<div style={{ maxWidth: 320 }}>
<TopStatCard {...args} />
</div>
),
};
export const Mobile: Story = {
args: {
label: "Avg audit score",
value: "84%",
deltaText: "▲ 3pts improvement",
deltaTone: "good",
underlineTone: "info",
},
parameters: { viewport: { defaultViewport: "mobile1" } },
render: (args) => (
<div style={{ maxWidth: 320 }}>
<TopStatCard {...args} />
</div>
),
};
import "./top-stat-card.css";
type StatTone = "danger" | "warn" | "good" | "neutral" | "info";
type TopStatCardProps = {
label: string;
value: string | number;
helper?: string;
deltaText?: string;
deltaTone?: StatTone;
underlineTone?: StatTone;
};
function formatValue(value: string | number) {
if (typeof value === "number") return value.toLocaleString();
return value;
}
export function TopStatCard({
label,
value,
helper,
deltaText,
deltaTone = "neutral",
underlineTone = "neutral",
}: TopStatCardProps) {
return (
<article className={`maf-topstat maf-topstat--u-${underlineTone}`.trim()}>
<div className="maf-topstat__label">{label}</div>
<div className="maf-topstat__value">{formatValue(value)}</div>
<div className="maf-topstat__bottom">
{helper ? <span className="maf-topstat__helper">{helper}</span> : null}
{deltaText ? (
<span className={`maf-topstat__delta maf-topstat__delta--${deltaTone}`.trim()}>
{deltaText}
</span>
) : null}
</div>
</article>
);
}
.maf-topstat {
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.9);
padding: 12px 12px 10px;
display: grid;
gap: 6px;
min-width: 0;
position: relative;
overflow: hidden;
}
.maf-topstat::after {
content: "";
position: absolute;
left: 10px;
right: 10px;
bottom: 0;
height: 3px;
border-radius: 999px;
background: rgba(110, 90, 68, 0.28);
}
.maf-topstat--u-danger::after {
background: rgba(239, 68, 68, 0.7);
}
.maf-topstat--u-warn::after {
background: rgba(245, 158, 11, 0.75);
}
.maf-topstat--u-good::after {
background: rgba(34, 197, 94, 0.7);
}
.maf-topstat--u-info::after {
background: rgba(59, 130, 246, 0.7);
}
.maf-topstat--u-neutral::after {
background: rgba(110, 90, 68, 0.28);
}
.maf-topstat__label {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3, rgba(110, 90, 68, 0.62));
}
.maf-topstat__value {
font-size: 1.6rem;
font-weight: 800;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--text-1, rgba(110, 90, 68, 0.96));
line-height: 1.05;
}
.maf-topstat__bottom {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding-bottom: 6px;
}
.maf-topstat__helper {
font-size: 0.78rem;
color: var(--text-2, rgba(110, 90, 68, 0.78));
}
.maf-topstat__delta {
font-size: 0.76rem;
font-weight: 700;
white-space: nowrap;
}
.maf-topstat__delta--danger {
color: rgba(185, 28, 28, 0.92);
}
.maf-topstat__delta--warn {
color: rgba(180, 83, 9, 0.92);
}
.maf-topstat__delta--good {
color: rgba(21, 128, 61, 0.92);
}
.maf-topstat__delta--info {
color: rgba(29, 78, 216, 0.92);
}
.maf-topstat__delta--neutral {
color: rgba(110, 90, 68, 0.82);
}
@media (max-width: 768px) {
.maf-topstat__value {
font-size: 1.45rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { IncidentsDashboard } from "./IncidentsDashboard";
const meta = {
title: "Panel/Modules/Incidents/Dashboard",
component: IncidentsDashboard,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof IncidentsDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = { parameters: { viewport: { defaultViewport: "tablet" } } };
export const Mobile: Story = { parameters: { viewport: { defaultViewport: "mobile1" } } };
"use client";
import React from "react";
import { TopStatCard } from "@/components/panel/dashboard/TopStatCard/TopStatCard";
import { DualLineTrendChart } from "@/components/shared/charts/DualLineTrendChart/DualLineTrendChart";
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import "./incidents-dashboard.css";
export function IncidentsDashboard() {
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const major = [14, 5, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const lti = [1, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
return (
<div className="maf-modDash">
<header className="maf-modDash__header">
<div className="maf-modDash__titleBlock">
<h1 className="maf-modDash__title">Incidents</h1>
<p className="maf-modDash__subtitle">Today’s snapshot and progress trend across the year.</p>
</div>
</header>
<ModuleDashboardMenu
ariaLabel="Incidents navigation"
items={[
{ label: "Dashboard", href: "/panel/incidents", active: true },
{ label: "New incident", href: "/panel/incidents/new" },
{ label: "Incident tracking", href: "/panel/incidents/tracking" },
{ label: "Incident listing", href: "/panel/incidents/list" },
{ label: "Monthly data", href: "/panel/incidents/monthly" },
]}
/>
<section className="maf-modDash__kpis" aria-label="Incident KPIs">
<TopStatCard label="Total incidents" value={770} helper="This month" underlineTone="neutral" />
<TopStatCard label="Major incidents" value={28} helper="This month" underlineTone="warn" deltaTone="warn" deltaText="Focus" />
<TopStatCard label="LTI%" value={6} helper="Rolling 30 days" underlineTone="info" />
<TopStatCard label="Critical severity" value={2} helper="Open now" underlineTone="danger" deltaTone="danger" deltaText="Immediate" />
</section>
<section className="maf-modDash__grid" aria-label="Incidents dashboard content">
<DashboardCard title="Incident Progress" subtitle="Major vs LTI trend">
<DualLineTrendChart
title="Incident Progress"
labels={months}
seriesA={major}
seriesB={lti}
seriesALabel="Major Injury Accident"
seriesBLabel="LTI - Lost Time Injury"
height={320}
/>
</DashboardCard>
</section>
</div>
);
}
.maf-modDash {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-modDash__header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-modDash__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-modDash__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-modDash__kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
}
.maf-modDash__grid {
margin-top: 12px;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 12px;
padding: 0.2rem;
align-items: start;
}
.maf-modDash__actions {
display: grid;
gap: 10px;
}
.maf-modDash__actionBtn {
border-radius: 14px;
border: 1px solid rgba(201, 169, 97, 0.45);
background: rgba(139, 21, 56, 0.92);
color: rgba(255, 255, 255, 0.96);
padding: 12px 12px;
font-weight: 900;
cursor: pointer;
box-shadow:
0 18px 44px rgba(139, 21, 56, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
text-align: left;
}
.maf-modDash__actionBtn:hover {
background: rgba(107, 15, 42, 0.94);
}
.maf-modDash__actionBtn--ghost {
background: rgba(255, 255, 255, 0.84);
color: rgba(46, 39, 32, 0.96);
border: 1px solid rgba(110, 90, 68, 0.18);
box-shadow: 0 18px 44px rgba(16, 12, 8, 0.08);
}
.maf-modDash__actionBtn--ghost:hover {
background: rgba(250, 246, 240, 0.98);
}
@media (max-width: 1024px) {
.maf-modDash__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-modDash__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.maf-modDash {
padding: 0.9rem 0.65rem 1.1rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { InspectionDashboard } from "./InspectionDashboard";
const meta = {
title: "Panel/Modules/Inspection/Dashboard",
component: InspectionDashboard,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof InspectionDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = { parameters: { viewport: { defaultViewport: "tablet" } } };
export const Mobile: Story = { parameters: { viewport: { defaultViewport: "mobile1" } } };
"use client";
import React from "react";
import { TopStatCard } from "@/components/panel/dashboard/TopStatCard/TopStatCard";
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
import { DashboardTrendChart } from "@/components/shared/charts/DashboardTrendChart/DashboardTrendChart";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import "./inspection-dashboard.css";
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
export function InspectionDashboard() {
const completed = [690, 612, 612, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const incomplete = [22, 28, 31, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const upcoming = [
{ name: "MAF CITY CENTRE MALL", location: "MAF CITY CENTRE MALL", date: "01-01-2023", status: "ACTIVE" },
{ name: "MAF CITY CENTRE MALL", location: "MAF CITY CENTRE MALL", date: "19-03-2023", status: "ACTIVE" },
{ name: "MAF CITY CENTRE MALL", location: "MAF CITY CENTRE MALL", date: "27-01-2023", status: "ACTIVE" },
];
return (
<div className="maf-inspDash">
<header className="maf-inspDash__header">
<div>
<h1 className="maf-inspDash__title">Inspection</h1>
<p className="maf-inspDash__subtitle">Operational inspections, actions and schedule overview.</p>
</div>
</header>
<ModuleDashboardMenu
ariaLabel="Inspection navigation"
items={[
{ label: "Dashboard", href: "/panel/inspection", active: true },
{ label: "Conduct inspection", href: "/panel/inspection/conduct" },
{ label: "Inspection tracking", href: "/panel/inspection/tracking" },
{ label: "Inspection list", href: "/panel/inspection/list" },
]}
/>
<section className="maf-inspDash__kpis" aria-label="Inspection KPIs">
<TopStatCard label="Total inspections" value={1989} helper="All time" underlineTone="neutral" />
<TopStatCard label="Completed inspections" value={1902} helper="All time" underlineTone="good" />
<TopStatCard label="Incomplete inspections" value={87} helper="All time" underlineTone="warn" />
<TopStatCard label="Actions" value={1272} helper="All time" underlineTone="info" />
<TopStatCard label="Open actions" value={905} helper="Pending" underlineTone="warn" />
<TopStatCard label="Closed actions" value={367} helper="Resolved" underlineTone="good" />
</section>
<section className="maf-inspDash__grid" aria-label="Inspection dashboard content">
<DashboardCard title="Inspection Dashboard Progress" subtitle="Completed vs incomplete">
<DashboardTrendChart
title="Inspection Dashboard Progress"
labels={months}
completed={completed}
incomplete={incomplete}
height={320}
showSeriesLabels
/>
</DashboardCard>
<DashboardCard title="Upcoming inspection" subtitle="Next scheduled">
<div className="maf-inspDash__table" role="table" aria-label="Upcoming inspection table">
<div className="maf-inspDash__thead" role="row">
<div role="columnheader">Inspection</div>
<div role="columnheader">Location</div>
<div role="columnheader">Date</div>
<div role="columnheader">Status</div>
</div>
{upcoming.map((u) => (
<div key={`${u.name}-${u.date}`} className="maf-inspDash__row" role="row">
<div role="cell" className="maf-inspDash__cellTitle">{u.name}</div>
<div role="cell">{u.location}</div>
<div role="cell">{u.date}</div>
<div role="cell">
<span className="maf-inspDash__pill">{u.status}</span>
</div>
</div>
))}
</div>
</DashboardCard>
</section>
</div>
);
}
.maf-inspDash {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-inspDash__header {
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-inspDash__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-inspDash__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-inspDash__kpis {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
}
.maf-inspDash__grid {
margin-top: 12px;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 12px;
padding: 0.2rem;
align-items: start;
}
.maf-inspDash__table {
display: grid;
gap: 8px;
}
.maf-inspDash__thead {
display: grid;
grid-template-columns: 1.1fr 1fr 0.7fr 0.55fr;
gap: 10px;
font-size: 0.75rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(110, 90, 68, 0.62);
}
.maf-inspDash__row {
display: grid;
grid-template-columns: 1.1fr 1fr 0.7fr 0.55fr;
gap: 10px;
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.84);
padding: 10px 10px;
color: rgba(110, 90, 68, 0.86);
}
.maf-inspDash__cellTitle {
font-weight: 900;
color: rgba(46, 39, 32, 0.96);
}
.maf-inspDash__pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(34, 197, 94, 0.24);
background: rgba(34, 197, 94, 0.1);
color: rgba(21, 128, 61, 0.92);
font-size: 0.75rem;
font-weight: 900;
letter-spacing: 0.04em;
}
@media (max-width: 1200px) {
.maf-inspDash__kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.maf-inspDash__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-inspDash__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.maf-inspDash__thead {
display: none;
}
.maf-inspDash__row {
grid-template-columns: 1fr;
gap: 6px;
}
.maf-inspDash__pill {
justify-self: start;
}
}
@media (max-width: 520px) {
.maf-inspDash {
padding: 0.9rem 0.65rem 1.1rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ModuleCard } from "./ModuleCard";
const meta = {
title: "Panel/Modules/ModuleCard",
component: ModuleCard,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof ModuleCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Incidents",
description: "Report, classify and resolve incidents with fast escalation paths.",
href: "/panel/incidents",
badge: "Hot",
meta: "Last updated today",
},
};
export const Mobile: Story = {
args: Default.args,
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
import Link from "next/link";
import type React from "react";
import "./module-card.css";
export type ModuleCardProps = {
title: string;
description: string;
href: string;
icon?: React.ReactNode;
badge?: string;
meta?: string;
};
export function ModuleCard({
title,
description,
href,
icon,
badge,
meta,
}: ModuleCardProps) {
return (
<Link className="maf-module-card" href={href}>
<div className="maf-module-card__head">
<div className="maf-module-card__icon" aria-hidden="true">
{icon ?? "▦"}
</div>
<div className="maf-module-card__headText">
<div className="maf-module-card__row">
<span className="maf-module-card__title">{title}</span>
{badge ? <span className="maf-module-card__badge">{badge}</span> : null}
</div>
{meta ? <span className="maf-module-card__meta">{meta}</span> : null}
</div>
</div>
<p className="maf-module-card__desc">{description}</p>
<span className="maf-module-card__cta" aria-hidden="true">
Open →
</span>
</Link>
);
}
.maf-module-card {
border-radius: 16px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(255, 255, 255, 0.92);
padding: 14px 14px;
display: grid;
gap: 10px;
text-decoration: none;
color: inherit;
box-shadow: 0 18px 44px rgba(16, 12, 8, 0.08);
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease,
background 160ms ease;
min-width: 0;
}
.maf-module-card:hover {
transform: translateY(-1px);
border-color: rgba(110, 90, 68, 0.28);
background: rgba(250, 246, 240, 0.98);
box-shadow: 0 22px 54px rgba(16, 12, 8, 0.1);
}
.maf-module-card:focus-visible {
outline: none;
box-shadow:
0 22px 54px rgba(16, 12, 8, 0.1),
0 0 0 3px rgba(201, 169, 97, 0.38);
}
.maf-module-card__head {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: start;
min-width: 0;
}
.maf-module-card__icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
background: rgba(167, 138, 104, 0.14);
border: 1px solid rgba(110, 90, 68, 0.18);
color: rgba(110, 90, 68, 0.9);
flex: 0 0 auto;
}
.maf-module-card__headText {
min-width: 0;
display: grid;
gap: 4px;
}
.maf-module-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.maf-module-card__title {
font-size: 1rem;
font-weight: 850;
color: rgba(46, 39, 32, 0.96);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.maf-module-card__badge {
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(201, 169, 97, 0.35);
background: rgba(201, 169, 97, 0.14);
color: rgba(110, 90, 68, 0.92);
flex: 0 0 auto;
}
.maf-module-card__meta {
font-size: 0.82rem;
color: rgba(110, 90, 68, 0.7);
}
.maf-module-card__desc {
margin: 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.78);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.maf-module-card__cta {
font-size: 0.86rem;
font-weight: 850;
color: rgba(139, 21, 56, 0.9);
}
@media (max-width: 520px) {
.maf-module-card {
border-radius: 14px;
padding: 12px 12px;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ModuleDashboardMenu } from "./ModuleDashboardMenu";
const meta = {
title: "Panel/Modules/ModuleDashboardMenu",
component: ModuleDashboardMenu,
parameters: { layout: "padded" },
} satisfies Meta<typeof ModuleDashboardMenu>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [
{ label: "Dashboard", href: "/panel/incidents", active: true },
{ label: "New incident", href: "/panel/incidents/new" },
{ label: "Incident tracking", href: "/panel/incidents/tracking" },
{ label: "Incident listing", href: "/panel/incidents/list" },
{ label: "Monthly data", href: "/panel/incidents/monthly" },
],
},
};
export const Mobile: Story = {
args: Default.args,
parameters: { viewport: { defaultViewport: "mobile1" } },
};
"use client";
import Link from "next/link";
import React from "react";
import "./module-dashboard-menu.css";
export type ModuleMenuItem = {
label: string;
href: string;
active?: boolean;
};
export type ModuleDashboardMenuProps = {
items: ModuleMenuItem[];
ariaLabel?: string;
};
export function ModuleDashboardMenu({
items,
ariaLabel = "Module navigation",
}: ModuleDashboardMenuProps) {
return (
<nav className="maf-modMenu" aria-label={ariaLabel}>
<div className="maf-modMenu__scroll">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={`maf-modMenu__item ${item.active ? "is-active" : ""}`.trim()}
aria-current={item.active ? "page" : undefined}
>
{item.label}
</Link>
))}
</div>
</nav>
);
}
.maf-modMenu {
margin: 0.2rem 0.2rem 0.85rem;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.16);
background: rgba(255, 255, 255, 0.78);
box-shadow:
0 18px 44px rgba(16, 12, 8, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.68);
overflow: hidden;
}
.maf-modMenu__scroll {
display: flex;
gap: 6px;
padding: 8px 8px;
overflow: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.maf-modMenu__scroll::-webkit-scrollbar {
display: none;
}
.maf-modMenu__item {
text-decoration: none;
border-radius: 999px;
padding: 9px 12px;
font-size: 0.82rem;
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(110, 90, 68, 0.86);
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(250, 246, 240, 0.72);
white-space: nowrap;
transition:
transform 160ms ease,
background 160ms ease,
border-color 160ms ease;
}
.maf-modMenu__item:hover {
transform: translateY(-0.5px);
background: rgba(250, 246, 240, 0.96);
border-color: rgba(110, 90, 68, 0.22);
}
.maf-modMenu__item.is-active {
background: rgba(201, 169, 97, 0.18);
border-color: rgba(201, 169, 97, 0.46);
color: rgba(46, 39, 32, 0.96);
}
.maf-modMenu__item:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(201, 169, 97, 0.32);
}
@media (max-width: 520px) {
.maf-modMenu {
margin: 0.1rem 0.1rem 0.75rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ModulesPage } from "./ModulesPage";
const meta = {
title: "Panel/Modules/ModulesPage",
component: ModulesPage,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof ModulesPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = {
parameters: {
viewport: { defaultViewport: "tablet" },
},
};
export const Mobile: Story = {
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
"use client";
import React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ModuleCard } from "../ModuleCard/ModuleCard";
import { moduleCategories, modules } from "../modules-data";
import "./modules-page.css";
export function ModulesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const urlQuery = (searchParams.get("q") ?? "").trim();
const [query, setQuery] = React.useState(urlQuery);
const [category, setCategory] = React.useState<(typeof moduleCategories)[number]>(
"All",
);
React.useEffect(() => {
setQuery(urlQuery);
}, [urlQuery]);
React.useEffect(() => {
const q = query.trim();
const next = new URLSearchParams(searchParams.toString());
if (q) next.set("q", q);
else next.delete("q");
const qs = next.toString();
router.replace(qs ? `/panel/modules?${qs}` : "/panel/modules");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
return modules.filter((m) => {
const inCat = category === "All" ? true : m.category === category;
if (!inCat) return false;
if (!q) return true;
return (
m.title.toLowerCase().includes(q) ||
m.description.toLowerCase().includes(q) ||
m.category.toLowerCase().includes(q)
);
});
}, [query, category]);
return (
<div className="maf-modules">
<header className="maf-modules__header">
<div className="maf-modules__titleBlock">
<h1 className="maf-modules__title">Modules</h1>
<p className="maf-modules__subtitle">
Find what you need fast. Search, filter, then jump into a workflow.
</p>
</div>
<div className="maf-modules__controls" aria-label="Module controls">
<label className="maf-modules__search">
<span className="maf-modules__srOnly">Search modules</span>
<span className="maf-modules__searchIcon" aria-hidden="true">
</span>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search modules…"
inputMode="search"
/>
{query ? (
<button
type="button"
className="maf-modules__clear"
onClick={() => setQuery("")}
aria-label="Clear search"
>
×
</button>
) : null}
</label>
<div className="maf-modules__chips" role="tablist" aria-label="Categories">
{moduleCategories.map((c) => (
<button
key={c}
type="button"
role="tab"
aria-selected={category === c}
className={`maf-modules__chip ${category === c ? "is-active" : ""}`.trim()}
onClick={() => setCategory(c)}
>
{c}
</button>
))}
</div>
</div>
</header>
{filtered.length === 0 ? (
<div className="maf-modules__empty" role="status" aria-live="polite">
<div className="maf-modules__emptyTitle">No modules found</div>
<div className="maf-modules__emptyBody">
Try a different search term or clear filters.
</div>
<div className="maf-modules__emptyActions">
<button
type="button"
className="maf-modules__emptyBtn"
onClick={() => {
setQuery("");
setCategory("All");
}}
>
Reset
</button>
</div>
</div>
) : (
<section className="maf-modules__grid" aria-label="Modules list">
{filtered.map((m) => (
<ModuleCard
key={m.id}
title={m.title}
description={m.description}
href={m.href}
badge={m.badge}
meta={m.meta ?? m.category}
/>
))}
</section>
)}
</div>
);
}
.maf-modules {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-modules__header {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-modules__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-modules__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-modules__controls {
display: grid;
gap: 10px;
}
.maf-modules__search {
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(255, 255, 255, 0.72);
padding: 10px 12px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
box-shadow:
0 16px 38px rgba(16, 12, 8, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.maf-modules__search:focus-within {
border-color: rgba(201, 169, 97, 0.55);
box-shadow:
0 16px 38px rgba(16, 12, 8, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72),
0 0 0 3px rgba(201, 169, 97, 0.32);
}
.maf-modules__searchIcon {
width: 26px;
height: 26px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(167, 138, 104, 0.14);
border: 1px solid rgba(110, 90, 68, 0.16);
color: rgba(110, 90, 68, 0.86);
font-weight: 900;
}
.maf-modules__search input {
width: 100%;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: rgba(46, 39, 32, 0.96);
font-size: 0.95rem;
}
.maf-modules__search input::placeholder {
color: rgba(110, 90, 68, 0.58);
}
.maf-modules__clear {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.16);
background: rgba(255, 255, 255, 0.78);
color: rgba(110, 90, 68, 0.86);
font-size: 1.2rem;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
.maf-modules__clear:hover {
background: rgba(250, 246, 240, 0.98);
}
.maf-modules__chips {
display: flex;
gap: 8px;
overflow: auto;
padding: 2px 2px 4px;
-webkit-overflow-scrolling: touch;
}
.maf-modules__chip {
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(255, 255, 255, 0.7);
padding: 8px 12px;
font-size: 0.86rem;
font-weight: 820;
color: rgba(110, 90, 68, 0.86);
cursor: pointer;
white-space: nowrap;
transition:
background 160ms ease,
border-color 160ms ease,
transform 160ms ease;
}
.maf-modules__chip:hover {
transform: translateY(-0.5px);
background: rgba(250, 246, 240, 0.98);
border-color: rgba(110, 90, 68, 0.26);
}
.maf-modules__chip.is-active {
background: rgba(201, 169, 97, 0.16);
border-color: rgba(201, 169, 97, 0.45);
color: rgba(46, 39, 32, 0.96);
}
.maf-modules__grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
min-width: 0;
}
.maf-modules__empty {
margin: 0.3rem 0.2rem 0;
border-radius: 16px;
border: 1px dashed rgba(110, 90, 68, 0.26);
background: rgba(255, 255, 255, 0.62);
padding: 18px 14px;
display: grid;
gap: 8px;
}
.maf-modules__emptyTitle {
font-weight: 900;
color: rgba(46, 39, 32, 0.96);
}
.maf-modules__emptyBody {
color: rgba(110, 90, 68, 0.78);
}
.maf-modules__emptyActions {
margin-top: 4px;
}
.maf-modules__emptyBtn {
border-radius: 12px;
border: 1px solid rgba(201, 169, 97, 0.42);
background: rgba(201, 169, 97, 0.14);
padding: 10px 12px;
font-weight: 850;
color: rgba(110, 90, 68, 0.92);
cursor: pointer;
}
.maf-modules__emptyBtn:hover {
background: rgba(201, 169, 97, 0.2);
}
.maf-modules__srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (min-width: 560px) {
.maf-modules__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 900px) {
.maf-modules__header {
grid-template-columns: 1.1fr 1fr;
align-items: end;
gap: 18px;
}
.maf-modules__controls {
justify-self: end;
width: min(520px, 100%);
}
.maf-modules__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
}
@media (min-width: 1200px) {
.maf-modules__grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
export type ModuleCategory = "Operations" | "Assurance" | "Compliance" | "Admin";
export type ModuleItem = {
id: string;
title: string;
description: string;
href: string;
badge?: string;
meta?: string;
category: ModuleCategory;
};
export const modules: ModuleItem[] = [
{
id: "incidents",
title: "Incidents",
description: "Report, classify and resolve incidents with fast escalation paths.",
href: "/panel/incidents",
badge: "Hot",
meta: "Last updated today",
category: "Operations",
},
{
id: "audits",
title: "Audits",
description: "Plan audits, assign checklists, track completion and export reports.",
href: "/panel/audits",
meta: "Rolling 30 days",
category: "Assurance",
},
{
id: "inspections",
title: "Inspection",
description: "Mobile-friendly inspections with offline support and evidence capture.",
href: "/panel/inspection",
badge: "Field",
meta: "Offline ready",
category: "Operations",
},
{
id: "checklist",
title: "Checklist",
description: "Daily/weekly checklists with blockers, approvals and audit trail.",
href: "/panel/checklist",
category: "Compliance",
},
{
id: "permit",
title: "Permit",
description: "Manage permits, approvals and contractor workflows in one place.",
href: "/panel/permit",
category: "Operations",
},
{
id: "suggestion",
title: "Suggestion",
description: "Capture improvement ideas and route them for review and action.",
href: "/panel/suggestion",
category: "Admin",
},
{
id: "tracking",
title: "Tracking",
description: "Track corrective actions, due dates and overdue items across sites.",
href: "/panel/tracking",
category: "Assurance",
},
{
id: "admin",
title: "Admin",
description: "Users, roles, configuration and operational control centre.",
href: "/panel/admin",
badge: "Restricted",
category: "Admin",
},
];
export const moduleCategories: Array<ModuleCategory | "All"> = [
"All",
"Operations",
"Assurance",
"Compliance",
"Admin",
];
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { PermitDashboard } from "./PermitDashboard";
const meta = {
title: "Panel/Modules/Permit/Dashboard",
component: PermitDashboard,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof PermitDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = { parameters: { viewport: { defaultViewport: "tablet" } } };
export const Mobile: Story = { parameters: { viewport: { defaultViewport: "mobile1" } } };
"use client";
import React from "react";
import { TopStatCard } from "@/components/panel/dashboard/TopStatCard/TopStatCard";
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import "./permit-dashboard.css";
type PermitKpi = {
label: string;
value: number;
helper?: string;
tone: "good" | "warn" | "info" | "danger" | "neutral";
};
const kpis: PermitKpi[] = [
{ label: "Approved permits", value: 1239, tone: "good", helper: "Active" },
{ label: "Needs change", value: 49, tone: "info", helper: "Returned" },
{ label: "Extension requested", value: 422, tone: "info", helper: "In review" },
{ label: "Rejected permits", value: 7, tone: "danger", helper: "This month" },
{ label: "Pending approval (permits)", value: 1476, tone: "warn", helper: "Waiting" },
{ label: "Pending approval (contractors)", value: 11, tone: "warn", helper: "Waiting" },
{ label: "Rejected contractors", value: 6, tone: "danger", helper: "This month" },
{ label: "Violations", value: 9, tone: "neutral", helper: "Open" },
];
export function PermitDashboard() {
return (
<div className="maf-permitDash">
<header className="maf-permitDash__header">
<div>
<h1 className="maf-permitDash__title">Permit</h1>
<p className="maf-permitDash__subtitle">
At-a-glance permit health. Prioritize approvals and exceptions.
</p>
</div>
</header>
<ModuleDashboardMenu
ariaLabel="Permit navigation"
items={[
{ label: "Dashboard", href: "/panel/permit", active: true },
{ label: "Create project", href: "/panel/permit/create-project" },
{ label: "Permit list", href: "/panel/permit/list" },
{ label: "Invite contractor", href: "/panel/permit/invite-contractor" },
{ label: "Contractor list", href: "/panel/permit/contractors" },
]}
/>
<section className="maf-permitDash__kpis" aria-label="Permit KPIs">
{kpis.map((k) => (
<TopStatCard
key={k.label}
label={k.label}
value={k.value}
helper={k.helper}
underlineTone={k.tone}
/>
))}
</section>
<section className="maf-permitDash__grid" aria-label="Permit dashboard content">
<DashboardCard title="Review queue" subtitle="What needs attention">
<div className="maf-permitDash__queue">
<div className="maf-permitDash__queueRow">
<span>Pending approvals</span>
<strong>{(1476 + 11).toLocaleString()}</strong>
</div>
<div className="maf-permitDash__queueRow">
<span>Extensions</span>
<strong>{(422).toLocaleString()}</strong>
</div>
<div className="maf-permitDash__queueRow">
<span>Needs change</span>
<strong>{(49).toLocaleString()}</strong>
</div>
<div className="maf-permitDash__queueRow">
<span>Violations</span>
<strong>{(9).toLocaleString()}</strong>
</div>
</div>
</DashboardCard>
</section>
</div>
);
}
.maf-permitDash {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-permitDash__header {
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-permitDash__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-permitDash__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-permitDash__kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
}
.maf-permitDash__grid {
margin-top: 12px;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 12px;
padding: 0.2rem;
align-items: start;
}
.maf-permitDash__actions {
display: grid;
gap: 10px;
}
.maf-permitDash__btn {
border-radius: 14px;
border: 1px solid rgba(201, 169, 97, 0.45);
background: rgba(201, 169, 97, 0.24);
color: rgba(46, 39, 32, 0.96);
padding: 12px 12px;
font-weight: 900;
cursor: pointer;
box-shadow: 0 18px 44px rgba(16, 12, 8, 0.08);
text-align: left;
}
.maf-permitDash__btn:hover {
background: rgba(201, 169, 97, 0.32);
}
.maf-permitDash__btn--ghost {
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(110, 90, 68, 0.18);
}
.maf-permitDash__btn--ghost:hover {
background: rgba(250, 246, 240, 0.98);
}
.maf-permitDash__queue {
display: grid;
gap: 10px;
}
.maf-permitDash__queueRow {
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.84);
padding: 12px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: rgba(110, 90, 68, 0.86);
}
.maf-permitDash__queueRow strong {
color: rgba(46, 39, 32, 0.96);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1024px) {
.maf-permitDash__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-permitDash__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.maf-permitDash {
padding: 0.9rem 0.65rem 1.1rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { SuggestionDashboard } from "./SuggestionDashboard";
const meta = {
title: "Panel/Modules/Suggestion/Dashboard",
component: SuggestionDashboard,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof SuggestionDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = { parameters: { viewport: { defaultViewport: "tablet" } } };
export const Mobile: Story = { parameters: { viewport: { defaultViewport: "mobile1" } } };
"use client";
import React from "react";
import { TopStatCard } from "@/components/panel/dashboard/TopStatCard/TopStatCard";
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import "./suggestion-dashboard.css";
export function SuggestionDashboard() {
return (
<div className="maf-suggestDash">
<header className="maf-suggestDash__header">
<div>
<h1 className="maf-suggestDash__title">Suggestion</h1>
<p className="maf-suggestDash__subtitle">Track ideas from submission to implementation.</p>
</div>
</header>
<ModuleDashboardMenu
ariaLabel="Suggestion navigation"
items={[
{ label: "Dashboard", href: "/panel/suggestion", active: true },
{ label: "Add suggestion", href: "/panel/suggestion/new" },
{ label: "Suggestion tracking", href: "/panel/suggestion/tracking" },
{ label: "Suggestion list", href: "/panel/suggestion/list" },
]}
/>
<section className="maf-suggestDash__kpis" aria-label="Suggestion KPIs">
<TopStatCard label="Total" value={1346} helper="All time" underlineTone="neutral" />
<TopStatCard label="Approved suggestions" value={60} helper="Ready" underlineTone="good" />
<TopStatCard label="Pending approval" value={568} helper="In review" underlineTone="warn" />
<TopStatCard label="Rejected" value={29} helper="Closed" underlineTone="danger" />
<TopStatCard label="Implemented suggestions" value={689} helper="Shipped" underlineTone="info" />
</section>
<section className="maf-suggestDash__grid" aria-label="Suggestion dashboard content">
<DashboardCard title="Focus" subtitle="What to prioritize">
<div className="maf-suggestDash__focus">
<div className="maf-suggestDash__focusRow">
<span>Pending approval</span>
<strong>{(568).toLocaleString()}</strong>
</div>
<div className="maf-suggestDash__focusRow">
<span>Approved (ready)</span>
<strong>{(60).toLocaleString()}</strong>
</div>
<div className="maf-suggestDash__focusRow">
<span>Implemented</span>
<strong>{(689).toLocaleString()}</strong>
</div>
</div>
</DashboardCard>
</section>
</div>
);
}
.maf-suggestDash {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-suggestDash__header {
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-suggestDash__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-suggestDash__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-suggestDash__kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
}
.maf-suggestDash__grid {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 0.2rem;
align-items: start;
}
.maf-suggestDash__actions {
display: grid;
gap: 10px;
}
.maf-suggestDash__btn {
border-radius: 14px;
border: 1px solid rgba(201, 169, 97, 0.45);
background: rgba(139, 21, 56, 0.92);
color: rgba(255, 255, 255, 0.96);
padding: 12px 12px;
font-weight: 900;
cursor: pointer;
box-shadow:
0 18px 44px rgba(139, 21, 56, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
text-align: left;
}
.maf-suggestDash__btn:hover {
background: rgba(107, 15, 42, 0.94);
}
.maf-suggestDash__btn--ghost {
background: rgba(255, 255, 255, 0.84);
color: rgba(46, 39, 32, 0.96);
border: 1px solid rgba(110, 90, 68, 0.18);
box-shadow: 0 18px 44px rgba(16, 12, 8, 0.08);
}
.maf-suggestDash__btn--ghost:hover {
background: rgba(250, 246, 240, 0.98);
}
.maf-suggestDash__focus {
display: grid;
gap: 10px;
}
.maf-suggestDash__focusRow {
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.84);
padding: 12px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: rgba(110, 90, 68, 0.86);
}
.maf-suggestDash__focusRow strong {
color: rgba(46, 39, 32, 0.96);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1024px) {
.maf-suggestDash__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-suggestDash__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.maf-suggestDash {
padding: 0.9rem 0.65rem 1.1rem;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { TrackingDashboard } from "./TrackingDashboard";
const meta = {
title: "Panel/Modules/Tracking/Dashboard",
component: TrackingDashboard,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof TrackingDashboard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Tablet: Story = { parameters: { viewport: { defaultViewport: "tablet" } } };
export const Mobile: Story = { parameters: { viewport: { defaultViewport: "mobile1" } } };
"use client";
import React from "react";
import { TopStatCard } from "@/components/panel/dashboard/TopStatCard/TopStatCard";
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import "./tracking-dashboard.css";
export function TrackingDashboard() {
return (
<div className="maf-trackDash">
<header className="maf-trackDash__header">
<div>
<h1 className="maf-trackDash__title">Tracking</h1>
<p className="maf-trackDash__subtitle">Corrective actions status across sites and teams.</p>
</div>
</header>
<ModuleDashboardMenu
ariaLabel="Tracking navigation"
items={[
{ label: "Dashboard", href: "/panel/tracking", active: true },
{ label: "Tracking list", href: "/panel/tracking/list" },
]}
/>
<section className="maf-trackDash__kpis" aria-label="Tracking KPIs">
<TopStatCard label="Total" value={2817} helper="All time" underlineTone="neutral" />
<TopStatCard label="Closed actions" value={766} helper="Resolved" underlineTone="good" />
<TopStatCard label="Open actions" value={2029} helper="Pending" underlineTone="warn" />
<TopStatCard label="Overdue" value={1} helper="Needs attention" underlineTone="danger" deltaTone="danger" deltaText="SLA" />
</section>
<section className="maf-trackDash__grid" aria-label="Tracking dashboard content">
<DashboardCard title="Today" subtitle="What to tackle first">
<div className="maf-trackDash__focus">
<div className="maf-trackDash__focusRow">
<span>Overdue</span>
<strong>{(1).toLocaleString()}</strong>
</div>
<div className="maf-trackDash__focusRow">
<span>Open actions</span>
<strong>{(2029).toLocaleString()}</strong>
</div>
<div className="maf-trackDash__focusRow">
<span>Closed actions</span>
<strong>{(766).toLocaleString()}</strong>
</div>
</div>
</DashboardCard>
</section>
</div>
);
}
.maf-trackDash {
width: 100%;
padding: 1.1rem 1.1rem 1.2rem;
box-sizing: border-box;
min-width: 0;
}
.maf-trackDash__header {
padding: 0.2rem 0.2rem 0.8rem;
}
.maf-trackDash__title {
margin: 0;
font-size: 1.35rem;
font-weight: 920;
letter-spacing: -0.02em;
color: rgba(46, 39, 32, 0.96);
}
.maf-trackDash__subtitle {
margin: 6px 0 0;
font-size: 0.92rem;
line-height: 1.35;
color: rgba(110, 90, 68, 0.74);
}
.maf-trackDash__kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 0.2rem;
}
.maf-trackDash__grid {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 0.2rem;
align-items: start;
}
.maf-trackDash__actions {
display: grid;
gap: 10px;
}
.maf-trackDash__btn {
border-radius: 14px;
border: 1px solid rgba(201, 169, 97, 0.45);
background: rgba(201, 169, 97, 0.24);
color: rgba(46, 39, 32, 0.96);
padding: 12px 12px;
font-weight: 900;
cursor: pointer;
box-shadow: 0 18px 44px rgba(16, 12, 8, 0.08);
text-align: left;
}
.maf-trackDash__btn:hover {
background: rgba(201, 169, 97, 0.32);
}
.maf-trackDash__btn--ghost {
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(110, 90, 68, 0.18);
}
.maf-trackDash__btn--ghost:hover {
background: rgba(250, 246, 240, 0.98);
}
.maf-trackDash__focus {
display: grid;
gap: 10px;
}
.maf-trackDash__focusRow {
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.84);
padding: 12px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: rgba(110, 90, 68, 0.86);
}
.maf-trackDash__focusRow strong {
color: rgba(46, 39, 32, 0.96);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1024px) {
.maf-trackDash__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maf-trackDash__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.maf-trackDash {
padding: 0.9rem 0.65rem 1.1rem;
}
}
"use client";
import React from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import "./dual-line-trend-chart.css";
type DualLineTrendChartProps = {
title?: string;
labels: string[];
seriesA: number[];
seriesB: number[];
seriesALabel: string;
seriesBLabel: string;
seriesAColor?: string;
seriesBColor?: string;
height?: number;
};
export function DualLineTrendChart({
title = "Trend",
labels,
seriesA,
seriesB,
seriesALabel,
seriesBLabel,
seriesAColor = "rgba(59, 130, 246, 0.95)",
seriesBColor = "rgba(236, 72, 153, 0.92)",
height = 300,
}: DualLineTrendChartProps) {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const chartRef = React.useRef<uPlot | null>(null);
const [containerWidth, setContainerWidth] = React.useState(0);
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const resizeObserver = new ResizeObserver(() => {
setContainerWidth(root.clientWidth);
});
resizeObserver.observe(root);
setContainerWidth(root.clientWidth);
return () => resizeObserver.disconnect();
}, []);
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
if (!labels.length) return;
const x = labels.map((_, index) => index + 1);
const width = Math.max(containerWidth || root.clientWidth, 220);
const isSmallMobile = width <= 360;
const isMobile = width <= 768;
const minTickGap = isSmallMobile ? 4 : isMobile ? 2 : 1;
const responsiveHeight = isSmallMobile
? Math.max(178, height - 92)
: isMobile
? Math.max(214, height - 45)
: height;
const axisFont = isSmallMobile
? "10px ui-sans-serif, system-ui, sans-serif"
: isMobile
? "11px ui-sans-serif, system-ui, sans-serif"
: "12px ui-sans-serif, system-ui, sans-serif";
const options: uPlot.Options = {
width,
height: responsiveHeight,
padding: isSmallMobile
? [8, 6, 20, 28]
: isMobile
? [10, 8, 24, 34]
: [16, 16, 30, 42],
title,
scales: {
x: { time: false },
},
axes: [
{
stroke: "rgba(110, 90, 68, 0.35)",
grid: { stroke: "rgba(110, 90, 68, 0.12)", width: 1 },
size: isSmallMobile ? 22 : 26,
font: axisFont,
values: (_, ticks) =>
ticks.map((tick) => {
const index = Math.max(0, Number(tick) - 1);
if (index % minTickGap !== 0) return "";
return labels[index] || "";
}),
},
{
stroke: "rgba(110, 90, 68, 0.35)",
grid: { stroke: "rgba(110, 90, 68, 0.12)", width: 1 },
size: isSmallMobile ? 26 : isMobile ? 34 : 42,
font: axisFont,
},
],
series: [
{},
{
label: seriesALabel,
stroke: seriesAColor,
width: isSmallMobile ? 1.5 : 2,
fill: "rgba(59, 130, 246, 0.06)",
points: {
size: isSmallMobile ? 2.5 : isMobile ? 3 : 4,
fill: "#ffffff",
stroke: seriesAColor,
width: isSmallMobile ? 1.5 : 2,
},
},
{
label: seriesBLabel,
stroke: seriesBColor,
width: isSmallMobile ? 1.5 : 2,
fill: "rgba(236, 72, 153, 0.05)",
points: {
size: isSmallMobile ? 2.5 : isMobile ? 3 : 4,
fill: "#ffffff",
stroke: seriesBColor,
width: isSmallMobile ? 1.5 : 2,
},
},
],
legend: {
show: false,
},
};
chartRef.current?.destroy();
chartRef.current = new uPlot(options, [x, seriesA, seriesB], root);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [containerWidth, height, labels, seriesA, seriesAColor, seriesALabel, seriesB, seriesBColor, seriesBLabel, title]);
const sumA = seriesA.reduce((s, v) => s + v, 0);
const sumB = seriesB.reduce((s, v) => s + v, 0);
return (
<div className="maf-dual-trend">
<div className="maf-dual-trend__plot" ref={rootRef} />
<section className="maf-dual-trend__legend" aria-label="Chart legend and totals">
<div className="maf-dual-trend__pill">
<span className="maf-dual-trend__dot" style={{ background: seriesAColor }} aria-hidden />
<span className="maf-dual-trend__label">{seriesALabel}</span>
<span className="maf-dual-trend__value">{sumA.toLocaleString()}</span>
</div>
<div className="maf-dual-trend__pill">
<span className="maf-dual-trend__dot" style={{ background: seriesBColor }} aria-hidden />
<span className="maf-dual-trend__label">{seriesBLabel}</span>
<span className="maf-dual-trend__value">{sumB.toLocaleString()}</span>
</div>
</section>
</div>
);
}
.maf-dual-trend {
width: 100%;
}
.maf-dual-trend__plot {
width: 100%;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(110, 90, 68, 0.16);
border-radius: 16px;
box-shadow: 0 22px 54px rgba(16, 12, 8, 0.1);
padding: 0.55rem;
}
.maf-dual-trend__plot .u-title {
font-size: 0.98rem;
font-weight: 900;
letter-spacing: -0.01em;
color: rgba(46, 39, 32, 0.96);
}
.maf-dual-trend__legend {
margin-top: 0.55rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.maf-dual-trend__pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(255, 255, 255, 0.84);
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
color: rgba(110, 90, 68, 0.86);
}
.maf-dual-trend__dot {
width: 9px;
height: 9px;
border-radius: 999px;
display: inline-block;
flex: 0 0 auto;
box-shadow: 0 0 0 3px rgba(110, 90, 68, 0.08);
}
.maf-dual-trend__label {
font-weight: 800;
}
.maf-dual-trend__value {
font-weight: 900;
color: rgba(46, 39, 32, 0.96);
}
@media (max-width: 768px) {
.maf-dual-trend__plot {
padding: 0.32rem;
}
.maf-dual-trend__plot .u-title {
font-size: 0.84rem;
}
}
@media (max-width: 420px) {
.maf-dual-trend__legend {
display: grid;
grid-template-columns: 1fr;
}
.maf-dual-trend__pill {
justify-content: space-between;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { LocationSelector } from "./LocationSelector";
const meta = {
title: "Widgets/LocationSelector",
component: LocationSelector,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof LocationSelector>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithSelection: Story = {
args: {
value: {
country: "UAE",
organisation: "MAF",
operatingSubsidiary: "Retail",
company: "Carrefour",
},
},
};
export const Mobile: Story = {
parameters: { viewport: { defaultViewport: "mobile1" } },
};
"use client";
import React from "react";
import "./location-selector.css";
type LocationDraft = {
country: string;
organisation: string;
operatingSubsidiary: string;
company: string;
entity: string;
businessUnit: string;
department: string;
};
const emptyDraft: LocationDraft = {
country: "",
organisation: "",
operatingSubsidiary: "",
company: "",
entity: "",
businessUnit: "",
department: "",
};
function nonEmpty(values: string[]) {
return values.filter(Boolean);
}
function summary(draft: LocationDraft) {
const parts = nonEmpty([
draft.country,
draft.organisation,
draft.operatingSubsidiary,
draft.company,
draft.entity,
draft.businessUnit,
draft.department,
]);
if (parts.length === 0) return "All locations";
return parts.join(" · ");
}
type LocationSelectorProps = {
value?: Partial<LocationDraft>;
onApply?: (value: LocationDraft) => void;
};
export function LocationSelector({ value, onApply }: LocationSelectorProps) {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const panelRef = React.useRef<HTMLDivElement | null>(null);
const [applied, setApplied] = React.useState<LocationDraft>({
...emptyDraft,
...value,
});
const [draft, setDraft] = React.useState<LocationDraft>(applied);
React.useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
setDraft(applied);
triggerRef.current?.focus();
}
};
const onPointerDown = (event: PointerEvent) => {
const panel = panelRef.current;
const trigger = triggerRef.current;
if (!panel || !trigger) return;
const target = event.target as Node | null;
if (!target) return;
if (panel.contains(target) || trigger.contains(target)) return;
setOpen(false);
setDraft(applied);
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", onPointerDown);
};
}, [open, applied]);
const apply = () => {
setApplied(draft);
onApply?.(draft);
setOpen(false);
triggerRef.current?.focus();
};
const clearAll = () => {
setDraft(emptyDraft);
};
const appliedSummary = summary(applied);
const hasSelection = appliedSummary !== "All locations";
const setField = (key: keyof LocationDraft, next: string) => {
setDraft((prev) => {
const updated: LocationDraft = { ...prev, [key]: next };
const ordered: Array<keyof LocationDraft> = [
"country",
"organisation",
"operatingSubsidiary",
"company",
"entity",
"businessUnit",
"department",
];
const changedIndex = ordered.indexOf(key);
for (let index = changedIndex + 1; index < ordered.length; index += 1) {
updated[ordered[index]] = "";
}
return updated;
});
};
return (
<div className="maf-location">
<button
ref={triggerRef}
type="button"
className={`maf-location__trigger ${hasSelection ? "is-selected" : ""}`.trim()}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => {
setOpen((v) => !v);
setDraft(applied);
}}
>
<span className="maf-location__icon" aria-hidden="true">
</span>
<span className="maf-location__triggerText">
<span className="maf-location__label">Location</span>
<span className="maf-location__value" title={appliedSummary}>
{appliedSummary}
</span>
</span>
<span className="maf-location__chev" aria-hidden="true">
</span>
</button>
{open ? (
<>
<div className="maf-location__backdrop" aria-hidden="true" />
<div
ref={panelRef}
className="maf-location__panel"
role="dialog"
aria-modal="true"
aria-label="Location selector"
>
<header className="maf-location__panelHead">
<div className="maf-location__panelTitleWrap">
<div className="maf-location__panelKicker">Location</div>
<div className="maf-location__panelTitle">Location selector</div>
</div>
<button
type="button"
className="maf-location__close"
aria-label="Close location selector"
onClick={() => {
setOpen(false);
setDraft(applied);
}}
>
×
</button>
</header>
<div className="maf-location__fields" aria-label="Location fields">
<label className="maf-location__field">
<span>Country</span>
<select
value={draft.country}
onChange={(e) => setField("country", e.target.value)}
>
<option value="">Select country</option>
<option value="UAE">UAE</option>
<option value="KSA">KSA</option>
<option value="Egypt">Egypt</option>
<option value="Oman">Oman</option>
<option value="Lebanon">Lebanon</option>
</select>
</label>
<label className="maf-location__field">
<span>Organisation</span>
<select
value={draft.organisation}
onChange={(e) => setField("organisation", e.target.value)}
disabled={!draft.country}
>
<option value="">Select organisation</option>
<option value="MAF">MAF</option>
<option value="Majid Al Futtaim">Majid Al Futtaim</option>
</select>
</label>
<label className="maf-location__field">
<span>Operating subsidiaries</span>
<select
value={draft.operatingSubsidiary}
onChange={(e) => setField("operatingSubsidiary", e.target.value)}
disabled={!draft.organisation}
>
<option value="">Select operating subsidiaries</option>
<option value="Retail">Retail</option>
<option value="Properties">Properties</option>
<option value="Leisure">Leisure</option>
</select>
</label>
<label className="maf-location__field">
<span>Company</span>
<select
value={draft.company}
onChange={(e) => setField("company", e.target.value)}
disabled={!draft.operatingSubsidiary}
>
<option value="">Select company</option>
<option value="Carrefour">Carrefour</option>
<option value="Vox">Vox</option>
<option value="City Centre">City Centre</option>
</select>
</label>
<label className="maf-location__field">
<span>Entity</span>
<select
value={draft.entity}
onChange={(e) => setField("entity", e.target.value)}
disabled={!draft.company}
>
<option value="">Select entity</option>
<option value="Entity A">Entity A</option>
<option value="Entity B">Entity B</option>
</select>
</label>
<label className="maf-location__field">
<span>Business unit</span>
<select
value={draft.businessUnit}
onChange={(e) => setField("businessUnit", e.target.value)}
disabled={!draft.entity}
>
<option value="">Select business unit</option>
<option value="Operations">Operations</option>
<option value="EHS">EHS</option>
<option value="Security">Security</option>
</select>
</label>
<label className="maf-location__field">
<span>Department</span>
<select
value={draft.department}
onChange={(e) => setField("department", e.target.value)}
disabled={!draft.businessUnit}
>
<option value="">Select department</option>
<option value="Admin">Admin</option>
<option value="Field">Field</option>
<option value="Analytics">Analytics</option>
</select>
</label>
</div>
<footer className="maf-location__footer">
<button type="button" className="maf-location__link" onClick={clearAll}>
Clear all
</button>
<button type="button" className="maf-location__apply" onClick={apply}>
Apply
</button>
</footer>
</div>
</>
) : null}
</div>
);
}
.maf-location {
position: relative;
}
.maf-location__trigger {
height: 42px;
max-width: min(420px, 44vw);
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.28);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10px 22px rgba(110, 90, 68, 0.08);
color: rgba(110, 90, 68, 0.96);
cursor: pointer;
text-align: left;
}
.maf-location__trigger:hover {
background: rgba(250, 246, 240, 0.98);
border-color: rgba(110, 90, 68, 0.35);
}
.maf-location__trigger:focus-visible {
outline: none;
box-shadow:
0 10px 22px rgba(110, 90, 68, 0.08),
0 0 0 3px rgba(201, 169, 97, 0.35);
}
.maf-location__trigger.is-selected {
border-color: rgba(201, 169, 97, 0.55);
background: rgba(201, 169, 97, 0.12);
}
.maf-location__icon {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(167, 138, 104, 0.14);
border: 1px solid rgba(110, 90, 68, 0.2);
flex: 0 0 auto;
}
.maf-location__triggerText {
min-width: 0;
display: grid;
gap: 2px;
}
.maf-location__label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(110, 90, 68, 0.65);
line-height: 1.1;
}
.maf-location__value {
font-size: 0.9rem;
font-weight: 750;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.maf-location__chev {
opacity: 0.8;
flex: 0 0 auto;
}
.maf-location__backdrop {
position: fixed;
inset: 0;
background: rgba(16, 12, 8, 0.22);
z-index: 48;
}
.maf-location__panel {
position: fixed;
z-index: 49;
top: 72px;
right: 18px;
width: min(560px, calc(100vw - 2rem));
border-radius: 16px;
border: 1px solid rgba(110, 90, 68, 0.25);
background:
radial-gradient(680px 300px at 10% 0%, rgba(167, 138, 104, 0.14), transparent 60%),
rgba(255, 255, 255, 0.98);
box-shadow: 0 34px 84px rgba(110, 90, 68, 0.18);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100dvh - 92px);
}
.maf-location__panelHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
border-bottom: 1px solid rgba(110, 90, 68, 0.16);
}
.maf-location__panelKicker {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(110, 90, 68, 0.65);
}
.maf-location__panelTitle {
font-size: 1.05rem;
font-weight: 800;
color: rgba(46, 39, 32, 0.96);
}
.maf-location__close {
width: 40px;
height: 40px;
border-radius: 12px;
border: 1px solid rgba(110, 90, 68, 0.28);
background: rgba(255, 255, 255, 0.75);
color: rgba(110, 90, 68, 0.92);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.maf-location__close:hover {
background: rgba(110, 90, 68, 0.06);
}
.maf-location__fields {
display: grid;
gap: 10px;
padding: 14px 16px 8px;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.maf-location__field {
display: grid;
gap: 6px;
}
.maf-location__field span {
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(110, 90, 68, 0.72);
}
.maf-location__field select {
height: 42px;
border-radius: 12px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(250, 246, 240, 0.9);
padding: 0 12px;
color: rgba(46, 39, 32, 0.96);
font: inherit;
}
.maf-location__field select:disabled {
opacity: 0.55;
}
.maf-location__field select:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(201, 169, 97, 0.28);
}
.maf-location__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-top: 1px solid rgba(110, 90, 68, 0.16);
background: rgba(255, 255, 255, 0.75);
}
.maf-location__link {
border: 0;
background: transparent;
color: rgba(139, 21, 56, 0.9);
font-weight: 750;
cursor: pointer;
padding: 8px 10px;
border-radius: 10px;
}
.maf-location__link:hover {
background: rgba(139, 21, 56, 0.06);
}
.maf-location__apply {
border: 0;
height: 40px;
padding: 0 14px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(139, 21, 56, 0.92), rgba(169, 29, 71, 0.92));
color: #fff;
font-weight: 800;
cursor: pointer;
box-shadow: 0 14px 34px rgba(139, 21, 56, 0.18);
}
.maf-location__apply:hover {
box-shadow: 0 16px 40px rgba(139, 21, 56, 0.22);
transform: translateY(-1px);
}
@media (max-width: 768px) {
.maf-location__trigger {
max-width: min(340px, 54vw);
}
.maf-location__panel {
top: 64px;
right: 10px;
left: 10px;
width: auto;
max-height: calc(100dvh - 76px);
}
.maf-location__fields {
padding: 12px 12px 10px;
}
.maf-location__footer {
position: sticky;
bottom: 0;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
@media (max-width: 520px) {
.maf-location__trigger {
max-width: 58vw;
padding: 8px 10px;
gap: 8px;
}
.maf-location__icon {
width: 26px;
height: 26px;
}
.maf-location__label {
font-size: 0.68rem;
}
.maf-location__value {
font-size: 0.86rem;
}
.maf-location__panel {
top: 58px;
right: 8px;
left: 8px;
border-radius: 14px;
max-height: calc(100dvh - 70px);
}
.maf-location__panelHead {
padding: 12px 12px 10px;
}
.maf-location__fields {
gap: 9px;
}
.maf-location__field select {
height: 44px;
}
}
/* Tablet: use space better with 2-column form */
@media (min-width: 768px) and (max-width: 1024px) {
.maf-location__panel {
top: 66px;
right: 14px;
left: 14px;
width: auto;
max-height: calc(100dvh - 78px);
}
.maf-location__fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 12px;
}
.maf-location__footer {
position: sticky;
bottom: 0;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { NotificationsPanel } from "./NotificationsPanel";
const sampleSections = [
{
label: "Today",
items: [
{
id: "1",
title: "Incident #INC-2024-001 assigned to you",
description: "Requires review by 15 Apr 2024",
timeLabel: "2m ago",
tone: "alert" as const,
},
],
},
{
label: "Yesterday",
items: [
{
id: "2",
title: "User added successfully",
description: "Ahmed.AlAlawi@maf.ae is now active",
timeLabel: "Yesterday",
tone: "success" as const,
},
],
},
];
const meta = {
title: "Widgets/NotificationsPanel",
component: NotificationsPanel,
parameters: {
layout: "centered",
backgrounds: { default: "light" },
},
decorators: [
(Story) => (
<div
style={{
padding: 24,
minWidth: 400,
background:
"linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(250,246,240,0.98) 100%)",
}}
>
<Story />
</div>
),
],
} satisfies Meta<typeof NotificationsPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
count: 5,
sections: sampleSections,
onMarkAllRead: () => {},
onViewAll: () => {},
},
};
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { NotificationSection, NotificationTone } from "./notifications-types";
import "./notifications-panel.css";
export type NotificationsPanelProps = {
count?: number;
sections: NotificationSection[];
onMarkAllRead?: () => void;
onViewAll?: () => void;
onGoToPage?: (id: string) => void;
onForward?: (id: string) => void;
};
function BellIcon() {
return (
<svg className="maf-notifications__bellIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" strokeLinecap="round" strokeLinejoin="round" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" strokeLinecap="round" />
</svg>
);
}
function toneClass(tone: NotificationTone | undefined) {
switch (tone) {
case "success":
return "is-success";
case "info":
return "is-info";
case "warning":
return "is-warning";
default:
return "is-alert";
}
}
export function NotificationsPanel({
count = 0,
sections,
onMarkAllRead,
onViewAll,
onGoToPage,
onForward,
}: NotificationsPanelProps) {
const [open, setOpen] = useState(false);
/** After open, ignore outside-dismiss and backdrop close briefly (ghost click / tap-through). */
const openedAtRef = useRef(0);
const rootRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const panelId = useId();
const [useSheetPortal, setUseSheetPortal] = useState(false);
useLayoutEffect(() => {
const mq = window.matchMedia("(max-width: 1024px)");
const sync = () => setUseSheetPortal(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (Date.now() - openedAtRef.current < 280) return;
const t = e.target as Node;
if (rootRef.current?.contains(t) || overlayRef.current?.contains(t)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
useEffect(() => {
if (!open) return;
const mq = window.matchMedia("(max-width: 1024px)");
if (!mq.matches) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const badge = count > 99 ? "99+" : String(count);
const toggleOpen = () => {
setOpen((v) => {
const next = !v;
if (next) openedAtRef.current = Date.now();
return next;
});
};
const closeBackdrop = () => {
if (Date.now() - openedAtRef.current < 280) return;
setOpen(false);
};
const overlayContent = (
<>
<button
type="button"
className="maf-notifications__backdrop"
aria-label="Close notifications"
onClick={closeBackdrop}
/>
<div id={panelId} className="maf-notifications__panel" role="dialog" aria-label="Notifications">
<div className="maf-notifications__sheetHandle" aria-hidden />
<div className="maf-notifications__panelHead">
<h2 className="maf-notifications__panelTitle">Notifications</h2>
{onMarkAllRead ? (
<button type="button" className="maf-notifications__markAll" onClick={onMarkAllRead}>
Mark all read
</button>
) : (
<span className="maf-notifications__markAll" aria-hidden>
Mark all read
</span>
)}
</div>
<div className="maf-notifications__scroll">
{sections.map((section) => (
<section key={section.label} className="maf-notifications__section">
<header className="maf-notifications__sectionLabel">{section.label}</header>
<ul className="maf-notifications__list">
{section.items.map((item) => (
<li key={item.id} className="maf-notifications__item">
<div className={`maf-notifications__itemIcon ${toneClass(item.tone)}`.trim()} aria-hidden />
<div className="maf-notifications__itemBody">
<div className="maf-notifications__itemTop">
<span className="maf-notifications__itemTitle">{item.title}</span>
<span className="maf-notifications__itemTime">{item.timeLabel}</span>
</div>
<p className="maf-notifications__itemDesc">{item.description}</p>
<div className="maf-notifications__itemActions">
<button
type="button"
className="maf-notifications__linkGold"
onClick={() => onGoToPage?.(item.id)}
>
Go to page
</button>
<button
type="button"
className="maf-notifications__linkMuted"
onClick={() => onForward?.(item.id)}
>
Forward
</button>
</div>
</div>
</li>
))}
</ul>
</section>
))}
</div>
<div className="maf-notifications__footer">
<p className="maf-notifications__hint">
Click a notification to go to the associated page. Use Forward to escalate to another user.
</p>
{onViewAll ? (
<button type="button" className="maf-notifications__viewAll" onClick={onViewAll}>
View all notifications
</button>
) : (
<span className="maf-notifications__viewAll" role="presentation">
View all notifications
</span>
)}
</div>
</div>
</>
);
const overlayInPortal =
open && useSheetPortal && typeof document !== "undefined"
? createPortal(
<div ref={overlayRef} className="maf-notifications__overlay">
{overlayContent}
</div>,
document.body,
)
: null;
return (
<div className={`maf-notifications${open ? " is-open" : ""}`.trim()} ref={rootRef}>
<button
type="button"
className="maf-notifications__bell"
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={panelId}
aria-label={`Notifications${count ? `, ${count} unread` : ""}`}
onClick={toggleOpen}
>
<BellIcon />
{count > 0 ? (
<span className="maf-notifications__badge" aria-hidden>
{badge}
</span>
) : null}
</button>
{open && !useSheetPortal ? overlayContent : null}
{overlayInPortal}
</div>
);
}
/* MAF theme — matches Sidebar / listing / panel header */
.maf-notifications {
--maf-brown: #6e5a44;
--maf-brown-soft: #a78a68;
--maf-accent: #c4a574;
--maf-border: rgba(110, 90, 68, 0.22);
--maf-border-soft: rgba(110, 90, 68, 0.14);
position: relative;
font-family: var(--font-sans), "Manrope", ui-sans-serif, system-ui, sans-serif;
}
.maf-notifications__bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid var(--maf-border-soft);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 246, 240, 0.96) 100%);
color: rgba(110, 90, 68, 0.88);
cursor: pointer;
box-shadow: 0 4px 12px rgba(110, 90, 68, 0.1);
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.maf-notifications__bell:hover {
border-color: rgba(110, 90, 68, 0.26);
background: rgba(255, 255, 255, 1);
box-shadow: 0 6px 16px rgba(110, 90, 68, 0.14);
color: rgba(110, 90, 68, 1);
}
.maf-notifications__bellIcon {
width: 20px;
height: 20px;
}
.maf-notifications__badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: linear-gradient(145deg, #c45c5c 0%, #b91c1c 100%);
color: #fff;
font-size: 0.58rem;
font-weight: 700;
line-height: 16px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 2px 6px rgba(110, 90, 68, 0.18);
}
/* Portal root (body) — escapes header backdrop-filter containing block so fixed sheet is viewport-relative */
.maf-notifications__overlay {
position: static;
}
.maf-notifications__backdrop {
display: none;
}
.maf-notifications__sheetHandle {
display: none;
}
.maf-notifications__panel {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: min(380px, calc(100vw - 24px));
max-height: min(72vh, 560px);
display: flex;
flex-direction: column;
border-radius: 14px;
border: 1px solid var(--maf-border);
background:
radial-gradient(820px 280px at 10% 0%, rgba(167, 138, 104, 0.12), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 246, 240, 0.98) 100%);
box-shadow: 0 14px 34px rgba(110, 90, 68, 0.18);
z-index: 60;
}
@keyframes maf-notifications-sheet-in {
from {
transform: translateY(100%);
opacity: 0.96;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.maf-notifications__panelHead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem 0.65rem;
border-bottom: 1px solid var(--maf-border-soft);
}
.maf-notifications__panelTitle {
margin: 0;
font-family: var(--font-serif), "Playfair Display", Georgia, serif;
font-size: 1.05rem;
font-weight: 600;
color: rgba(110, 90, 68, 0.95);
letter-spacing: 0.02em;
}
.maf-notifications__markAll {
border: 0;
background: none;
padding: 0;
font-size: 0.72rem;
font-weight: 600;
color: rgba(110, 90, 68, 0.55);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.maf-notifications__markAll:not(button) {
cursor: default;
text-decoration: none;
}
.maf-notifications__markAll:hover {
color: rgba(110, 90, 68, 0.85);
}
.maf-notifications__scroll {
overflow: auto;
flex: 1;
min-height: 0;
padding: 0.6rem 0.5rem 0.4rem;
}
.maf-notifications__section {
margin-bottom: 0.75rem;
}
.maf-notifications__sectionLabel {
margin: 0 0 0.45rem 0.5rem;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(110, 90, 68, 0.55);
}
.maf-notifications__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.maf-notifications__item {
display: grid;
grid-template-columns: 40px 1fr;
gap: 0.55rem;
padding: 0.55rem 0.5rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--maf-border-soft);
}
.maf-notifications__itemIcon {
width: 36px;
height: 36px;
border-radius: 10px;
align-self: start;
}
.maf-notifications__itemIcon.is-alert {
background: linear-gradient(145deg, rgba(196, 165, 116, 0.45), rgba(196, 165, 116, 0.15));
border: 1px solid rgba(110, 90, 68, 0.12);
}
.maf-notifications__itemIcon.is-success {
background: linear-gradient(145deg, rgba(34, 197, 94, 0.22), rgba(34, 197, 94, 0.08));
border: 1px solid rgba(34, 197, 94, 0.15);
}
.maf-notifications__itemIcon.is-info {
background: linear-gradient(145deg, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.06));
border: 1px solid rgba(59, 130, 246, 0.12);
}
.maf-notifications__itemIcon.is-warning {
background: linear-gradient(145deg, rgba(245, 158, 11, 0.28), rgba(245, 158, 11, 0.08));
border: 1px solid rgba(245, 158, 11, 0.15);
}
.maf-notifications__itemTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.maf-notifications__itemTitle {
font-size: 0.76rem;
font-weight: 700;
color: rgba(58, 47, 37, 0.94);
line-height: 1.35;
}
.maf-notifications__itemTime {
font-size: 0.62rem;
color: rgba(110, 90, 68, 0.5);
white-space: nowrap;
flex-shrink: 0;
}
.maf-notifications__itemDesc {
margin: 0.25rem 0 0;
font-size: 0.68rem;
color: rgba(82, 69, 54, 0.82);
line-height: 1.4;
}
.maf-notifications__itemActions {
display: flex;
gap: 0.75rem;
margin-top: 0.45rem;
}
.maf-notifications__linkGold,
.maf-notifications__linkMuted {
border: 0;
background: none;
padding: 0;
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
}
.maf-notifications__linkGold {
color: rgba(110, 90, 68, 0.92);
text-decoration: underline;
text-underline-offset: 2px;
}
.maf-notifications__linkGold:hover {
color: rgba(110, 90, 68, 1);
}
.maf-notifications__linkMuted {
color: rgba(110, 90, 68, 0.48);
}
.maf-notifications__linkMuted:hover {
color: rgba(110, 90, 68, 0.72);
}
.maf-notifications__footer {
padding: 0.65rem 1rem 0.85rem;
border-top: 1px solid var(--maf-border-soft);
text-align: center;
background: linear-gradient(180deg, rgba(250, 246, 240, 0.4) 0%, rgba(250, 246, 240, 0.85) 100%);
}
.maf-notifications__hint {
margin: 0 0 0.45rem;
font-size: 0.62rem;
line-height: 1.45;
color: rgba(110, 90, 68, 0.55);
}
.maf-notifications__viewAll {
border: 0;
background: none;
padding: 0;
font-size: 0.72rem;
font-weight: 700;
color: rgba(110, 90, 68, 0.92);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.maf-notifications__viewAll:not(button) {
cursor: default;
text-decoration: none;
}
.maf-notifications__viewAll:hover {
color: rgba(110, 90, 68, 1);
}
@media (max-width: 1024px) {
.maf-notifications {
flex-shrink: 0;
}
.maf-notifications__bell {
width: 36px;
height: 36px;
}
.maf-notifications__bellIcon {
width: 18px;
height: 18px;
}
.maf-notifications__overlay .maf-notifications__backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 90;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
cursor: pointer;
background: rgba(24, 18, 12, 0.42);
-webkit-tap-highlight-color: transparent;
}
.maf-notifications__sheetHandle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0.5rem 1rem 0.25rem;
}
.maf-notifications__sheetHandle::before {
content: "";
width: 36px;
height: 4px;
border-radius: 999px;
background: rgba(110, 90, 68, 0.22);
}
.maf-notifications__panel {
position: fixed;
left: 0;
right: 0;
top: auto;
/* Flush to viewport bottom — covers bottom tab while open */
bottom: 0;
width: 100%;
max-width: none;
max-height: min(92vh, 100dvh);
padding-bottom: env(safe-area-inset-bottom, 0px);
box-sizing: border-box;
border-radius: 16px 16px 0 0;
z-index: 91;
animation: maf-notifications-sheet-in 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.maf-notifications__panel {
animation: none;
}
}
}
export type NotificationTone = "alert" | "success" | "info" | "warning";
export type NotificationItem = {
id: string;
title: string;
description: string;
timeLabel: string;
tone?: NotificationTone;
};
export type NotificationSection = {
label: string;
items: NotificationItem[];
};
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { modules } from "@/components/panel/modules/modules-data";
import "./panel-module-search.css";
export type PanelModuleSearchProps = {
placeholder?: string;
};
export function PanelModuleSearch({ placeholder = "Search modules…" }: PanelModuleSearchProps) {
const router = useRouter();
const [value, setValue] = React.useState("");
const [open, setOpen] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(0);
const wrapRef = React.useRef<HTMLDivElement | null>(null);
const listId = React.useId();
const results = React.useMemo(() => {
const q = value.trim().toLowerCase();
if (!q) return [];
return modules
.filter((m) => {
return (
m.title.toLowerCase().includes(q) ||
m.description.toLowerCase().includes(q) ||
m.category.toLowerCase().includes(q)
);
})
.slice(0, 7);
}, [value]);
React.useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
const el = wrapRef.current;
if (!el) return;
if (e.target instanceof Node && el.contains(e.target)) return;
setOpen(false);
};
window.addEventListener("pointerdown", onDown);
return () => window.removeEventListener("pointerdown", onDown);
}, [open]);
React.useEffect(() => {
setActiveIndex(0);
}, [value]);
return (
<div className="maf-panel-module-searchWrap" ref={wrapRef}>
<form
className="maf-panel-module-search"
role="search"
aria-label="Search modules"
onSubmit={(e) => {
e.preventDefault();
const q = value.trim();
if (!q) {
setOpen(false);
router.push("/panel/modules");
return;
}
const direct = results[activeIndex];
if (open && direct) {
setOpen(false);
router.push(direct.href);
return;
}
setOpen(false);
router.push(`/panel/modules?q=${encodeURIComponent(q)}`);
}}
>
<span className="maf-panel-module-search__icon" aria-hidden="true">
</span>
<input
value={value}
onChange={(e) => {
setValue(e.target.value);
setOpen(true);
}}
onFocus={() => {
if (value.trim()) setOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, Math.max(0, results.length - 1)));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(0, i - 1));
return;
}
}}
placeholder={placeholder}
inputMode="search"
aria-autocomplete="list"
aria-expanded={open && results.length > 0}
aria-controls={listId}
aria-activedescendant={
open && results[activeIndex] ? `${listId}-${results[activeIndex].id}` : undefined
}
/>
{value ? (
<button
type="button"
className="maf-panel-module-search__clear"
onClick={() => {
setValue("");
setOpen(false);
}}
aria-label="Clear search"
>
×
</button>
) : null}
</form>
{open && results.length > 0 ? (
<div className="maf-panel-module-search__dropdown" role="listbox" id={listId}>
{results.map((m, idx) => (
<button
key={m.id}
id={`${listId}-${m.id}`}
type="button"
role="option"
aria-selected={idx === activeIndex}
className={`maf-panel-module-search__item ${idx === activeIndex ? "is-active" : ""}`.trim()}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => {
setOpen(false);
router.push(m.href);
}}
>
<div className="maf-panel-module-search__itemTitle">{m.title}</div>
<div className="maf-panel-module-search__itemMeta">{m.category}</div>
</button>
))}
<button
type="button"
className="maf-panel-module-search__footer"
onClick={() => {
const q = value.trim();
setOpen(false);
router.push(q ? `/panel/modules?q=${encodeURIComponent(q)}` : "/panel/modules");
}}
>
View all results
</button>
</div>
) : null}
</div>
);
}
.maf-panel-module-searchWrap {
width: min(560px, 100%);
position: relative;
}
.maf-panel-module-search {
width: 100%;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(255, 255, 255, 0.72);
padding: 9px 10px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
box-shadow:
0 16px 38px rgba(16, 12, 8, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.maf-panel-module-search:focus-within {
border-color: rgba(201, 169, 97, 0.55);
box-shadow:
0 16px 38px rgba(16, 12, 8, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72),
0 0 0 3px rgba(201, 169, 97, 0.3);
}
.maf-panel-module-search__icon {
width: 26px;
height: 26px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(167, 138, 104, 0.14);
border: 1px solid rgba(110, 90, 68, 0.16);
color: rgba(110, 90, 68, 0.86);
font-weight: 900;
}
.maf-panel-module-search input {
width: 100%;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: rgba(46, 39, 32, 0.96);
font-size: 0.92rem;
}
.maf-panel-module-search input::placeholder {
color: rgba(110, 90, 68, 0.58);
}
.maf-panel-module-search__clear {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.16);
background: rgba(255, 255, 255, 0.78);
color: rgba(110, 90, 68, 0.86);
font-size: 1.2rem;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
.maf-panel-module-search__clear:hover {
background: rgba(250, 246, 240, 0.98);
}
.maf-panel-module-search__dropdown {
position: absolute;
top: calc(100% + 10px);
left: 0;
right: 0;
z-index: 30;
border-radius: 16px;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 30px 70px rgba(16, 12, 8, 0.16);
overflow: hidden;
}
.maf-panel-module-search__item {
width: 100%;
text-align: left;
border: 0;
border-bottom: 1px solid rgba(110, 90, 68, 0.12);
background: transparent;
padding: 11px 12px;
cursor: pointer;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
font: inherit;
}
.maf-panel-module-search__item:hover {
background: rgba(167, 138, 104, 0.12);
}
.maf-panel-module-search__item.is-active {
background: rgba(201, 169, 97, 0.16);
}
.maf-panel-module-search__itemTitle {
font-weight: 900;
color: rgba(46, 39, 32, 0.96);
}
.maf-panel-module-search__itemMeta {
font-size: 0.82rem;
font-weight: 850;
color: rgba(110, 90, 68, 0.72);
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(110, 90, 68, 0.14);
background: rgba(250, 246, 240, 0.9);
}
.maf-panel-module-search__footer {
width: 100%;
border: 0;
background: rgba(250, 246, 240, 0.92);
padding: 10px 12px;
cursor: pointer;
font: inherit;
font-weight: 900;
color: rgba(139, 21, 56, 0.9);
}
.maf-panel-module-search__footer:hover {
background: rgba(250, 246, 240, 0.98);
}
@media (max-width: 520px) {
.maf-panel-module-searchWrap {
display: none;
}
}
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ProfilePanelHeader } from "./ProfilePanelHeader";
const meta = {
title: "Widgets/ProfilePanelHeader",
component: ProfilePanelHeader,
parameters: {
layout: "centered",
backgrounds: { default: "light" },
},
decorators: [
(Story) => (
<div
style={{
padding: 24,
minWidth: 320,
background:
"linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(250,246,240,0.98) 100%)",
}}
>
<Story />
</div>
),
],
} satisfies Meta<typeof ProfilePanelHeader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
name: "Nino Lester Cruz",
role: "ADMIN",
initials: "NL",
onSignOut: () => {},
},
};
"use client";
import { useEffect, useId, useRef, useState } from "react";
import "./profile-panel-header.css";
export type ProfilePanelHeaderProps = {
name: string;
role: string;
initials?: string;
profileHref?: string;
settingsHref?: string;
onProfileClick?: () => void;
onSettingsClick?: () => void;
onSignOut?: () => void;
};
function PersonIcon() {
return (
<svg className="maf-profile-panel__menuIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="8" r="3.5" />
<path d="M6 20c0-3.3 2.7-6 6-6s6 2.7 6 6" strokeLinecap="round" />
</svg>
);
}
function GearIcon() {
return (
<svg className="maf-profile-panel__menuIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" strokeLinecap="round" />
</svg>
);
}
function SignOutIcon() {
return (
<svg className="maf-profile-panel__menuIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" strokeLinecap="round" />
<path d="M16 17l5-5-5-5M21 12H9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function ProfilePanelHeader({
name,
role,
initials,
profileHref = "#",
settingsHref = "#",
onProfileClick,
onSettingsClick,
onSignOut,
}: ProfilePanelHeaderProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const menuId = useId();
const initialsFromName =
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join("") || "?";
const displayInitials = initials ?? initialsFromName;
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
return (
<div className="maf-profile-panel" ref={rootRef}>
<button
type="button"
className="maf-profile-panel__trigger"
aria-label={`${name}, ${role}`}
aria-expanded={open}
aria-haspopup="menu"
aria-controls={menuId}
onClick={() => setOpen((v) => !v)}
>
<span className="maf-profile-panel__avatar" aria-hidden>
{displayInitials}
</span>
<span className="maf-profile-panel__text">
<span className="maf-profile-panel__name">{name}</span>
<span className="maf-profile-panel__role">{role}</span>
</span>
<svg className="maf-profile-panel__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open ? (
<div id={menuId} className="maf-profile-panel__menu" role="menu">
{onProfileClick ? (
<button type="button" className="maf-profile-panel__menuItem" role="menuitem" onClick={() => { onProfileClick(); setOpen(false); }}>
<PersonIcon />
Profile
</button>
) : (
<a className="maf-profile-panel__menuItem" role="menuitem" href={profileHref} onClick={() => setOpen(false)}>
<PersonIcon />
Profile
</a>
)}
{onSettingsClick ? (
<button type="button" className="maf-profile-panel__menuItem" role="menuitem" onClick={() => { onSettingsClick(); setOpen(false); }}>
<GearIcon />
Settings
</button>
) : (
<a className="maf-profile-panel__menuItem" role="menuitem" href={settingsHref} onClick={() => setOpen(false)}>
<GearIcon />
Settings
</a>
)}
<div className="maf-profile-panel__divider" role="separator" />
<button
type="button"
className="maf-profile-panel__menuItem maf-profile-panel__menuItem--signOut"
role="menuitem"
onClick={() => {
onSignOut?.();
setOpen(false);
}}
>
<SignOutIcon />
Sign out
</button>
</div>
) : null}
</div>
);
}
/* MAF theme — matches Sidebar / panel header (brown, cream, gold) */
.maf-profile-panel {
--maf-brown: #6e5a44;
--maf-brown-soft: #a78a68;
--maf-accent: #c4a574;
--maf-border: rgba(110, 90, 68, 0.22);
--maf-border-soft: rgba(110, 90, 68, 0.14);
position: relative;
font-family: var(--font-sans), "Manrope", ui-sans-serif, system-ui, sans-serif;
}
.maf-profile-panel__trigger {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.35rem 0.65rem 0.35rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--maf-border-soft);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 246, 240, 0.96) 100%);
color: rgba(58, 47, 37, 0.94);
cursor: pointer;
box-shadow: 0 4px 14px rgba(110, 90, 68, 0.1);
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.maf-profile-panel__trigger:hover {
border-color: rgba(110, 90, 68, 0.28);
background: rgba(255, 255, 255, 1);
box-shadow: 0 6px 18px rgba(110, 90, 68, 0.14);
}
.maf-profile-panel__avatar {
width: 34px;
height: 34px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
background: linear-gradient(145deg, rgba(167, 138, 104, 0.45) 0%, rgba(110, 90, 68, 0.88) 100%);
color: #fff;
border: 1px solid rgba(110, 90, 68, 0.25);
flex-shrink: 0;
}
.maf-profile-panel__text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
text-align: left;
}
.maf-profile-panel__name {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.2;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(58, 47, 37, 0.96);
}
.maf-profile-panel__role {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(110, 90, 68, 0.55);
}
.maf-profile-panel__chevron {
width: 14px;
height: 14px;
flex-shrink: 0;
color: rgba(110, 90, 68, 0.55);
}
.maf-profile-panel__menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 200px;
padding: 0.4rem;
border-radius: 12px;
border: 1px solid var(--maf-border);
background:
radial-gradient(520px 240px at 0% 0%, rgba(167, 138, 104, 0.12), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 246, 240, 0.98) 100%);
box-shadow: 0 14px 34px rgba(110, 90, 68, 0.18);
z-index: 60;
}
.maf-profile-panel__menuItem {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.55rem 0.65rem;
border: 0;
border-radius: 8px;
background: transparent;
color: rgba(110, 90, 68, 0.92);
font-size: 0.8rem;
text-decoration: none;
text-align: left;
cursor: pointer;
transition: background 0.12s ease;
}
.maf-profile-panel__menuItem:hover {
background: rgba(110, 90, 68, 0.08);
}
.maf-profile-panel__menuItem--signOut {
color: rgba(160, 55, 55, 0.95);
}
.maf-profile-panel__menuIcon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: rgba(110, 90, 68, 0.75);
}
.maf-profile-panel__menuItem--signOut .maf-profile-panel__menuIcon {
color: rgba(160, 55, 55, 0.85);
}
.maf-profile-panel__divider {
height: 1px;
margin: 0.25rem 0;
background: rgba(110, 90, 68, 0.14);
}
/* Panel header mobile: one row with bell + avatar — avoid wrapping */
@media (max-width: 1024px) {
.maf-profile-panel {
flex-shrink: 0;
}
.maf-profile-panel__text,
.maf-profile-panel__chevron {
display: none;
}
.maf-profile-panel__trigger {
padding: 0.26rem;
gap: 0;
}
.maf-profile-panel__avatar {
width: 32px;
height: 32px;
font-size: 0.65rem;
}
.maf-profile-panel__menu {
right: 0;
left: auto;
max-width: min(240px, calc(100vw - 20px));
}
}
......@@ -60,30 +60,61 @@
}
.maf-quick-action__fab {
width: 62px;
height: 62px;
width: 54px;
height: 54px;
border-radius: 999px;
border: 0;
font-size: 2rem;
font-size: 1.85rem;
line-height: 1;
color: rgba(110, 90, 68, 0.95);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.98) 0%, rgba(244, 238, 228, 0.98) 100%);
/* Frosted glass: transparent but readable */
background: rgba(255, 255, 255, 0.52);
backdrop-filter: blur(10px) saturate(1.15);
-webkit-backdrop-filter: blur(10px) saturate(1.15);
box-shadow:
0 14px 32px rgba(110, 90, 68, 0.2),
0 14px 32px rgba(110, 90, 68, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
border: 1px solid rgba(110, 90, 68, 0.28);
border: 1px solid rgba(110, 90, 68, 0.22);
cursor: pointer;
transition:
transform 160ms ease,
background 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.maf-quick-action__fab:hover {
background: rgba(255, 255, 255, 0.68);
border-color: rgba(110, 90, 68, 0.28);
box-shadow:
0 16px 38px rgba(110, 90, 68, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
transform: translateY(-1px);
}
.maf-quick-action__fab:active {
transform: translateY(0);
background: rgba(255, 255, 255, 0.62);
}
.maf-quick-action__fab:focus-visible {
outline: none;
box-shadow:
0 16px 38px rgba(110, 90, 68, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.7),
0 0 0 3px rgba(201, 169, 97, 0.38);
}
.maf-quick-action__fab.is-open {
font-size: 2.15rem;
font-size: 1.95rem;
transform: rotate(0deg);
}
@media (max-width: 1024px) {
.maf-quick-action__floatingWrap {
right: 0.9rem;
bottom: calc(4.85rem + env(safe-area-inset-bottom));
/* Panel overrides may lift this further; keep a safer default everywhere */
bottom: calc(7.2rem + env(safe-area-inset-bottom));
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment