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; ...@@ -20,8 +20,7 @@ type Story = StoryObj;
type RadioStoryArgs = { variant?: "default" | "rating" }; type RadioStoryArgs = { variant?: "default" | "rating" };
type DatePickerStoryArgs = { withTime?: boolean }; type DatePickerStoryArgs = { withTime?: boolean };
export const TextInput: Story = { function TextInputStory() {
render: () => {
const [value, setValue] = React.useState(""); const [value, setValue] = React.useState("");
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
...@@ -29,20 +28,13 @@ export const TextInput: Story = { ...@@ -29,20 +28,13 @@ export const TextInput: Story = {
<label className="form-label" htmlFor="field-text"> <label className="form-label" htmlFor="field-text">
Text input * Text input *
</label> </label>
<TextInputField <TextInputField id="field-text" value={value} onChange={setValue} placeholder="Type here…" />
id="field-text"
value={value}
onChange={setValue}
placeholder="Type here…"
/>
</div> </div>
</div> </div>
); );
}, }
};
export const Textarea: Story = { function TextareaStory() {
render: () => {
const [value, setValue] = React.useState("Initial note"); const [value, setValue] = React.useState("Initial note");
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
...@@ -50,32 +42,21 @@ export const Textarea: Story = { ...@@ -50,32 +42,21 @@ export const Textarea: Story = {
<label className="form-label" htmlFor="field-textarea"> <label className="form-label" htmlFor="field-textarea">
Textarea Textarea
</label> </label>
<TextareaField <TextareaField id="field-textarea" value={value} onChange={setValue} rows={5} />
id="field-textarea"
value={value}
onChange={setValue}
rows={5}
/>
</div> </div>
</div> </div>
); );
}, }
};
export const RadioGroup: Story = { function RadioGroupStory({ variant }: RadioStoryArgs) {
render: (args: RadioStoryArgs) => { const isRating = variant === "rating";
const isRating = args.variant === "rating"; const [value, setValue] = React.useState<string | undefined>(isRating ? "Yes" : undefined);
const [value, setValue] = React.useState<string | undefined>(
isRating ? "Yes" : undefined,
);
const options = isRating ? ["Yes", "No", "NA"] : ["Pass", "Fail"]; const options = isRating ? ["Yes", "No", "NA"] : ["Pass", "Fail"];
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
<div className="form-group"> <div className="form-group">
<div className="form-label"> <div className="form-label">{isRating ? "Rating (Yes / No / NA)" : "Radio group"}</div>
{isRating ? "Rating (Yes / No / NA)" : "Radio group"}
</div>
{isRating ? ( {isRating ? (
<div <div
style={{ style={{
...@@ -115,31 +96,20 @@ export const RadioGroup: Story = { ...@@ -115,31 +96,20 @@ export const RadioGroup: Story = {
</div> </div>
) : null} ) : null}
<RadioGroupField <RadioGroupField
key={String(args.variant)} key={String(variant)}
id={isRating ? "field-rating" : "field-radio"} id={isRating ? "field-rating" : "field-radio"}
name={isRating ? "field-rating" : "field-radio"} name={isRating ? "field-rating" : "field-radio"}
value={value} value={value}
options={options} options={options}
variant={args.variant} variant={variant}
onChange={setValue} onChange={setValue}
/> />
</div> </div>
</div> </div>
); );
}, }
args: {
variant: "default",
},
argTypes: {
variant: {
control: "select",
options: ["default", "rating"],
},
},
};
export const Select: Story = { function SelectStory() {
render: () => {
const [value, setValue] = React.useState<string | undefined>("No"); const [value, setValue] = React.useState<string | undefined>("No");
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
...@@ -157,11 +127,9 @@ export const Select: Story = { ...@@ -157,11 +127,9 @@ export const Select: Story = {
</div> </div>
</div> </div>
); );
}, }
};
export const MultiSelect: Story = { function MultiSelectStory() {
render: () => {
const [value, setValue] = React.useState<string[]>(["No"]); const [value, setValue] = React.useState<string[]>(["No"]);
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
...@@ -179,62 +147,41 @@ export const MultiSelect: Story = { ...@@ -179,62 +147,41 @@ export const MultiSelect: Story = {
</div> </div>
</div> </div>
); );
}, }
};
export const DatePicker: Story = { function DatePickerStory({ withTime }: DatePickerStoryArgs) {
render: (args: DatePickerStoryArgs) => { const [value, setValue] = React.useState(withTime ? "2026-03-23T14:30" : "2026-03-23");
const [value, setValue] = React.useState(
args.withTime ? "2026-03-23T14:30" : "2026-03-23",
);
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
<div className="form-group"> <div className="form-group">
<label className="form-label" htmlFor="field-datepicker"> <label className="form-label" htmlFor="field-datepicker">
{args.withTime ? "Date Time Picker" : "Date Picker"} {withTime ? "Date Time Picker" : "Date Picker"}
</label> </label>
<DatePickerField <DatePickerField
id="field-datepicker" id="field-datepicker"
value={value} value={value}
min="2020-01-01" min="2020-01-01"
max="2035-12-31" max="2035-12-31"
withTime={Boolean(args.withTime)} withTime={Boolean(withTime)}
onChange={setValue} onChange={setValue}
/> />
</div> </div>
</div> </div>
); );
}, }
args: {
withTime: false,
},
argTypes: {
withTime: {
control: "boolean",
},
},
};
export const Checkbox: Story = { function CheckboxStory() {
render: () => {
const [checked, setChecked] = React.useState(false); const [checked, setChecked] = React.useState(false);
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
<div className="form-group"> <div className="form-group">
<CheckboxField <CheckboxField id="field-checkbox" checked={checked} label="Confirm checkbox" onChange={setChecked} />
id="field-checkbox"
checked={checked}
label="Confirm checkbox"
onChange={setChecked}
/>
</div> </div>
</div> </div>
); );
}, }
};
export const ToggleButton: Story = { function ToggleButtonStory() {
render: () => {
const [checked, setChecked] = React.useState(false); const [checked, setChecked] = React.useState(false);
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
...@@ -252,26 +199,18 @@ export const ToggleButton: Story = { ...@@ -252,26 +199,18 @@ export const ToggleButton: Story = {
</div> </div>
</div> </div>
); );
}, }
};
export const OptionalComment: Story = { function OptionalCommentStory() {
render: () => {
const [value, setValue] = React.useState(""); const [value, setValue] = React.useState("");
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
<OptionalCommentField <OptionalCommentField id="field-comment" value={value} onChange={setValue} />
id="field-comment"
value={value}
onChange={setValue}
/>
</div> </div>
); );
}, }
};
export const LinkedFile: Story = { function LinkedFileStory() {
render: () => {
const [files, setFiles] = React.useState<File[]>([]); const [files, setFiles] = React.useState<File[]>([]);
return ( return (
<div style={{ maxWidth: 520 }}> <div style={{ maxWidth: 520 }}>
...@@ -289,5 +228,81 @@ export const LinkedFile: Story = { ...@@ -289,5 +228,81 @@ export const LinkedFile: Story = {
</div> </div>
</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"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import "./bottom-tab.css"; import "./bottom-tab.css";
export type BottomTabItem = { export type BottomTabItem = {
...@@ -59,13 +61,21 @@ function Icon({ label }: { label: string }) { ...@@ -59,13 +61,21 @@ function Icon({ label }: { label: string }) {
export function BottomTab({ export function BottomTab({
items = [ items = [
{ label: "Home", href: "#" }, { label: "Home", href: "/panel" },
{ label: "Modules", href: "#" }, { label: "Modules", href: "/panel/modules" },
{ label: "Incidents", href: "#" }, { label: "Incidents", href: "/panel/incidents" },
{ label: "Alerts", href: "#" }, { label: "Alerts", href: "/panel/alerts" },
{ label: "Profile", href: "#", active: true }, { label: "Profile", href: "/panel/profile" },
], ],
}: BottomTabProps) { }: 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 homeItem = items.find((item) => item.label.toLowerCase() === "home");
const sideItems = items.filter((item) => item.label.toLowerCase() !== "home"); const sideItems = items.filter((item) => item.label.toLowerCase() !== "home");
const leftItems = sideItems.slice(0, 2); const leftItems = sideItems.slice(0, 2);
...@@ -76,12 +86,18 @@ export function BottomTab({ ...@@ -76,12 +86,18 @@ export function BottomTab({
<div className="maf-bottom-tab__inner"> <div className="maf-bottom-tab__inner">
<div className="maf-bottom-tab__side maf-bottom-tab__side--left"> <div className="maf-bottom-tab__side maf-bottom-tab__side--left">
{leftItems.map((item) => ( {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> <span className="maf-bottom-tab__icon" aria-hidden>
<Icon label={item.label} /> <Icon label={item.label} />
</span> </span>
<span className="maf-bottom-tab__label">{item.label}</span> <span className="maf-bottom-tab__label">{item.label}</span>
</a> </Link>
))} ))}
</div> </div>
...@@ -89,26 +105,34 @@ export function BottomTab({ ...@@ -89,26 +105,34 @@ export function BottomTab({
<div className="maf-bottom-tab__side maf-bottom-tab__side--right"> <div className="maf-bottom-tab__side maf-bottom-tab__side--right">
{rightItems.map((item) => ( {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> <span className="maf-bottom-tab__icon" aria-hidden>
<Icon label={item.label} /> <Icon label={item.label} />
</span> </span>
<span className="maf-bottom-tab__label">{item.label}</span> <span className="maf-bottom-tab__label">{item.label}</span>
</a> </Link>
))} ))}
</div> </div>
{homeItem ? ( {homeItem ? (
<a <Link
key={homeItem.label} key={homeItem.label}
href={homeItem.href ?? "#"} href={homeItem.href ?? "/panel"}
className={`maf-bottom-tab__item maf-bottom-tab__item--home ${homeItem.active ? "is-active" : ""}`.trim()} 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> <span className="maf-bottom-tab__icon maf-bottom-tab__icon--home" aria-hidden>
<Icon label={homeItem.label} /> <Icon label={homeItem.label} />
</span> </span>
<span className="maf-bottom-tab__label">{homeItem.label}</span> <span className="maf-bottom-tab__label">{homeItem.label}</span>
</a> </Link>
) : null} ) : null}
</div> </div>
</nav> </nav>
......
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import React from "react"; import React from "react";
import "./header.css"; import "./header.css";
...@@ -86,7 +87,7 @@ export function Header({ ...@@ -86,7 +87,7 @@ export function Header({
<div className="landing-header__inner"> <div className="landing-header__inner">
<div className="landing-header__left"> <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"> <span className="landing-header__logoWrap">
<Image <Image
className="landing-header__logo" className="landing-header__logo"
...@@ -98,7 +99,7 @@ export function Header({ ...@@ -98,7 +99,7 @@ export function Header({
/> />
</span> </span>
<span className="landing-header__srOnly">{brandLabel}</span> <span className="landing-header__srOnly">{brandLabel}</span>
</a> </Link>
</div> </div>
<div className="landing-header__center"> <div className="landing-header__center">
......
"use client"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import "./sidebar.css"; import "./sidebar.css";
export type SidebarItem = { export type SidebarItem = {
...@@ -99,6 +102,27 @@ function SidebarIcon({ label }: { label: string }) { ...@@ -99,6 +102,27 @@ function SidebarIcon({ label }: { label: string }) {
</svg> </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") { if (icon === "settings") {
return ( return (
<svg className="maf-sidebar__iconSvg" viewBox="0 0 24 24" aria-hidden> <svg className="maf-sidebar__iconSvg" viewBox="0 0 24 24" aria-hidden>
...@@ -125,7 +149,7 @@ function SidebarIcon({ label }: { label: string }) { ...@@ -125,7 +149,7 @@ function SidebarIcon({ label }: { label: string }) {
export function Sidebar({ export function Sidebar({
items = [ items = [
{ label: "Home", href: "/panel", active: true }, { label: "Home", href: "/panel" },
{ label: "Modules", href: "/panel/modules" }, { label: "Modules", href: "/panel/modules" },
{ label: "Admin", href: "/panel/admin" }, { label: "Admin", href: "/panel/admin" },
{ label: "Incidents", href: "/panel/incidents" }, { label: "Incidents", href: "/panel/incidents" },
...@@ -135,16 +159,26 @@ export function Sidebar({ ...@@ -135,16 +159,26 @@ export function Sidebar({
{ label: "Checklist", href: "/panel/checklist" }, { label: "Checklist", href: "/panel/checklist" },
{ label: "Suggestion", href: "/panel/suggestion" }, { label: "Suggestion", href: "/panel/suggestion" },
{ label: "Tracking", href: "/panel/tracking" }, { label: "Tracking", href: "/panel/tracking" },
{ label: "AI Copilot", href: "/panel/ai" },
], ],
onLogout, onLogout,
logoutHref = "/", logoutHref = "/",
logoutLabel = "Logout", logoutLabel = "Logout",
}: SidebarProps) { }: 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 ( return (
<aside className="maf-sidebar" aria-label="Sidebar navigation"> <aside className="maf-sidebar" aria-label="Sidebar navigation">
<div className="maf-sidebar__list"> <div className="maf-sidebar__list">
{items.map((item) => ( {resolvedItems.map((item) => (
<a <Link
key={item.label} key={item.label}
href={item.href ?? "#"} href={item.href ?? "#"}
className={`maf-sidebar__item ${item.active ? "is-active" : ""}`.trim()} className={`maf-sidebar__item ${item.active ? "is-active" : ""}`.trim()}
...@@ -153,7 +187,7 @@ export function Sidebar({ ...@@ -153,7 +187,7 @@ export function Sidebar({
<SidebarIcon label={item.label} /> <SidebarIcon label={item.label} />
</span> </span>
<span>{item.label}</span> <span>{item.label}</span>
</a> </Link>
))} ))}
</div> </div>
...@@ -172,12 +206,12 @@ export function Sidebar({ ...@@ -172,12 +206,12 @@ export function Sidebar({
<span>{logoutLabel}</span> <span>{logoutLabel}</span>
</button> </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> <span className="maf-sidebar__icon" aria-hidden>
<SidebarIcon label="logout" /> <SidebarIcon label="logout" />
</span> </span>
<span>{logoutLabel}</span> <span>{logoutLabel}</span>
</a> </Link>
)} )}
</div> </div>
</aside> </aside>
......
...@@ -10,6 +10,11 @@ import type { ListingFilterOption, ListingRecord, ListingViewMode } from "./list ...@@ -10,6 +10,11 @@ import type { ListingFilterOption, ListingRecord, ListingViewMode } from "./list
type ListingPageProps = { type ListingPageProps = {
data: ListingRecord[]; data: ListingRecord[];
title?: string;
subtitle?: string;
emptyText?: string;
tableOnly?: boolean;
menu?: React.ReactNode;
}; };
const countryOptions: ListingFilterOption[] = [ const countryOptions: ListingFilterOption[] = [
...@@ -25,12 +30,20 @@ const statusOptions: ListingFilterOption[] = [ ...@@ -25,12 +30,20 @@ const statusOptions: ListingFilterOption[] = [
{ label: "Completed", value: "Completed" }, { 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 [query, setQuery] = useState("");
const [countries, setCountries] = useState<string[]>(["UAE", "KSA"]); const [countries, setCountries] = useState<string[]>(["UAE", "KSA"]);
const [statuses, setStatuses] = useState<string[]>([]); const [statuses, setStatuses] = useState<string[]>([]);
const [viewMode, setViewMode] = useState<ListingViewMode>(() => { 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 "cards";
} }
return "table"; return "table";
...@@ -38,9 +51,14 @@ export function ListingPage({ data }: ListingPageProps) { ...@@ -38,9 +51,14 @@ export function ListingPage({ data }: ListingPageProps) {
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; 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(() => { const filtered = useMemo(() => {
...@@ -68,7 +86,7 @@ export function ListingPage({ data }: ListingPageProps) { ...@@ -68,7 +86,7 @@ export function ListingPage({ data }: ListingPageProps) {
}; };
return ( return (
<ListingShell title="Audits" subtitle="Manage and review all audit submissions"> <ListingShell title={title} subtitle={subtitle} menu={menu}>
<ListingToolbar <ListingToolbar
query={query} query={query}
onQueryChange={setQuery} onQueryChange={setQuery}
...@@ -80,6 +98,7 @@ export function ListingPage({ data }: ListingPageProps) { ...@@ -80,6 +98,7 @@ export function ListingPage({ data }: ListingPageProps) {
onStatusToggle={(value) => toggleValue(statuses, value, setStatuses)} onStatusToggle={(value) => toggleValue(statuses, value, setStatuses)}
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
showViewToggle={!tableOnly}
/> />
<ListingActiveChips <ListingActiveChips
...@@ -94,7 +113,11 @@ export function ListingPage({ data }: ListingPageProps) { ...@@ -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> </ListingShell>
); );
} }
......
...@@ -4,11 +4,12 @@ import "./listing-shell.css"; ...@@ -4,11 +4,12 @@ import "./listing-shell.css";
export type ListingShellProps = { export type ListingShellProps = {
title: string; title: string;
subtitle?: string; subtitle?: string;
menu?: React.ReactNode;
actions?: React.ReactNode; actions?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
}; };
export function ListingShell({ title, subtitle, actions, children }: ListingShellProps) { export function ListingShell({ title, subtitle, menu, actions, children }: ListingShellProps) {
return ( return (
<section className="maf-listing-shell"> <section className="maf-listing-shell">
<header className="maf-listing-shell__header"> <header className="maf-listing-shell__header">
...@@ -18,6 +19,7 @@ export function ListingShell({ title, subtitle, actions, children }: ListingShel ...@@ -18,6 +19,7 @@ export function ListingShell({ title, subtitle, actions, children }: ListingShel
</div> </div>
{actions ? <div className="maf-listing-shell__actions">{actions}</div> : null} {actions ? <div className="maf-listing-shell__actions">{actions}</div> : null}
</header> </header>
{menu ? <div className="maf-listing-shell__menu">{menu}</div> : null}
{children} {children}
</section> </section>
); );
......
...@@ -3,6 +3,7 @@ import "./listing-table.css"; ...@@ -3,6 +3,7 @@ import "./listing-table.css";
type ListingTableProps = { type ListingTableProps = {
rows: ListingRecord[]; rows: ListingRecord[];
emptyText?: string;
}; };
function scoreClass(score: number | null) { function scoreClass(score: number | null) {
...@@ -19,9 +20,9 @@ function scoreLabel(score: number | null) { ...@@ -19,9 +20,9 @@ function scoreLabel(score: number | null) {
return "Low score"; return "Low score";
} }
export function ListingTable({ rows }: ListingTableProps) { export function ListingTable({ rows, emptyText = "No results found." }: ListingTableProps) {
if (!rows.length) { 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 ( return (
......
...@@ -18,10 +18,7 @@ const meta = { ...@@ -18,10 +18,7 @@ const meta = {
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Interactive: Story = { function InteractiveToolbar() {
// Render-only story; satisfy Storybook/TS when `args` is required on the inferred type.
args: {} as Story["args"],
render: () => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [countries, setCountries] = useState<string[]>(["UAE"]); const [countries, setCountries] = useState<string[]>(["UAE"]);
const [statuses, setStatuses] = useState<string[]>([]); const [statuses, setStatuses] = useState<string[]>([]);
...@@ -52,6 +49,13 @@ export const Interactive: Story = { ...@@ -52,6 +49,13 @@ export const Interactive: Story = {
onViewModeChange={setView} 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 = { ...@@ -12,6 +12,7 @@ type ListingToolbarProps = {
onStatusToggle: (value: string) => void; onStatusToggle: (value: string) => void;
viewMode: ListingViewMode; viewMode: ListingViewMode;
onViewModeChange: (mode: ListingViewMode) => void; onViewModeChange: (mode: ListingViewMode) => void;
showViewToggle?: boolean;
}; };
function ViewIcon({ mode }: { mode: ListingViewMode }) { function ViewIcon({ mode }: { mode: ListingViewMode }) {
...@@ -64,6 +65,7 @@ export function ListingToolbar({ ...@@ -64,6 +65,7 @@ export function ListingToolbar({
onStatusToggle, onStatusToggle,
viewMode, viewMode,
onViewModeChange, onViewModeChange,
showViewToggle = true,
}: ListingToolbarProps) { }: ListingToolbarProps) {
return ( return (
<div className="maf-listing-toolbar"> <div className="maf-listing-toolbar">
...@@ -104,6 +106,7 @@ export function ListingToolbar({ ...@@ -104,6 +106,7 @@ export function ListingToolbar({
))} ))}
</div> </div>
{showViewToggle ? (
<div className="maf-listing-toolbar__view"> <div className="maf-listing-toolbar__view">
<button <button
type="button" type="button"
...@@ -122,6 +125,7 @@ export function ListingToolbar({ ...@@ -122,6 +125,7 @@ export function ListingToolbar({
<ViewIcon mode="cards" /> <ViewIcon mode="cards" />
</button> </button>
</div> </div>
) : null}
</div> </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() { ...@@ -31,8 +31,10 @@ export function ChecklistDashboard() {
items={[ items={[
{ label: "Dashboard", href: "/panel/checklist", active: true }, { label: "Dashboard", href: "/panel/checklist", active: true },
{ label: "Conduct checklist", href: "/panel/checklist/conduct" }, { label: "Conduct checklist", href: "/panel/checklist/conduct" },
{ label: "Checklist action plan", href: "/panel/checklist/action-plan" }, // Action plan = tracking view
{ label: "Checklist status", href: "/panel/checklist/status" }, { 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({ ...@@ -27,19 +27,26 @@ export function DonutChart({
const radius = (size - thickness) / 2; const radius = (size - thickness) / 2;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
let offset = 0; const rendered = slices.reduce(
const rendered = slices.map((slice) => { (acc, slice) => {
const pct = total === 0 ? 0 : slice.value / total; const pct = total === 0 ? 0 : slice.value / total;
const dash = circumference * pct; const dash = circumference * pct;
const dashArray = `${dash} ${Math.max(0, circumference - dash)}`; const dashArray = `${dash} ${Math.max(0, circumference - dash)}`;
const dashOffset = -offset; const dashOffset = -acc.offset;
offset += dash;
return { return {
offset: acc.offset + dash,
items: [
...acc.items,
{
...slice, ...slice,
dashArray, dashArray,
dashOffset, dashOffset,
},
],
}; };
}); },
{ offset: 0, items: [] as Array<DonutSlice & { dashArray: string; dashOffset: number }> },
).items;
return ( return (
<div className="maf-donut"> <div className="maf-donut">
......
...@@ -66,6 +66,15 @@ export const modules: ModuleItem[] = [ ...@@ -66,6 +66,15 @@ export const modules: ModuleItem[] = [
category: "Assurance", 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", id: "admin",
title: "Admin", title: "Admin",
description: "Users, roles, configuration and operational control centre.", description: "Users, roles, configuration and operational control centre.",
......
...@@ -9,6 +9,7 @@ import { BottomTab } from "@/components/layout/BottomTab/BottomTab"; ...@@ -9,6 +9,7 @@ import { BottomTab } from "@/components/layout/BottomTab/BottomTab";
import { NotificationsPanel } from "@/components/widgets/NotificationsPanel/NotificationsPanel"; import { NotificationsPanel } from "@/components/widgets/NotificationsPanel/NotificationsPanel";
import { ProfilePanelHeader } from "@/components/widgets/ProfilePanelHeader/ProfilePanelHeader"; import { ProfilePanelHeader } from "@/components/widgets/ProfilePanelHeader/ProfilePanelHeader";
import { QuickAction } from "@/components/widgets/QuickAction/QuickAction"; import { QuickAction } from "@/components/widgets/QuickAction/QuickAction";
import { AICopilotDock } from "@/components/widgets/AICopilotDock/AICopilotDock";
import { LocationSelector } from "@/components/widgets/LocationSelector/LocationSelector"; import { LocationSelector } from "@/components/widgets/LocationSelector/LocationSelector";
import { PanelModuleSearch } from "@/components/widgets/PanelModuleSearch/PanelModuleSearch"; import { PanelModuleSearch } from "@/components/widgets/PanelModuleSearch/PanelModuleSearch";
import { panelNotificationSections } from "@/app/panel/panel-notifications"; import { panelNotificationSections } from "@/app/panel/panel-notifications";
...@@ -180,6 +181,7 @@ export function PanelShellClient({ children }: { children: React.ReactNode }) { ...@@ -180,6 +181,7 @@ export function PanelShellClient({ children }: { children: React.ReactNode }) {
) : null} ) : null}
<BottomTab /> <BottomTab />
<AICopilotDock />
<QuickAction /> <QuickAction />
</div> </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({ ...@@ -79,7 +79,9 @@ export function QuickAction({
aria-expanded={open} aria-expanded={open}
aria-label={open ? "Close quick actions" : "Open quick actions"} aria-label={open ? "Close quick actions" : "Open quick actions"}
> >
<span className="maf-quick-action__fabIcon" aria-hidden>
{open ? "×" : "+"} {open ? "×" : "+"}
</span>
</button> </button>
</div> </div>
); );
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
.maf-quick-action__floatingWrap { .maf-quick-action__floatingWrap {
position: fixed; position: fixed;
right: 1.1rem; right: 1.1rem;
bottom: 1.1rem; bottom: 1.6rem;
z-index: 45; z-index: 45;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
...@@ -21,6 +21,13 @@ ...@@ -21,6 +21,13 @@
gap: 0.65rem; 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 { .maf-quick-action--floatingPanel {
width: min(280px, calc(100vw - 2rem)); width: min(280px, calc(100vw - 2rem));
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.5); box-shadow: 0 16px 44px rgba(0, 0, 0, 0.5);
...@@ -64,8 +71,7 @@ ...@@ -64,8 +71,7 @@
height: 54px; height: 54px;
border-radius: 999px; border-radius: 999px;
border: 0; border: 0;
font-size: 1.85rem; font-size: 1.1rem;
line-height: 1;
color: rgba(110, 90, 68, 0.95); color: rgba(110, 90, 68, 0.95);
/* Frosted glass: transparent but readable */ /* Frosted glass: transparent but readable */
background: rgba(255, 255, 255, 0.52); background: rgba(255, 255, 255, 0.52);
...@@ -76,6 +82,9 @@ ...@@ -76,6 +82,9 @@
inset 0 1px 0 rgba(255, 255, 255, 0.65); inset 0 1px 0 rgba(255, 255, 255, 0.65);
border: 1px solid rgba(110, 90, 68, 0.22); border: 1px solid rgba(110, 90, 68, 0.22);
cursor: pointer; cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transition:
transform 160ms ease, transform 160ms ease,
background 160ms ease, background 160ms ease,
...@@ -106,15 +115,20 @@ ...@@ -106,15 +115,20 @@
} }
.maf-quick-action__fab.is-open { .maf-quick-action__fab.is-open {
font-size: 1.95rem;
transform: rotate(0deg); transform: rotate(0deg);
} }
.maf-quick-action__fabIcon {
font-size: 1.6rem;
line-height: 1;
display: inline-flex;
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.maf-quick-action__floatingWrap { .maf-quick-action__floatingWrap {
right: 0.9rem; right: 0.9rem;
/* Panel overrides may lift this further; keep a safer default everywhere */ /* Keep Quick Action slightly above Copilot on small screens */
bottom: calc(7.2rem + env(safe-area-inset-bottom)); bottom: calc(8.2rem + env(safe-area-inset-bottom));
} }
} }
...@@ -11,6 +11,8 @@ const eslintConfig = defineConfig([ ...@@ -11,6 +11,8 @@ const eslintConfig = defineConfig([
".next/**", ".next/**",
"out/**", "out/**",
"build/**", "build/**",
"storybook-static/**",
"coverage/**",
"next-env.d.ts", "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