Commit a55c4f99 by krds-arun

update gitlab yml and docker for maf gateway revamp frontend

parent 134283f0
stages:
- build
- deploy
variables:
app_name: maf-mp-frontend
app_image_tag: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
image_tag: $CI_BUILD_REF_NAME
image: $CI_REGISTRY_IMAGE
registry_pass: $CI_BUILD_TOKEN
registry_user: gitlab-ci-token
registry: $CI_REGISTRY
slack_channel: magicplanet
ecs_definition: config/ecs-task-definition.json
ecs_entrypoint: app:3000
docker_build_staging:
tags:
- docker
- eu
stage: build
variables:
app_env: staging
app_api_url: https://magicplanet-new-api-staging.eu-staging.kacdn.net
app_cms_url: https://maf-mp-strapi-staging.eu-staging.kacdn.net/api
app_assets_url: https://maf-mp.s3.ap-southeast-1.amazonaws.com/maf-mp-strapi/staging
script:
- env
- docker login -u $registry_user -p $registry_pass $registry
- docker build -t $app_image_tag
--build-arg APP_ENV=$app_env
--build-arg APP_REVALIDATE_SECRET=$app_revalidate_secret
--build-arg NEXT_PUBLIC_AUTH0_DOMAIN=$app_auth0_domain
--build-arg NEXT_PUBLIC_AUTH0_CLIENT_ID=$app_auth0_client_id
--build-arg NEXT_PUBLIC_RECAPTCHA_SITE_KEY=$app_recaptcha_site_key
--build-arg APP_CMS_URL=$app_cms_url
--build-arg APP_CMS_TOKEN=$app_cms_token
--build-arg APP_ASSETS_URL=$app_assets_url
--build-arg NEXT_PUBLIC_API_URL=$app_api_url .
- docker push $app_image_tag
only:
- master
deploy_staging:
image: registry.git.int.krds.com/tools/deploy:edge
tags:
- deploy
- eu
stage: deploy
variables:
app_env: staging
# app_http_auth_path: /etc/nginx/.htpasswd_common
# app_http_auth_path: config/vhost.conf
script:
- deploy-ecs eu-staging
only:
- master
FROM node:18-alpine AS base
ARG APP_ENV
ARG NEXT_PUBLIC_API_URL
ARG APP_CMS_URL
ARG APP_CMS_TOKEN
ARG APP_ASSETS_URL
ARG APP_REVALIDATE_SECRET
ARG NEXT_PUBLIC_RECAPTCHA_SITE_KEY
ENV APP_ENV=${APP_ENV}
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV APP_CMS_URL=${APP_CMS_URL}
ENV APP_CMS_TOKEN=${APP_CMS_TOKEN}
ENV APP_REVALIDATE_SECRET=${APP_REVALIDATE_SECRET}
ENV NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${NEXT_PUBLIC_RECAPTCHA_SITE_KEY}
ENV NEXT_TELEMETRY_DISABLED 1
ENV COREPACK_INTEGRITY_KEYS=0
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm
RUN pnpm install --production
# Rebuild the source code only when needed
FROM base AS builder
ARG NEXT_PUBLIC_AUTH0_DOMAIN
ARG NEXT_PUBLIC_AUTH0_CLIENT_ID
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm
RUN pnpm build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
import { AICopilotPage } from "@/components/panel/ai/AICopilotPage/AICopilotPage";
export default function Page() {
return <AICopilotPage />;
}
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
export default function Page() {
return (
<div style={{ padding: "1rem" }}>
<DashboardCard title="Alerts" subtitle="Coming soon">
Your alerts center will appear here.
</DashboardCard>
</div>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
return (
<ListingPage
data={auditListingData}
title="Audit listing"
subtitle="Manage and review all audit submissions"
emptyText="No audits found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Audits navigation"
items={[
{ label: "Dashboard", href: "/panel/audits" },
{ label: "Conduct audit", href: "/panel/audits/conduct" },
{ label: "Audit tracking", href: "/panel/audits/tracking" },
{ label: "Audit listing", href: "/panel/audits/list", active: true },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Audit Tracking - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Audit tracking"
subtitle="Track open actions and remediation progress"
emptyText="No audit tracking items found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Audits navigation"
items={[
{ label: "Dashboard", href: "/panel/audits" },
{ label: "Conduct audit", href: "/panel/audits/conduct" },
{ label: "Audit tracking", href: "/panel/audits/tracking", active: true },
{ label: "Audit listing", href: "/panel/audits/list" },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Checklist - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Checklist listing"
subtitle="Browse and manage submitted checklists"
emptyText="No checklists found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Checklist navigation"
items={[
{ label: "Dashboard", href: "/panel/checklist" },
{ label: "Conduct checklist", href: "/panel/checklist/conduct" },
// Action plan = tracking
{ label: "Checklist action plan", href: "/panel/checklist/tracking" },
// Status = listing (this page)
{ label: "Checklist status", href: "/panel/checklist/list", active: true },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Checklist Tracking - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Checklist tracking"
subtitle="Track checklist actions and completion status"
emptyText="No checklist tracking items found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Checklist navigation"
items={[
{ label: "Dashboard", href: "/panel/checklist" },
{ label: "Conduct checklist", href: "/panel/checklist/conduct" },
// Action plan = tracking (this page)
{ label: "Checklist action plan", href: "/panel/checklist/tracking", active: true },
// Status = listing
{ label: "Checklist status", href: "/panel/checklist/list" },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Incident - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Incident listing"
subtitle="Review incidents across sites and teams"
emptyText="No incidents found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Incidents navigation"
items={[
{ label: "Dashboard", href: "/panel/incidents" },
{ label: "New incident", href: "/panel/incidents/new" },
{ label: "Incident tracking", href: "/panel/incidents/tracking" },
{ label: "Incident listing", href: "/panel/incidents/list", active: true },
{ label: "Monthly data", href: "/panel/incidents/monthly" },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Incident Tracking - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Incident tracking"
subtitle="Monitor status and follow-ups for reported incidents"
emptyText="No incident tracking items found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Incidents navigation"
items={[
{ label: "Dashboard", href: "/panel/incidents" },
{ label: "New incident", href: "/panel/incidents/new" },
{ label: "Incident tracking", href: "/panel/incidents/tracking", active: true },
{ label: "Incident listing", href: "/panel/incidents/list" },
{ label: "Monthly data", href: "/panel/incidents/monthly" },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Inspection - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Inspection list"
subtitle="Review inspections and outcomes"
emptyText="No inspections found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Inspection navigation"
items={[
{ label: "Dashboard", href: "/panel/inspection" },
{ label: "Conduct inspection", href: "/panel/inspection/conduct" },
{ label: "Inspection tracking", href: "/panel/inspection/tracking" },
{ label: "Inspection list", href: "/panel/inspection/list", active: true },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Inspection Tracking - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Inspection tracking"
subtitle="Track findings and corrective actions"
emptyText="No inspection tracking items found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Inspection navigation"
items={[
{ label: "Dashboard", href: "/panel/inspection" },
{ label: "Conduct inspection", href: "/panel/inspection/conduct" },
{ label: "Inspection tracking", href: "/panel/inspection/tracking", active: true },
{ label: "Inspection list", href: "/panel/inspection/list" },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Permit - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Permit list"
subtitle="Browse and manage submitted permits"
emptyText="No permits found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Permit navigation"
items={[
{ label: "Dashboard", href: "/panel/permit" },
{ label: "Create project", href: "/panel/permit/create-project" },
{ label: "Permit list", href: "/panel/permit/list", active: true },
{ label: "Invite contractor", href: "/panel/permit/invite-contractor" },
{ label: "Contractor list", href: "/panel/permit/contractors" },
]}
/>
}
tableOnly
/>
);
}
import { DashboardCard } from "@/components/panel/dashboard/DashboardCard/DashboardCard";
export default function Page() {
return (
<div style={{ padding: "1rem" }}>
<DashboardCard title="Profile" subtitle="Coming soon">
Your profile page will appear here.
</DashboardCard>
</div>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Suggestion - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Suggestion list"
subtitle="Browse and manage improvement suggestions"
emptyText="No suggestions found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Suggestion navigation"
items={[
{ label: "Dashboard", href: "/panel/suggestion" },
{ label: "Add suggestion", href: "/panel/suggestion/new" },
{ label: "Suggestion tracking", href: "/panel/suggestion/tracking" },
{ label: "Suggestion list", href: "/panel/suggestion/list", active: true },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Suggestion Tracking - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Suggestion tracking"
subtitle="Track status of submitted suggestions"
emptyText="No suggestion tracking items found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Suggestion navigation"
items={[
{ label: "Dashboard", href: "/panel/suggestion" },
{ label: "Add suggestion", href: "/panel/suggestion/new" },
{ label: "Suggestion tracking", href: "/panel/suggestion/tracking", active: true },
{ label: "Suggestion list", href: "/panel/suggestion/list" },
]}
/>
}
tableOnly
/>
);
}
import { ListingPage, auditListingData } from "@/components/listing/base";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
export default function Page() {
const data = auditListingData.map((row) => ({
...row,
title: `Tracking - ${row.title}`,
}));
return (
<ListingPage
data={data}
title="Tracking list"
subtitle="Browse and follow up on corrective actions"
emptyText="No tracking items found for this filter set."
menu={
<ModuleDashboardMenu
ariaLabel="Tracking navigation"
items={[
{ label: "Dashboard", href: "/panel/tracking" },
{ label: "Tracking list", href: "/panel/tracking/list", active: true },
]}
/>
}
tableOnly
/>
);
}
......@@ -20,8 +20,7 @@ type Story = StoryObj;
type RadioStoryArgs = { variant?: "default" | "rating" };
type DatePickerStoryArgs = { withTime?: boolean };
export const TextInput: Story = {
render: () => {
function TextInputStory() {
const [value, setValue] = React.useState("");
return (
<div style={{ maxWidth: 520 }}>
......@@ -29,20 +28,13 @@ export const TextInput: Story = {
<label className="form-label" htmlFor="field-text">
Text input *
</label>
<TextInputField
id="field-text"
value={value}
onChange={setValue}
placeholder="Type here…"
/>
<TextInputField id="field-text" value={value} onChange={setValue} placeholder="Type here…" />
</div>
</div>
);
},
};
}
export const Textarea: Story = {
render: () => {
function TextareaStory() {
const [value, setValue] = React.useState("Initial note");
return (
<div style={{ maxWidth: 520 }}>
......@@ -50,32 +42,21 @@ export const Textarea: Story = {
<label className="form-label" htmlFor="field-textarea">
Textarea
</label>
<TextareaField
id="field-textarea"
value={value}
onChange={setValue}
rows={5}
/>
<TextareaField id="field-textarea" value={value} onChange={setValue} rows={5} />
</div>
</div>
);
},
};
}
export const RadioGroup: Story = {
render: (args: RadioStoryArgs) => {
const isRating = args.variant === "rating";
const [value, setValue] = React.useState<string | undefined>(
isRating ? "Yes" : undefined,
);
function RadioGroupStory({ variant }: RadioStoryArgs) {
const isRating = variant === "rating";
const [value, setValue] = React.useState<string | undefined>(isRating ? "Yes" : undefined);
const options = isRating ? ["Yes", "No", "NA"] : ["Pass", "Fail"];
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<div className="form-label">
{isRating ? "Rating (Yes / No / NA)" : "Radio group"}
</div>
<div className="form-label">{isRating ? "Rating (Yes / No / NA)" : "Radio group"}</div>
{isRating ? (
<div
style={{
......@@ -115,31 +96,20 @@ export const RadioGroup: Story = {
</div>
) : null}
<RadioGroupField
key={String(args.variant)}
key={String(variant)}
id={isRating ? "field-rating" : "field-radio"}
name={isRating ? "field-rating" : "field-radio"}
value={value}
options={options}
variant={args.variant}
variant={variant}
onChange={setValue}
/>
</div>
</div>
);
},
args: {
variant: "default",
},
argTypes: {
variant: {
control: "select",
options: ["default", "rating"],
},
},
};
}
export const Select: Story = {
render: () => {
function SelectStory() {
const [value, setValue] = React.useState<string | undefined>("No");
return (
<div style={{ maxWidth: 520 }}>
......@@ -157,11 +127,9 @@ export const Select: Story = {
</div>
</div>
);
},
};
}
export const MultiSelect: Story = {
render: () => {
function MultiSelectStory() {
const [value, setValue] = React.useState<string[]>(["No"]);
return (
<div style={{ maxWidth: 520 }}>
......@@ -179,62 +147,41 @@ export const MultiSelect: Story = {
</div>
</div>
);
},
};
}
export const DatePicker: Story = {
render: (args: DatePickerStoryArgs) => {
const [value, setValue] = React.useState(
args.withTime ? "2026-03-23T14:30" : "2026-03-23",
);
function DatePickerStory({ withTime }: DatePickerStoryArgs) {
const [value, setValue] = React.useState(withTime ? "2026-03-23T14:30" : "2026-03-23");
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<label className="form-label" htmlFor="field-datepicker">
{args.withTime ? "Date Time Picker" : "Date Picker"}
{withTime ? "Date Time Picker" : "Date Picker"}
</label>
<DatePickerField
id="field-datepicker"
value={value}
min="2020-01-01"
max="2035-12-31"
withTime={Boolean(args.withTime)}
withTime={Boolean(withTime)}
onChange={setValue}
/>
</div>
</div>
);
},
args: {
withTime: false,
},
argTypes: {
withTime: {
control: "boolean",
},
},
};
}
export const Checkbox: Story = {
render: () => {
function CheckboxStory() {
const [checked, setChecked] = React.useState(false);
return (
<div style={{ maxWidth: 520 }}>
<div className="form-group">
<CheckboxField
id="field-checkbox"
checked={checked}
label="Confirm checkbox"
onChange={setChecked}
/>
<CheckboxField id="field-checkbox" checked={checked} label="Confirm checkbox" onChange={setChecked} />
</div>
</div>
);
},
};
}
export const ToggleButton: Story = {
render: () => {
function ToggleButtonStory() {
const [checked, setChecked] = React.useState(false);
return (
<div style={{ maxWidth: 520 }}>
......@@ -252,26 +199,18 @@ export const ToggleButton: Story = {
</div>
</div>
);
},
};
}
export const OptionalComment: Story = {
render: () => {
function OptionalCommentStory() {
const [value, setValue] = React.useState("");
return (
<div style={{ maxWidth: 520 }}>
<OptionalCommentField
id="field-comment"
value={value}
onChange={setValue}
/>
<OptionalCommentField id="field-comment" value={value} onChange={setValue} />
</div>
);
},
};
}
export const LinkedFile: Story = {
render: () => {
function LinkedFileStory() {
const [files, setFiles] = React.useState<File[]>([]);
return (
<div style={{ maxWidth: 520 }}>
......@@ -289,5 +228,81 @@ export const LinkedFile: Story = {
</div>
</div>
);
}
export const TextInput: Story = {
render: () => {
return <TextInputStory />;
},
};
export const Textarea: Story = {
render: () => {
return <TextareaStory />;
},
};
export const RadioGroup: Story = {
render: (args: RadioStoryArgs) => {
return <RadioGroupStory {...args} />;
},
args: {
variant: "default",
},
argTypes: {
variant: {
control: "select",
options: ["default", "rating"],
},
},
};
export const Select: Story = {
render: () => {
return <SelectStory />;
},
};
export const MultiSelect: Story = {
render: () => {
return <MultiSelectStory />;
},
};
export const DatePicker: Story = {
render: (args: DatePickerStoryArgs) => {
return <DatePickerStory {...args} />;
},
args: {
withTime: false,
},
argTypes: {
withTime: {
control: "boolean",
},
},
};
export const Checkbox: Story = {
render: () => {
return <CheckboxStory />;
},
};
export const ToggleButton: Story = {
render: () => {
return <ToggleButtonStory />;
},
};
export const OptionalComment: Story = {
render: () => {
return <OptionalCommentStory />;
},
};
export const LinkedFile: Story = {
render: () => {
return <LinkedFileStory />;
},
};
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import "./bottom-tab.css";
export type BottomTabItem = {
......@@ -59,13 +61,21 @@ function Icon({ label }: { label: string }) {
export function BottomTab({
items = [
{ label: "Home", href: "#" },
{ label: "Modules", href: "#" },
{ label: "Incidents", href: "#" },
{ label: "Alerts", href: "#" },
{ label: "Profile", href: "#", active: true },
{ label: "Home", href: "/panel" },
{ label: "Modules", href: "/panel/modules" },
{ label: "Incidents", href: "/panel/incidents" },
{ label: "Alerts", href: "/panel/alerts" },
{ label: "Profile", href: "/panel/profile" },
],
}: BottomTabProps) {
const pathname = usePathname();
const isActiveHref = (href?: string) => {
if (!href) return false;
if (href === "/panel") return pathname === "/panel";
return pathname === href || pathname.startsWith(`${href}/`);
};
const homeItem = items.find((item) => item.label.toLowerCase() === "home");
const sideItems = items.filter((item) => item.label.toLowerCase() !== "home");
const leftItems = sideItems.slice(0, 2);
......@@ -76,12 +86,18 @@ export function BottomTab({
<div className="maf-bottom-tab__inner">
<div className="maf-bottom-tab__side maf-bottom-tab__side--left">
{leftItems.map((item) => (
<a key={item.label} href={item.href ?? "#"} className={`maf-bottom-tab__item ${item.active ? "is-active" : ""}`.trim()}>
<Link
key={item.label}
href={item.href ?? "/panel"}
className={`maf-bottom-tab__item ${item.active ?? isActiveHref(item.href) ? "is-active" : ""}`.trim()}
aria-current={item.active ?? isActiveHref(item.href) ? "page" : undefined}
prefetch
>
<span className="maf-bottom-tab__icon" aria-hidden>
<Icon label={item.label} />
</span>
<span className="maf-bottom-tab__label">{item.label}</span>
</a>
</Link>
))}
</div>
......@@ -89,26 +105,34 @@ export function BottomTab({
<div className="maf-bottom-tab__side maf-bottom-tab__side--right">
{rightItems.map((item) => (
<a key={item.label} href={item.href ?? "#"} className={`maf-bottom-tab__item ${item.active ? "is-active" : ""}`.trim()}>
<Link
key={item.label}
href={item.href ?? "/panel"}
className={`maf-bottom-tab__item ${item.active ?? isActiveHref(item.href) ? "is-active" : ""}`.trim()}
aria-current={item.active ?? isActiveHref(item.href) ? "page" : undefined}
prefetch
>
<span className="maf-bottom-tab__icon" aria-hidden>
<Icon label={item.label} />
</span>
<span className="maf-bottom-tab__label">{item.label}</span>
</a>
</Link>
))}
</div>
{homeItem ? (
<a
<Link
key={homeItem.label}
href={homeItem.href ?? "#"}
className={`maf-bottom-tab__item maf-bottom-tab__item--home ${homeItem.active ? "is-active" : ""}`.trim()}
href={homeItem.href ?? "/panel"}
className={`maf-bottom-tab__item maf-bottom-tab__item--home ${homeItem.active ?? isActiveHref(homeItem.href) ? "is-active" : ""}`.trim()}
aria-current={homeItem.active ?? isActiveHref(homeItem.href) ? "page" : undefined}
prefetch
>
<span className="maf-bottom-tab__icon maf-bottom-tab__icon--home" aria-hidden>
<Icon label={homeItem.label} />
</span>
<span className="maf-bottom-tab__label">{homeItem.label}</span>
</a>
</Link>
) : null}
</div>
</nav>
......
"use client";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import "./header.css";
......@@ -86,7 +87,7 @@ export function Header({
<div className="landing-header__inner">
<div className="landing-header__left">
<a className="landing-header__brand" href="/" aria-label={brandLabel}>
<Link className="landing-header__brand" href="/" aria-label={brandLabel}>
<span className="landing-header__logoWrap">
<Image
className="landing-header__logo"
......@@ -98,7 +99,7 @@ export function Header({
/>
</span>
<span className="landing-header__srOnly">{brandLabel}</span>
</a>
</Link>
</div>
<div className="landing-header__center">
......
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import "./sidebar.css";
export type SidebarItem = {
......@@ -99,6 +102,27 @@ function SidebarIcon({ label }: { label: string }) {
</svg>
);
}
if (icon === "ai copilot" || icon === "copilot" || icon === "ai") {
return (
<svg className="maf-sidebar__iconSvg" viewBox="0 0 24 24" aria-hidden>
<path
d="M7.2 8.3a3.2 3.2 0 0 1 6.4 0v.6h.6a3.2 3.2 0 1 1 0 6.4H9.6a3.2 3.2 0 1 1 0-6.4h.6v-.6Z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.1 18.2c1.2 1 2.5 1.6 3.9 1.6s2.7-.6 3.9-1.6"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
if (icon === "settings") {
return (
<svg className="maf-sidebar__iconSvg" viewBox="0 0 24 24" aria-hidden>
......@@ -125,7 +149,7 @@ function SidebarIcon({ label }: { label: string }) {
export function Sidebar({
items = [
{ label: "Home", href: "/panel", active: true },
{ label: "Home", href: "/panel" },
{ label: "Modules", href: "/panel/modules" },
{ label: "Admin", href: "/panel/admin" },
{ label: "Incidents", href: "/panel/incidents" },
......@@ -135,16 +159,26 @@ export function Sidebar({
{ label: "Checklist", href: "/panel/checklist" },
{ label: "Suggestion", href: "/panel/suggestion" },
{ label: "Tracking", href: "/panel/tracking" },
{ label: "AI Copilot", href: "/panel/ai" },
],
onLogout,
logoutHref = "/",
logoutLabel = "Logout",
}: SidebarProps) {
const pathname = usePathname();
const resolvedItems = useMemo(() => {
return items.map((item) => {
if (!item.href) return item;
const active = item.href === "/panel" ? pathname === "/panel" : pathname.startsWith(item.href);
return { ...item, active };
});
}, [items, pathname]);
return (
<aside className="maf-sidebar" aria-label="Sidebar navigation">
<div className="maf-sidebar__list">
{items.map((item) => (
<a
{resolvedItems.map((item) => (
<Link
key={item.label}
href={item.href ?? "#"}
className={`maf-sidebar__item ${item.active ? "is-active" : ""}`.trim()}
......@@ -153,7 +187,7 @@ export function Sidebar({
<SidebarIcon label={item.label} />
</span>
<span>{item.label}</span>
</a>
</Link>
))}
</div>
......@@ -172,12 +206,12 @@ export function Sidebar({
<span>{logoutLabel}</span>
</button>
) : (
<a className="maf-sidebar__item maf-sidebar__logout" href={logoutHref}>
<Link className="maf-sidebar__item maf-sidebar__logout" href={logoutHref}>
<span className="maf-sidebar__icon" aria-hidden>
<SidebarIcon label="logout" />
</span>
<span>{logoutLabel}</span>
</a>
</Link>
)}
</div>
</aside>
......
......@@ -10,6 +10,11 @@ import type { ListingFilterOption, ListingRecord, ListingViewMode } from "./list
type ListingPageProps = {
data: ListingRecord[];
title?: string;
subtitle?: string;
emptyText?: string;
tableOnly?: boolean;
menu?: React.ReactNode;
};
const countryOptions: ListingFilterOption[] = [
......@@ -25,12 +30,20 @@ const statusOptions: ListingFilterOption[] = [
{ label: "Completed", value: "Completed" },
];
export function ListingPage({ data }: ListingPageProps) {
export function ListingPage({
data,
title = "Listing",
subtitle = "Manage and review records",
emptyText,
tableOnly = false,
menu,
}: ListingPageProps) {
const responsiveMediaQuery = "(max-width: 1024px)";
const [query, setQuery] = useState("");
const [countries, setCountries] = useState<string[]>(["UAE", "KSA"]);
const [statuses, setStatuses] = useState<string[]>([]);
const [viewMode, setViewMode] = useState<ListingViewMode>(() => {
if (typeof window !== "undefined" && window.matchMedia("(max-width: 1024px)").matches) {
if (typeof window !== "undefined" && window.matchMedia(responsiveMediaQuery).matches) {
return "cards";
}
return "table";
......@@ -38,9 +51,14 @@ export function ListingPage({ data }: ListingPageProps) {
useEffect(() => {
if (typeof window === "undefined") return;
if (window.matchMedia("(max-width: 1024px)").matches) {
setViewMode("cards");
}
const mediaQuery = window.matchMedia(responsiveMediaQuery);
const onChange = () => setViewMode(mediaQuery.matches ? "cards" : "table");
// Ensure correct initial mode on mount (esp. when SSR default differs).
onChange();
mediaQuery.addEventListener("change", onChange);
return () => mediaQuery.removeEventListener("change", onChange);
}, []);
const filtered = useMemo(() => {
......@@ -68,7 +86,7 @@ export function ListingPage({ data }: ListingPageProps) {
};
return (
<ListingShell title="Audits" subtitle="Manage and review all audit submissions">
<ListingShell title={title} subtitle={subtitle} menu={menu}>
<ListingToolbar
query={query}
onQueryChange={setQuery}
......@@ -80,6 +98,7 @@ export function ListingPage({ data }: ListingPageProps) {
onStatusToggle={(value) => toggleValue(statuses, value, setStatuses)}
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={!tableOnly}
/>
<ListingActiveChips
......@@ -94,7 +113,11 @@ export function ListingPage({ data }: ListingPageProps) {
}}
/>
{viewMode === "table" ? <ListingTable rows={filtered} /> : <ListingCards rows={filtered} />}
{viewMode === "table" ? (
<ListingTable rows={filtered} emptyText={emptyText} />
) : (
<ListingCards rows={filtered} />
)}
</ListingShell>
);
}
......
......@@ -4,11 +4,12 @@ import "./listing-shell.css";
export type ListingShellProps = {
title: string;
subtitle?: string;
menu?: React.ReactNode;
actions?: React.ReactNode;
children: React.ReactNode;
};
export function ListingShell({ title, subtitle, actions, children }: ListingShellProps) {
export function ListingShell({ title, subtitle, menu, actions, children }: ListingShellProps) {
return (
<section className="maf-listing-shell">
<header className="maf-listing-shell__header">
......@@ -18,6 +19,7 @@ export function ListingShell({ title, subtitle, actions, children }: ListingShel
</div>
{actions ? <div className="maf-listing-shell__actions">{actions}</div> : null}
</header>
{menu ? <div className="maf-listing-shell__menu">{menu}</div> : null}
{children}
</section>
);
......
......@@ -3,6 +3,7 @@ import "./listing-table.css";
type ListingTableProps = {
rows: ListingRecord[];
emptyText?: string;
};
function scoreClass(score: number | null) {
......@@ -19,9 +20,9 @@ function scoreLabel(score: number | null) {
return "Low score";
}
export function ListingTable({ rows }: ListingTableProps) {
export function ListingTable({ rows, emptyText = "No results found." }: ListingTableProps) {
if (!rows.length) {
return <div className="maf-listing-table__empty">No audits found for this filter set.</div>;
return <div className="maf-listing-table__empty">{emptyText}</div>;
}
return (
......
......@@ -18,10 +18,7 @@ const meta = {
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: () => {
function InteractiveToolbar() {
const [query, setQuery] = useState("");
const [countries, setCountries] = useState<string[]>(["UAE"]);
const [statuses, setStatuses] = useState<string[]>([]);
......@@ -52,6 +49,13 @@ export const Interactive: Story = {
onViewModeChange={setView}
/>
);
}
export const Interactive: Story = {
// Render-only story; satisfy Storybook/TS when `args` is required on the inferred type.
args: {} as Story["args"],
render: () => {
return <InteractiveToolbar />;
},
};
......@@ -12,6 +12,7 @@ type ListingToolbarProps = {
onStatusToggle: (value: string) => void;
viewMode: ListingViewMode;
onViewModeChange: (mode: ListingViewMode) => void;
showViewToggle?: boolean;
};
function ViewIcon({ mode }: { mode: ListingViewMode }) {
......@@ -64,6 +65,7 @@ export function ListingToolbar({
onStatusToggle,
viewMode,
onViewModeChange,
showViewToggle = true,
}: ListingToolbarProps) {
return (
<div className="maf-listing-toolbar">
......@@ -104,6 +106,7 @@ export function ListingToolbar({
))}
</div>
{showViewToggle ? (
<div className="maf-listing-toolbar__view">
<button
type="button"
......@@ -122,6 +125,7 @@ export function ListingToolbar({
<ViewIcon mode="cards" />
</button>
</div>
) : null}
</div>
);
}
......
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ModuleDashboardMenu } from "@/components/panel/modules/ModuleDashboardMenu/ModuleDashboardMenu";
import { ListingTable } from "@/components/listing/base/ListingTable";
import { ListingCards } from "@/components/listing/base/ListingCards";
import { auditListingData } from "@/components/listing/base/audit-listing-data";
import type { ListingRecord } from "@/components/listing/base/listing-types";
import "./ai-copilot-page.css";
type ChatRole = "user" | "assistant";
type CopilotAction = {
id: string;
label: string;
kind: "navigate" | "applyFilters" | "createTasks";
payload?: Record<string, unknown>;
};
type ChatMessage = {
role: ChatRole;
content: string;
actions?: CopilotAction[];
};
const demoModules = [
{ id: "incidents", label: "Incidents", href: "/panel/incidents" },
{ id: "audits", label: "Audits", href: "/panel/audits" },
{ id: "inspection", label: "Inspection", href: "/panel/inspection" },
{ id: "checklist", label: "Checklist", href: "/panel/checklist" },
{ id: "suggestion", label: "Suggestion", href: "/panel/suggestion" },
{ id: "tracking", label: "Tracking", href: "/panel/tracking" },
] as const;
type DemoModuleId = (typeof demoModules)[number]["id"];
function isDemoModuleId(value: string): value is DemoModuleId {
return (demoModules as readonly { id: string }[]).some((m) => m.id === value);
}
function formatPreview(rows: ListingRecord[]) {
const head = rows.slice(0, 3).map((r) => `- ${r.title} · ${r.country} · ${r.status} · score=${r.score ?? "—"}`);
return head.join("\n");
}
function buildMockResponse(input: {
moduleId: string;
intent: "summary" | "followups" | "explain" | "custom";
prompt: string;
attached: boolean;
}) {
const moduleLabel = demoModules.find((m) => m.id === input.moduleId)?.label ?? "Module";
if (input.intent === "followups") {
return {
content:
`I can convert the current ${moduleLabel} view into a short, actionable follow-up plan.\n\n` +
`Top 3 follow-ups (demo):\n` +
`1) Assign owners for Ongoing items with low/no score.\n` +
`2) Flag overdue / SLA-risk items for review.\n` +
`3) Generate a weekly digest grouped by country & status.\n\n` +
`Want me to create tasks for the most urgent items?`,
actions: [
{ id: "create_tasks", label: "Create follow-up tasks (demo)", kind: "createTasks" },
{ id: "filter_ongoing", label: "Filter: Ongoing", kind: "applyFilters", payload: { status: ["Ongoing"] } },
] satisfies CopilotAction[],
};
}
if (input.intent === "explain") {
return {
content:
`Heres what stands out in ${moduleLabel} (demo):\n\n` +
`- Scores vary significantly; prioritize the lowest-scoring Ongoing items.\n` +
`- No score usually means missing evidence or pending review.\n` +
`- Clusters by location can indicate a systemic issue (process/training).\n\n` +
`If you want, I can open the most relevant list view and highlight the riskiest records.`,
actions: [
{ id: "nav_tracking", label: "Open Tracking list", kind: "navigate", payload: { href: "/panel/tracking/list" } },
] satisfies CopilotAction[],
};
}
if (input.intent === "summary") {
return {
content:
`Summary for ${moduleLabel} (demo):\n\n` +
`- You have a mix of Completed and Ongoing records.\n` +
`- Primary focus: Ongoing items with low/no score.\n` +
`- Suggested next step: filter to Ongoing, then sort by score ascending.\n\n` +
(input.attached ? `I used the attached rows preview:\n${formatPreview(auditListingData)}\n` : `Tip: attach a table snapshot for sharper results.`),
actions: [
{ id: "filter_low_score", label: "Focus: low scores", kind: "applyFilters", payload: { lowScore: true } },
{ id: "nav_list", label: "Open module listing", kind: "navigate", payload: { href: `/panel/${input.moduleId}/list` } },
] satisfies CopilotAction[],
};
}
return {
content:
`I’m ready.\n\n` +
`Tell me what you want to do in ${moduleLabel}: summarize, find anomalies, draft a report, or generate follow-ups.\n` +
`In this demo, I can also open the relevant list/tracking screens and apply “AI filters” to focus the work.`,
actions: [
{ id: "quick_summary", label: "Summarize this view", kind: "applyFilters", payload: { summary: true } },
] satisfies CopilotAction[],
};
}
export type AICopilotPageProps = {
variant?: "page" | "drawer";
initialModuleId?: DemoModuleId;
};
export function AICopilotPage({ variant = "page", initialModuleId = "incidents" }: AICopilotPageProps) {
const router = useRouter();
const [moduleId, setModuleId] = useState<DemoModuleId>(initialModuleId);
const [attachTable, setAttachTable] = useState(true);
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
const [statusFilter, setStatusFilter] = useState<Array<"Ongoing" | "Completed">>([]);
const [lowScoreOnly, setLowScoreOnly] = useState(false);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>(() => [
{
role: "assistant",
content:
"MAF Copilot (demo) is ready.\n\n" +
"Ask me to summarize a list, explain patterns, or generate follow-up tasks. " +
"I can also open the right module page and apply AI-focused filters.",
actions: [
{ id: "demo_summary", label: "Summarize current view", kind: "applyFilters", payload: { intent: "summary" } },
{ id: "demo_explain", label: "Explain what stands out", kind: "applyFilters", payload: { intent: "explain" } },
{ id: "demo_followups", label: "Generate follow-ups", kind: "applyFilters", payload: { intent: "followups" } },
],
},
]);
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages]);
useEffect(() => {
setModuleId(initialModuleId);
}, [initialModuleId]);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia("(max-width: 1024px)");
const onChange = () => setViewMode(mq.matches ? "cards" : "table");
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const rows = useMemo(() => {
let base = auditListingData.map((r) => ({
...r,
title: `${demoModules.find((m) => m.id === moduleId)?.label ?? "Module"} ${r.title}`,
}));
if (statusFilter.length) base = base.filter((r) => statusFilter.includes(r.status));
if (lowScoreOnly) base = base.filter((r) => r.score !== null && r.score < 50);
return base;
}, [lowScoreOnly, moduleId, statusFilter]);
const menu = (
<ModuleDashboardMenu
ariaLabel="AI Copilot navigation"
items={[
{ label: "Copilot", href: "/panel/ai", active: true },
{ label: "Incidents list", href: "/panel/incidents/list" },
{ label: "Audits list", href: "/panel/audits/list" },
{ label: "Tracking list", href: "/panel/tracking/list" },
]}
/>
);
const send = (prompt: string, intent: "summary" | "followups" | "explain" | "custom" = "custom") => {
const trimmed = prompt.trim();
if (!trimmed || busy) return;
setBusy(true);
const userMsg: ChatMessage = { role: "user", content: trimmed };
setDraft("");
const mock = buildMockResponse({ moduleId, intent, prompt: trimmed, attached: attachTable });
const full = mock.content;
setMessages((prev) => {
const base = [...prev, userMsg, { role: "assistant" as const, content: "", actions: mock.actions }];
const assistantIndex = base.length - 1;
const step = 18;
const totalSteps = Math.max(1, Math.ceil(full.length / step));
const ms = 35;
const tick = (s: number) => {
const nextLen = Math.min(full.length, s * step);
const partial = full.slice(0, nextLen);
setMessages((current) => {
if (assistantIndex >= current.length) return current;
const next = current.slice();
const existing = next[assistantIndex];
if (!existing || existing.role !== "assistant") return current;
next[assistantIndex] = { ...existing, content: partial, actions: mock.actions };
return next;
});
if (s >= totalSteps) {
setBusy(false);
return;
}
window.setTimeout(() => tick(s + 1), ms);
};
if (typeof window !== "undefined") {
window.setTimeout(() => tick(1), ms);
} else {
base[assistantIndex] = { ...base[assistantIndex], content: full, actions: mock.actions };
}
return base;
});
};
const applyAction = (action: CopilotAction) => {
if (action.kind === "navigate") {
const href = String(action.payload?.href ?? "/panel");
router.push(href);
return;
}
if (action.kind === "applyFilters") {
const status = action.payload?.status as Array<"Ongoing" | "Completed"> | undefined;
if (status) setStatusFilter(status);
if (action.payload?.lowScore) setLowScoreOnly(true);
const intent = action.payload?.intent as "summary" | "followups" | "explain" | undefined;
if (intent) send(`Apply AI focus for ${intent}.`, intent);
return;
}
if (action.kind === "createTasks") {
send("Create follow-up tasks for the most urgent items and assign owners.", "followups");
}
};
const promptChips = [
{ label: "Summarize this list", onClick: () => send("Summarize the current list and key risks.", "summary") },
{ label: "What should I do next?", onClick: () => send("What should I do next? Prioritize the top items.", "followups") },
{ label: "Explain anomalies", onClick: () => send("Explain any anomalies and likely root causes.", "explain") },
{ label: "Draft weekly digest", onClick: () => send("Draft a weekly digest grouped by country and status.", "custom") },
];
return (
<div className={`maf-aiCopilot maf-aiCopilot--${variant}`.trim()}>
{variant === "page" ? (
<>
<header className="maf-aiCopilot__header">
<div>
<h1 className="maf-aiCopilot__title">AI Copilot</h1>
<p className="maf-aiCopilot__subtitle">Chat + context + actions — a demo-ready AI experience for MAF Gateway.</p>
</div>
</header>
{menu}
</>
) : null}
<section className="maf-aiCopilot__layout" aria-label="AI Copilot layout">
<div className="maf-aiCopilot__chat">
<div className="maf-aiCopilot__chatTop">
<div className="maf-aiCopilot__chips" aria-label="Suggested prompts">
{promptChips.map((c) => (
<button key={c.label} type="button" className="maf-aiCopilot__chip" onClick={c.onClick} disabled={busy}>
{c.label}
</button>
))}
</div>
</div>
<div ref={scrollRef} className="maf-aiCopilot__messages" aria-label="Chat messages">
{messages.map((m, idx) => (
<div key={`${m.role}-${idx}`} className={`maf-aiCopilot__msg maf-aiCopilot__msg--${m.role}`.trim()}>
<div className="maf-aiCopilot__bubble">
<pre className="maf-aiCopilot__text">{m.content}</pre>
{m.actions?.length ? (
<div className="maf-aiCopilot__actions" aria-label="Copilot actions">
{m.actions.map((a) => (
<button
key={a.id}
type="button"
className="maf-aiCopilot__actionBtn"
onClick={() => applyAction(a)}
disabled={busy}
>
{a.label}
</button>
))}
</div>
) : null}
</div>
</div>
))}
</div>
<form
className="maf-aiCopilot__composer"
onSubmit={(e) => {
e.preventDefault();
send(draft, "custom");
}}
>
<textarea
className="maf-aiCopilot__input"
value={draft}
placeholder="Ask Copilot… (e.g. ‘Summarize risks and propose next steps’)"
onChange={(e) => setDraft(e.target.value)}
rows={2}
/>
<div className="maf-aiCopilot__composerRight">
<button type="submit" className="maf-aiCopilot__send" disabled={busy || !draft.trim()}>
{busy ? "Thinking…" : "Send"}
</button>
</div>
</form>
</div>
<aside className="maf-aiCopilot__context" aria-label="Copilot context">
<div className="maf-aiCopilot__panel">
<div className="maf-aiCopilot__panelTitle">Context</div>
<label className="maf-aiCopilot__field">
<span>Module</span>
<select
className="maf-aiCopilot__select"
value={moduleId}
onChange={(e) => {
const value = e.target.value;
if (isDemoModuleId(value)) setModuleId(value);
}}
>
{demoModules.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
</label>
<div className="maf-aiCopilot__row">
<label className="maf-aiCopilot__toggle">
<input type="checkbox" checked={attachTable} onChange={(e) => setAttachTable(e.target.checked)} />
<span>Attach table snapshot to prompts</span>
</label>
</div>
<div className="maf-aiCopilot__row">
<label className="maf-aiCopilot__toggle">
<input type="checkbox" checked={lowScoreOnly} onChange={(e) => setLowScoreOnly(e.target.checked)} />
<span>AI focus: low scores (&lt; 50)</span>
</label>
</div>
<div className="maf-aiCopilot__row maf-aiCopilot__filters">
<button
type="button"
className={`maf-aiCopilot__pill ${statusFilter.includes("Ongoing") ? "is-active" : ""}`.trim()}
onClick={() =>
setStatusFilter((s) => (s.includes("Ongoing") ? s.filter((x) => x !== "Ongoing") : [...s, "Ongoing"]))
}
>
Ongoing
</button>
<button
type="button"
className={`maf-aiCopilot__pill ${statusFilter.includes("Completed") ? "is-active" : ""}`.trim()}
onClick={() =>
setStatusFilter((s) =>
s.includes("Completed") ? s.filter((x) => x !== "Completed") : [...s, "Completed"],
)
}
>
Completed
</button>
<button
type="button"
className="maf-aiCopilot__pill maf-aiCopilot__pill--ghost"
onClick={() => {
setLowScoreOnly(false);
setStatusFilter([]);
}}
>
Clear
</button>
</div>
</div>
<div className="maf-aiCopilot__panel maf-aiCopilot__panel--table">
<div className="maf-aiCopilot__panelTitle">
Live preview <span className="maf-aiCopilot__muted">({rows.length} rows)</span>
</div>
<div className="maf-aiCopilot__preview">
{viewMode === "table" ? (
<ListingTable rows={rows} emptyText="No rows match the current AI focus." />
) : (
<ListingCards rows={rows} />
)}
</div>
</div>
<div className="maf-aiCopilot__panel">
<div className="maf-aiCopilot__panelTitle">AI “one-click” flows</div>
<div className="maf-aiCopilot__flowList">
<button type="button" className="maf-aiCopilot__flow" onClick={() => send("Summarize and open the right list.", "summary")}>
<strong>Summarize → Open list</strong>
<span>Shows navigation + focus filters as an AI action.</span>
</button>
<button type="button" className="maf-aiCopilot__flow" onClick={() => send("Generate follow-ups and create tasks.", "followups")}>
<strong>Generate follow-ups → Create tasks</strong>
<span>Demo of AI producing actionable next steps.</span>
</button>
<button type="button" className="maf-aiCopilot__flow" onClick={() => send("Explain why scores differ and what to check.", "explain")}>
<strong>Explain anomalies</strong>
<span>Demo of insight + guidance in plain language.</span>
</button>
</div>
</div>
</aside>
</section>
</div>
);
}
.maf-aiCopilot {
--maf-brown: #6e5a44;
--maf-brown-soft: #a78a68;
--maf-cream: #fbf7f1;
--maf-ink: rgba(38, 28, 18, 0.92);
--maf-muted: rgba(110, 90, 68, 0.7);
display: grid;
gap: 1rem;
}
.maf-aiCopilot--drawer {
height: 100%;
min-height: 0;
}
.maf-aiCopilot--drawer .maf-aiCopilot__header {
display: none;
}
.maf-aiCopilot--drawer .maf-aiCopilot__layout {
grid-template-columns: 1fr;
height: 100%;
min-height: 0;
}
.maf-aiCopilot--drawer .maf-aiCopilot__context {
display: none;
}
.maf-aiCopilot--drawer .maf-aiCopilot__chat {
height: 100%;
min-height: 0;
}
.maf-aiCopilot__header {
padding: 0.6rem 0.2rem 0;
}
.maf-aiCopilot__title {
margin: 0;
font-size: 1.75rem;
letter-spacing: -0.02em;
color: var(--maf-ink);
}
.maf-aiCopilot__subtitle {
margin: 0.35rem 0 0;
color: var(--maf-muted);
}
.maf-aiCopilot__layout {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 1rem;
align-items: start;
min-height: 0;
}
.maf-aiCopilot__chat {
min-height: 70vh;
display: grid;
grid-template-rows: auto 1fr auto;
border-radius: 16px;
border: 1px solid rgba(110, 90, 68, 0.2);
background:
radial-gradient(900px 360px at 20% 0%, rgba(167, 138, 104, 0.14), transparent 60%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(251, 247, 241, 0.98));
overflow: hidden;
box-shadow: 0 18px 50px rgba(30, 20, 10, 0.06);
min-height: 0;
}
.maf-aiCopilot__chatTop {
padding: 0.9rem 0.9rem 0.6rem;
border-bottom: 1px solid rgba(110, 90, 68, 0.16);
}
.maf-aiCopilot__chips {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.maf-aiCopilot__chip {
border: 1px solid rgba(110, 90, 68, 0.22);
background: rgba(255, 255, 255, 0.88);
color: rgba(110, 90, 68, 0.95);
border-radius: 999px;
padding: 0.45rem 0.7rem;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
transition: transform 0.12s ease, background-color 0.12s ease;
}
.maf-aiCopilot__chip:hover {
transform: translateY(-1px);
background: rgba(167, 138, 104, 0.12);
}
.maf-aiCopilot__chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.maf-aiCopilot__messages {
padding: 0.9rem;
overflow: auto;
display: grid;
gap: 0.7rem;
min-height: 0;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.maf-aiCopilot__msg {
display: flex;
}
.maf-aiCopilot__msg--user {
justify-content: flex-end;
}
.maf-aiCopilot__msg--assistant {
justify-content: flex-start;
}
.maf-aiCopilot__bubble {
max-width: min(620px, 95%);
border-radius: 14px;
padding: 0.75rem 0.8rem;
border: 1px solid rgba(110, 90, 68, 0.18);
background: rgba(255, 255, 255, 0.88);
}
.maf-aiCopilot__msg--user .maf-aiCopilot__bubble {
background: rgba(110, 90, 68, 0.08);
border-color: rgba(110, 90, 68, 0.22);
}
.maf-aiCopilot__text {
margin: 0;
white-space: pre-wrap;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
color: var(--maf-ink);
line-height: 1.45;
}
.maf-aiCopilot__actions {
margin-top: 0.65rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.maf-aiCopilot__actionBtn {
border: 1px solid rgba(110, 90, 68, 0.24);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(251, 247, 241, 0.92));
color: rgba(110, 90, 68, 0.98);
border-radius: 10px;
padding: 0.45rem 0.6rem;
font-weight: 700;
cursor: pointer;
transition: transform 0.12s ease;
}
.maf-aiCopilot__actionBtn:hover {
transform: translateY(-1px);
}
.maf-aiCopilot__actionBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.maf-aiCopilot__composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.6rem;
padding: 0.85rem 0.9rem;
border-top: 1px solid rgba(110, 90, 68, 0.16);
background: rgba(251, 247, 241, 0.9);
}
.maf-aiCopilot__input {
width: 100%;
resize: none;
border-radius: 12px;
border: 1px solid rgba(110, 90, 68, 0.22);
padding: 0.6rem 0.7rem;
background: rgba(255, 255, 255, 0.92);
outline: none;
color: var(--maf-ink);
}
.maf-aiCopilot__composerRight {
display: flex;
align-items: stretch;
}
.maf-aiCopilot__send {
border: 1px solid rgba(110, 90, 68, 0.28);
border-radius: 12px;
padding: 0 0.9rem;
font-weight: 800;
cursor: pointer;
color: rgba(255, 255, 255, 0.96);
background: linear-gradient(180deg, rgba(110, 90, 68, 0.96), rgba(80, 65, 48, 0.98));
box-shadow: 0 12px 24px rgba(40, 30, 20, 0.08);
}
.maf-aiCopilot__send:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.maf-aiCopilot__context {
display: grid;
gap: 1rem;
}
.maf-aiCopilot__panel {
border-radius: 16px;
border: 1px solid rgba(110, 90, 68, 0.2);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 50px rgba(30, 20, 10, 0.05);
padding: 0.9rem;
}
.maf-aiCopilot__panel--table {
padding-bottom: 0.4rem;
}
.maf-aiCopilot__panelTitle {
font-weight: 900;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 0.78rem;
color: rgba(110, 90, 68, 0.88);
margin-bottom: 0.7rem;
display: flex;
align-items: baseline;
gap: 0.45rem;
}
.maf-aiCopilot__muted {
color: rgba(110, 90, 68, 0.62);
font-weight: 750;
text-transform: none;
letter-spacing: 0;
}
.maf-aiCopilot__field {
display: grid;
gap: 0.35rem;
font-weight: 750;
color: rgba(110, 90, 68, 0.92);
}
.maf-aiCopilot__select {
border-radius: 12px;
border: 1px solid rgba(110, 90, 68, 0.22);
padding: 0.55rem 0.6rem;
background: rgba(251, 247, 241, 0.85);
color: var(--maf-ink);
}
.maf-aiCopilot__row {
margin-top: 0.75rem;
}
.maf-aiCopilot__toggle {
display: flex;
gap: 0.6rem;
align-items: center;
color: rgba(110, 90, 68, 0.9);
font-weight: 700;
}
.maf-aiCopilot__filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.maf-aiCopilot__pill {
border-radius: 999px;
padding: 0.4rem 0.65rem;
border: 1px solid rgba(110, 90, 68, 0.22);
background: rgba(251, 247, 241, 0.85);
color: rgba(110, 90, 68, 0.95);
font-weight: 800;
cursor: pointer;
}
.maf-aiCopilot__pill.is-active {
background: rgba(167, 138, 104, 0.18);
border-color: rgba(110, 90, 68, 0.28);
}
.maf-aiCopilot__pill--ghost {
background: transparent;
opacity: 0.9;
}
.maf-aiCopilot__preview {
max-height: 360px;
overflow: auto;
border-radius: 12px;
}
.maf-aiCopilot__flowList {
display: grid;
gap: 0.55rem;
}
.maf-aiCopilot__flow {
width: 100%;
text-align: left;
border-radius: 14px;
border: 1px solid rgba(110, 90, 68, 0.2);
background: linear-gradient(180deg, rgba(251, 247, 241, 0.92), rgba(255, 255, 255, 0.92));
padding: 0.75rem 0.8rem;
cursor: pointer;
transition: transform 0.12s ease;
}
.maf-aiCopilot__flow:hover {
transform: translateY(-1px);
}
.maf-aiCopilot__flow strong {
display: block;
color: var(--maf-ink);
margin-bottom: 0.25rem;
}
.maf-aiCopilot__flow span {
color: rgba(110, 90, 68, 0.7);
font-weight: 650;
}
@media (max-width: 1024px) {
.maf-aiCopilot__layout {
grid-template-columns: 1fr;
}
.maf-aiCopilot__chat {
min-height: 62vh;
}
}
......@@ -31,8 +31,10 @@ export function ChecklistDashboard() {
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" },
// Action plan = tracking view
{ label: "Checklist action plan", href: "/panel/checklist/tracking" },
// Status = listing view
{ label: "Checklist status", href: "/panel/checklist/list" },
]}
/>
......
......@@ -27,19 +27,26 @@ export function DonutChart({
const radius = (size - thickness) / 2;
const circumference = 2 * Math.PI * radius;
let offset = 0;
const rendered = slices.map((slice) => {
const rendered = slices.reduce(
(acc, 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;
const dashOffset = -acc.offset;
return {
offset: acc.offset + dash,
items: [
...acc.items,
{
...slice,
dashArray,
dashOffset,
},
],
};
});
},
{ offset: 0, items: [] as Array<DonutSlice & { dashArray: string; dashOffset: number }> },
).items;
return (
<div className="maf-donut">
......
......@@ -66,6 +66,15 @@ export const modules: ModuleItem[] = [
category: "Assurance",
},
{
id: "ai-copilot",
title: "AI Copilot",
description: "Chat, insights and one-click actions across modules (demo).",
href: "/panel/ai",
badge: "Demo",
meta: "Context-aware",
category: "Admin",
},
{
id: "admin",
title: "Admin",
description: "Users, roles, configuration and operational control centre.",
......
......@@ -9,6 +9,7 @@ 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 { AICopilotDock } from "@/components/widgets/AICopilotDock/AICopilotDock";
import { LocationSelector } from "@/components/widgets/LocationSelector/LocationSelector";
import { PanelModuleSearch } from "@/components/widgets/PanelModuleSearch/PanelModuleSearch";
import { panelNotificationSections } from "@/app/panel/panel-notifications";
......@@ -180,6 +181,7 @@ export function PanelShellClient({ children }: { children: React.ReactNode }) {
) : null}
<BottomTab />
<AICopilotDock />
<QuickAction />
</div>
);
......
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { AICopilotPage } from "@/components/panel/ai/AICopilotPage/AICopilotPage";
import type { AICopilotPageProps } from "@/components/panel/ai/AICopilotPage/AICopilotPage";
import "./ai-copilot-dock.css";
type DemoModuleId = NonNullable<AICopilotPageProps["initialModuleId"]>;
function moduleFromPath(pathname: string): DemoModuleId {
if (pathname.startsWith("/panel/audits")) return "audits";
if (pathname.startsWith("/panel/inspection")) return "inspection";
if (pathname.startsWith("/panel/checklist")) return "checklist";
if (pathname.startsWith("/panel/suggestion")) return "suggestion";
if (pathname.startsWith("/panel/tracking")) return "tracking";
if (pathname.startsWith("/panel/incidents")) return "incidents";
return "incidents";
}
export function AICopilotDock() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const initialModuleId = useMemo(() => moduleFromPath(pathname), [pathname]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") setOpen((v) => !v);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return (
<>
{open ? (
<button
type="button"
className="maf-aiDock__backdrop"
aria-label="Close AI Copilot"
onClick={() => setOpen(false)}
/>
) : null}
<aside className={`maf-aiDock ${open ? "is-open" : ""}`.trim()} aria-label="AI Copilot">
<div className="maf-aiDock__header">
<div className="maf-aiDock__titleWrap">
<div className="maf-aiDock__title">Copilot</div>
<div className="maf-aiDock__subtitle">
Context: <strong>{initialModuleId}</strong>
</div>
</div>
<button type="button" className="maf-aiDock__close" onClick={() => setOpen(false)} aria-label="Close Copilot">
×
</button>
</div>
<div className="maf-aiDock__content">
<AICopilotPage variant="drawer" initialModuleId={initialModuleId} />
</div>
</aside>
{!open ? (
<button
type="button"
className="maf-aiDock__fab"
onClick={() => setOpen(true)}
aria-label="Open AI Copilot"
title="AI Copilot (Ctrl/⌘ + K)"
>
<span className="maf-aiDock__fabIcon" aria-hidden>
<svg viewBox="0 0 24 24" className="maf-aiDock__fabIconSvg">
<rect x="6.2" y="8.2" width="11.6" height="8.4" rx="3.1" fill="none" stroke="currentColor" strokeWidth="1.6" />
<circle cx="10" cy="12" r="1.1" fill="currentColor" />
<circle cx="14" cy="12" r="1.1" fill="currentColor" />
<path
d="M9.4 16.2c.6.6 1.5 1 2.6 1s2-.4 2.6-1"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
<path
d="M8 6.3c.6-.9 2-1.6 4-1.6s3.4.7 4 1.6"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</span>
</button>
) : null}
</>
);
}
.maf-aiDock__fab {
position: fixed;
right: 6.4rem;
bottom: 1.6rem;
z-index: 60;
border: 1px solid rgba(110, 90, 68, 0.28);
border-radius: 999px;
padding: 0.72rem 0.95rem;
font-weight: 900;
letter-spacing: 0.02em;
cursor: pointer;
color: rgba(255, 255, 255, 0.96);
background: linear-gradient(180deg, rgba(110, 90, 68, 0.98), rgba(80, 65, 48, 0.98));
box-shadow: 0 18px 44px rgba(30, 20, 10, 0.18);
width: 54px;
height: 54px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
}
@media (min-width: 1025px) {
.maf-aiDock__fab {
/* Keep Copilot above footer/legal links on desktop */
bottom: 5.25rem;
}
}
.maf-aiDock__fab.is-open {
background: linear-gradient(180deg, rgba(90, 72, 54, 0.98), rgba(70, 55, 40, 0.98));
}
.maf-aiDock__fabIconSvg {
width: 21px;
height: 21px;
}
.maf-aiDock__backdrop {
position: fixed;
inset: 0;
z-index: 58;
border: 0;
background: rgba(20, 14, 8, 0.22);
backdrop-filter: blur(3px);
}
.maf-aiDock {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: min(520px, 92vw);
z-index: 59;
transform: translateX(104%);
transition: transform 0.22s ease;
background:
radial-gradient(900px 360px at 20% 0%, rgba(167, 138, 104, 0.16), transparent 60%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(251, 247, 241, 0.98));
border-left: 1px solid rgba(110, 90, 68, 0.22);
box-shadow: -30px 0 80px rgba(30, 20, 10, 0.18);
display: grid;
grid-template-rows: auto 1fr;
}
.maf-aiDock.is-open {
transform: translateX(0);
}
.maf-aiDock__header {
padding: 0.9rem 1rem;
border-bottom: 1px solid rgba(110, 90, 68, 0.16);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.maf-aiDock__titleWrap {
display: grid;
gap: 0.15rem;
}
.maf-aiDock__title {
font-weight: 950;
letter-spacing: -0.02em;
color: rgba(38, 28, 18, 0.92);
}
.maf-aiDock__subtitle {
font-weight: 750;
color: rgba(110, 90, 68, 0.7);
font-size: 0.86rem;
}
.maf-aiDock__close {
border: 1px solid rgba(110, 90, 68, 0.22);
background: rgba(255, 255, 255, 0.8);
border-radius: 12px;
width: 38px;
height: 38px;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
color: rgba(110, 90, 68, 0.95);
}
.maf-aiDock__content {
padding: 0.85rem;
overflow: hidden;
min-height: 0;
}
.maf-aiDock__content .maf-aiCopilot__chat {
height: calc(100vh - 140px);
min-height: 0;
}
@media (max-width: 1024px) {
.maf-aiDock__fab {
right: 1rem;
bottom: calc(4.6rem + env(safe-area-inset-bottom));
}
}
......@@ -79,7 +79,9 @@ export function QuickAction({
aria-expanded={open}
aria-label={open ? "Close quick actions" : "Open quick actions"}
>
<span className="maf-quick-action__fabIcon" aria-hidden>
{open ? "×" : "+"}
</span>
</button>
</div>
);
......
......@@ -13,7 +13,7 @@
.maf-quick-action__floatingWrap {
position: fixed;
right: 1.1rem;
bottom: 1.1rem;
bottom: 1.6rem;
z-index: 45;
display: flex;
flex-direction: column;
......@@ -21,6 +21,13 @@
gap: 0.65rem;
}
@media (min-width: 1025px) {
.maf-quick-action__floatingWrap {
/* Keep FAB above footer/legal links on desktop */
bottom: 5.25rem;
}
}
.maf-quick-action--floatingPanel {
width: min(280px, calc(100vw - 2rem));
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.5);
......@@ -64,8 +71,7 @@
height: 54px;
border-radius: 999px;
border: 0;
font-size: 1.85rem;
line-height: 1;
font-size: 1.1rem;
color: rgba(110, 90, 68, 0.95);
/* Frosted glass: transparent but readable */
background: rgba(255, 255, 255, 0.52);
......@@ -76,6 +82,9 @@
inset 0 1px 0 rgba(255, 255, 255, 0.65);
border: 1px solid rgba(110, 90, 68, 0.22);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
transform 160ms ease,
background 160ms ease,
......@@ -106,15 +115,20 @@
}
.maf-quick-action__fab.is-open {
font-size: 1.95rem;
transform: rotate(0deg);
}
.maf-quick-action__fabIcon {
font-size: 1.6rem;
line-height: 1;
display: inline-flex;
}
@media (max-width: 1024px) {
.maf-quick-action__floatingWrap {
right: 0.9rem;
/* Panel overrides may lift this further; keep a safer default everywhere */
bottom: calc(7.2rem + env(safe-area-inset-bottom));
/* Keep Quick Action slightly above Copilot on small screens */
bottom: calc(8.2rem + env(safe-area-inset-bottom));
}
}
......@@ -11,6 +11,8 @@ const eslintConfig = defineConfig([
".next/**",
"out/**",
"build/**",
"storybook-static/**",
"coverage/**",
"next-env.d.ts",
]),
]);
......
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