);
diff --git a/src/app/globals.css b/src/app/globals.css
index a294b7a..7da6b74 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -112,3 +112,44 @@
font-family: var(--font-sora), var(--font-ethiopic), var(--font-sans), system-ui, sans-serif;
}
}
+
+/* Print: pharmacy member ID card only (hide shell/nav) */
+@media print {
+ @page {
+ size: 85.6mm 54mm;
+ margin: 0;
+ }
+
+ html,
+ body {
+ background: white !important;
+ background-image: none !important;
+ }
+
+ body * {
+ visibility: hidden !important;
+ }
+
+ #member-id-card-print,
+ #member-id-card-print * {
+ visibility: visible !important;
+ }
+
+ #member-id-card-print {
+ position: absolute !important;
+ left: 0 !important;
+ top: 0 !important;
+ width: 85.6mm !important;
+ height: 54mm !important;
+ max-width: none !important;
+ margin: 0 !important;
+ box-shadow: none !important;
+ border-radius: 0 !important;
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+
+ .print\:hidden {
+ display: none !important;
+ }
+}
diff --git a/src/components/charts/portal-charts.tsx b/src/components/charts/portal-charts.tsx
index 15279c2..8eeff5b 100644
--- a/src/components/charts/portal-charts.tsx
+++ b/src/components/charts/portal-charts.tsx
@@ -26,6 +26,24 @@ const LINE = "#e2e5d8";
const PIE_COLORS = [BRAND, BRAND_DEEP, ACCENT, FOREST, "#96b532", "#c97310"];
+/** Compact ETB-scale ticks so Y labels fit (e.g. 800000 → 800K). */
+const formatAxisValue = (value: number) =>
+ new Intl.NumberFormat("en", {
+ notation: "compact",
+ compactDisplay: "short",
+ maximumFractionDigits: 1,
+ }).format(value);
+
+const VALUE_Y_AXIS = {
+ tick: { fill: MUTED, fontSize: 11 },
+ axisLine: false as const,
+ tickLine: false as const,
+ width: 56,
+ tickFormatter: formatAxisValue,
+};
+
+const CHART_MARGIN = { top: 8, right: 8, left: 8, bottom: 0 };
+
type ChartCardProps = {
title: string;
description?: string;
@@ -35,14 +53,16 @@ type ChartCardProps = {
export function ChartCard({ title, description, children, className }: ChartCardProps) {
return (
-
+
{title}
{description ? (
{description}
) : null}
- {children}
+ {children}
);
}
@@ -55,7 +75,7 @@ export function SpendTrendChart({
return (
-
+
@@ -64,7 +84,7 @@ export function SpendTrendChart({
-
+
-
+
-
+
-
diff --git a/src/components/layout/app-topbar.tsx b/src/components/layout/app-topbar.tsx
index 61c3ed8..7429ff9 100644
--- a/src/components/layout/app-topbar.tsx
+++ b/src/components/layout/app-topbar.tsx
@@ -8,7 +8,7 @@ import { ROLE_META } from "@/lib/auth/session";
import { LanguageSwitcher } from "./language-switcher";
import { QuickSearch } from "./quick-search";
import { Button } from "@/components/ui/button";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { UserAvatar } from "@/components/shared/user-avatar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -52,11 +52,14 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
-
-
- {user.avatarInitials}
-
-
+
{user.name}
@@ -75,6 +78,9 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
+ router.push("/profile")}>
+ {t("nav.profile")}
+
{
switchDemoUser();
diff --git a/src/components/layout/breadcrumbs.tsx b/src/components/layout/breadcrumbs.tsx
index 6778ac3..f765bad 100644
--- a/src/components/layout/breadcrumbs.tsx
+++ b/src/components/layout/breadcrumbs.tsx
@@ -28,6 +28,7 @@ const PATH_LABEL_KEYS: Record = {
"/me/orders": "myOrders",
"/prescriptions": "prescriptions",
"/prescriptions/new": "newPrescription",
+ "/profile": "profile",
};
/** Lone segment fallbacks when full path is unmapped. */
diff --git a/src/components/layout/detail-layout.tsx b/src/components/layout/detail-layout.tsx
index 6c2aa1b..a51017f 100644
--- a/src/components/layout/detail-layout.tsx
+++ b/src/components/layout/detail-layout.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { PageShell } from "@/components/layout/page-shell";
+import { PageDetailSkeleton } from "@/components/layout/page-loading";
import { cn } from "@/lib/utils";
/** Flat MakerSys / Yimaru panel — thin border, no shadow */
@@ -35,12 +36,17 @@ export function DetailPageLayout({
);
}
-export function DetailLoading({ label }: { label?: string }) {
- return (
-
-
{label ?? "Loading…"}
-
- );
+/** Box skeleton for detail routes — keeps layout footprint while queries load. */
+export function DetailLoading({
+ label: _label,
+ metrics = 3,
+}: {
+ /** @deprecated Text loading — retained for call-site compatibility */
+ label?: string;
+ metrics?: 2 | 3 | 4;
+}) {
+ void _label;
+ return ;
}
/** Slim summary strip — left brand accent */
diff --git a/src/components/layout/page-loading.tsx b/src/components/layout/page-loading.tsx
index 006dd1e..9951a4d 100644
--- a/src/components/layout/page-loading.tsx
+++ b/src/components/layout/page-loading.tsx
@@ -121,7 +121,7 @@ export function PageDetailSkeleton({
-
+
diff --git a/src/components/members/member-id-card.tsx b/src/components/members/member-id-card.tsx
new file mode 100644
index 0000000..cfaff57
--- /dev/null
+++ b/src/components/members/member-id-card.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+import { QRCodeSVG } from "qrcode.react";
+import { Printer } from "lucide-react";
+import { brand } from "@/data/brand";
+import {
+ memberScanPayload,
+ resolveAvatarUrl,
+ resolveCardId,
+} from "@/lib/avatar";
+import type { Member, Organisation } from "@/types";
+import { Button } from "@/components/ui/button";
+import { StatusBadge } from "@/components/shared/status-badge";
+import { cn } from "@/lib/utils";
+
+export type MemberIdCardProps = {
+ member: Member;
+ organisation: Pick
;
+ packageName?: string;
+ className?: string;
+ /** Hide the print toolbar (e.g. when embedding) */
+ hideActions?: boolean;
+};
+
+export function MemberIdCard({
+ member,
+ organisation,
+ packageName,
+ className,
+ hideActions = false,
+}: MemberIdCardProps) {
+ const t = useTranslations("idCard");
+ const verificationId = member.verificationId ?? member.employeeId;
+ const cardId = resolveCardId({
+ cardId: member.cardId,
+ verificationId: member.verificationId,
+ employeeId: member.employeeId,
+ memberId: member.id,
+ });
+ const scanPayload = memberScanPayload(member.id, verificationId);
+ const photoUrl = resolveAvatarUrl(member.avatarUrl, member.id);
+
+ function handlePrint() {
+ window.print();
+ }
+
+ return (
+
+ {!hideActions ? (
+
+
+
{t("title")}
+
{t("subtitle")}
+
+
+
+ ) : null}
+
+ {/* Screen preview + print target */}
+
+
+ {/* Accent stripe */}
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+ {brand.shortName}
+
+
+ {t("pharmacyId")}
+
+
+
+
+
+
+ {/* Body */}
+
+
+ {/* External DiceBear portrait — not next/image remotePatterns */}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+
+
+ {member.name}
+
+
+ {organisation.tradeName}
+
+
+
+
- {t("employeeId")}
+ -
+ {member.employeeId}
+
+
+
+
- {t("cardId")}
+ - {cardId}
+
+ {packageName ? (
+
+
- {t("package")}
+ - {packageName}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {verificationId}
+
+
+
+
+ {/* Footer */}
+
+
+ {t("scanHint")}
+ {organisation.joinCode
+ ? ` · ${t("orgCode")}: ${organisation.joinCode}`
+ : null}
+
+
+ {cardId}
+
+
+
+
+
+
+
+ {t("printHint")}
+
+
+ );
+}
diff --git a/src/components/shared/user-avatar.tsx b/src/components/shared/user-avatar.tsx
new file mode 100644
index 0000000..606111f
--- /dev/null
+++ b/src/components/shared/user-avatar.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { getInitials, resolveAvatarUrl } from "@/lib/avatar";
+import { cn } from "@/lib/utils";
+
+type AvatarSize = "default" | "sm" | "lg";
+
+export function UserAvatar({
+ name,
+ avatarUrl,
+ seed,
+ initials,
+ size = "default",
+ className,
+ fallbackClassName,
+}: {
+ name: string;
+ avatarUrl?: string;
+ /** Seed for deterministic portrait when avatarUrl is missing */
+ seed?: string;
+ initials?: string;
+ size?: AvatarSize;
+ className?: string;
+ fallbackClassName?: string;
+}) {
+ const src = resolveAvatarUrl(avatarUrl, seed ?? name);
+ const label = initials ?? getInitials(name);
+
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/components/table/table-panel.tsx b/src/components/table/table-panel.tsx
index 7c838db..b15f419 100644
--- a/src/components/table/table-panel.tsx
+++ b/src/components/table/table-panel.tsx
@@ -1,10 +1,26 @@
import type { ReactNode } from "react";
+import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/** Flat MakerSys / Yimaru table frame — white panel, thin border, no shadow. */
export const TABLE_PANEL_CLASS =
"overflow-hidden rounded-xl border border-border bg-card shadow-none";
+function TableRowsSkeleton({ rows = 6 }: { rows?: number }) {
+ return (
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
/**
* Wraps toolbar + table (or loading) in a single flush panel.
* Toolbar sits inside the card with a hairline bottom; grid scrolls horizontally on mobile.
@@ -14,24 +30,22 @@ export function TablePanel({
children,
className,
loading,
- loadingLabel = "Loading…",
+ loadingLabel: _loadingLabel,
+ skeletonRows = 6,
}: {
toolbar?: ReactNode;
children?: ReactNode;
className?: string;
loading?: boolean;
+ /** @deprecated Text loading — retained for call-site compatibility */
loadingLabel?: ReactNode;
+ skeletonRows?: number;
}) {
+ void _loadingLabel;
return (
{toolbar}
- {loading ? (
-
- ) : (
- children
- )}
+ {loading ?
: children}
);
}
diff --git a/src/components/table/table-toolbar.tsx b/src/components/table/table-toolbar.tsx
index ddd1911..d586f57 100644
--- a/src/components/table/table-toolbar.tsx
+++ b/src/components/table/table-toolbar.tsx
@@ -1,8 +1,19 @@
"use client";
-import type { ReactNode } from "react";
-import { Search } from "lucide-react";
+import { useEffect, useMemo, useState, type ReactNode } from "react";
+import { ListFilter, Search } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -17,37 +28,223 @@ export type TableFilterOption = {
label: string;
};
+/** One select filter in the toolbar (or filter modal when there are many). */
+export type TableFilterDef = {
+ id: string;
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ options: TableFilterOption[];
+ /** Shown as the “all / any” option. Defaults to translated common.all. */
+ allLabel?: string;
+ placeholder?: string;
+};
+
type TableToolbarProps = {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder?: string;
+ /**
+ * Preferred multi-filter API. When `filters.length > inlineFilterLimit` (default 3),
+ * filters open in a modal with draft state + Apply / Reset.
+ */
+ filters?: TableFilterDef[];
+ /** Legacy single-filter API — still supported (treated as one filter). */
filterValue?: string;
onFilterChange?: (value: string) => void;
filterOptions?: TableFilterOption[];
filterPlaceholder?: string;
filterAllLabel?: string;
+ /** Switch to modal when filter count exceeds this. Default 3. */
+ inlineFilterLimit?: number;
actions?: ReactNode;
className?: string;
resultCount?: number;
resultLabel?: string;
};
-/** In-panel search + optional filter (Fortune AppTable toolbar / Yimaru list header). */
+const DEFAULT_INLINE_LIMIT = 3;
+
+function FilterSelect({
+ filter,
+ value,
+ onValueChange,
+ className,
+}: {
+ filter: TableFilterDef;
+ value: string;
+ onValueChange: (value: string) => void;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+function countActiveFilters(filters: TableFilterDef[], values: Record) {
+ return filters.reduce((n, f) => {
+ const v = values[f.id] ?? f.value;
+ return n + (v && v !== "all" ? 1 : 0);
+ }, 0);
+}
+
+function valuesFromFilters(filters: TableFilterDef[]): Record {
+ return Object.fromEntries(filters.map((f) => [f.id, f.value || "all"]));
+}
+
+function allCleared(filters: TableFilterDef[]): Record {
+ return Object.fromEntries(filters.map((f) => [f.id, "all"]));
+}
+
+function FilterModal({
+ open,
+ onOpenChange,
+ filters,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ filters: TableFilterDef[];
+}) {
+ const t = useTranslations("filters");
+ const tCommon = useTranslations("common");
+ const [draft, setDraft] = useState>(() =>
+ valuesFromFilters(filters),
+ );
+
+ // Sync draft from applied values only when the modal opens (not on every parent re-render).
+ useEffect(() => {
+ if (open) setDraft(valuesFromFilters(filters));
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional open-only sync
+ }, [open]);
+
+ function setDraftValue(id: string, value: string) {
+ setDraft((prev) => ({ ...prev, [id]: value }));
+ }
+
+ function handleApply() {
+ for (const f of filters) {
+ const next = draft[f.id] ?? "all";
+ if (next !== f.value) f.onChange(next);
+ }
+ onOpenChange(false);
+ }
+
+ function handleReset() {
+ setDraft(allCleared(filters));
+ }
+
+ return (
+
+ );
+}
+
+/** In-panel search + optional filters (inline ≤3, modal when more). */
export function TableToolbar({
search,
onSearchChange,
searchPlaceholder = "Search…",
+ filters: filtersProp,
filterValue,
onFilterChange,
filterOptions,
filterPlaceholder = "Filter",
- filterAllLabel = "All",
+ filterAllLabel,
+ inlineFilterLimit = DEFAULT_INLINE_LIMIT,
actions,
className,
resultCount,
resultLabel,
}: TableToolbarProps) {
- const showFilter = !!filterOptions?.length && !!onFilterChange;
+ const tCommon = useTranslations("common");
+ const tFilters = useTranslations("filters");
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const filters = useMemo((): TableFilterDef[] => {
+ if (filtersProp?.length) return filtersProp;
+ if (filterOptions?.length && onFilterChange) {
+ return [
+ {
+ id: "legacy",
+ label: filterPlaceholder,
+ value: filterValue ?? "all",
+ onChange: onFilterChange,
+ options: filterOptions,
+ allLabel: filterAllLabel ?? tCommon("all"),
+ placeholder: filterPlaceholder,
+ },
+ ];
+ }
+ return [];
+ }, [
+ filtersProp,
+ filterOptions,
+ onFilterChange,
+ filterValue,
+ filterPlaceholder,
+ filterAllLabel,
+ tCommon,
+ ]);
+
+ const useModal = filters.length > inlineFilterLimit;
+ const appliedValues = useMemo(() => valuesFromFilters(filters), [filters]);
+ const activeCount = countActiveFilters(filters, appliedValues);
return (
- {showFilter ? (
-
- ) : null}
+
+ {useModal ? (
+ <>
+
+
+ >
+ ) : (
+ filters.map((filter) => (
+
+ ))
+ )}
+
{typeof resultCount === "number" ? (
{resultCount}
diff --git a/src/lib/api/mock-client.ts b/src/lib/api/mock-client.ts
index fd983f4..999fa5a 100644
--- a/src/lib/api/mock-client.ts
+++ b/src/lib/api/mock-client.ts
@@ -4,8 +4,10 @@ import type {
MigrationJobStatus,
Role,
} from "@/types";
+import type { LocaleCode, SessionUser } from "@/types";
import {
approvals,
+ demoUsers,
departments,
loyalty,
memberOrders,
@@ -18,11 +20,42 @@ import {
spendRows,
statements,
} from "@/mocks/data";
-import { canAccessClinical } from "@/lib/auth/session";
+import { canAccessClinical, listDemoPersonas } from "@/lib/auth/session";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
export const mockApi = {
+ /** GET /v1/me/profile — current session profile stub */
+ async getProfile(userId: string) {
+ await delay();
+ const user = demoUsers.find((u) => u.id === userId);
+ if (!user) return null;
+ return {
+ ...user,
+ orgName: organisation.tradeName,
+ orgLegalName: organisation.legalName,
+ };
+ },
+
+ /** PATCH /v1/me/profile — demo locale / display updates */
+ async updateProfile(
+ userId: string,
+ patch: Partial>,
+ ) {
+ await delay(180);
+ const user = demoUsers.find((u) => u.id === userId);
+ if (!user) throw new Error("NOT_FOUND");
+ if (patch.locale) user.locale = patch.locale as LocaleCode;
+ if (patch.name) user.name = patch.name;
+ return { ...user };
+ },
+
+ /** GET /v1/auth/demo-profiles */
+ async getDemoProfiles() {
+ await delay(80);
+ return listDemoPersonas();
+ },
+
async getOrganisation() {
await delay();
return { ...organisation };
diff --git a/src/lib/auth/nav.ts b/src/lib/auth/nav.ts
index 9d4a820..4e1e564 100644
--- a/src/lib/auth/nav.ts
+++ b/src/lib/auth/nav.ts
@@ -115,6 +115,13 @@ export const NAV_ITEMS: NavItem[] = [
roles: ["SUPER_USER", "FINANCE"],
section: "finance",
},
+ {
+ href: "/profile",
+ labelKey: "nav.profile",
+ icon: UserRound,
+ roles: ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
+ section: "account",
+ },
{
href: "/prescriptions",
labelKey: "nav.prescriptions",
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
index f3a08ba..7ba5869 100644
--- a/src/lib/auth/session.ts
+++ b/src/lib/auth/session.ts
@@ -1,5 +1,5 @@
import type { Role, SessionUser } from "@/types";
-import { demoUsers } from "@/mocks/data";
+import { DEMO_PERSONA_IDS, demoUsers, organisation } from "@/mocks/data";
export const SESSION_COOKIE = "gishen_b2b_session";
export const SESSION_STORAGE_KEY = "gishen_b2b_session";
@@ -30,12 +30,68 @@ export const ROLE_META: Record<
},
};
+export type DemoPersona = SessionUser & {
+ personaLabelKey: string;
+ personaDescKey: string;
+};
+
+const PERSONA_META: Record<
+ string,
+ { personaLabelKey: string; personaDescKey: string }
+> = {
+ user_super: {
+ personaLabelKey: "login.personas.superUser",
+ personaDescKey: "login.personas.superUserDesc",
+ },
+ user_hr: {
+ personaLabelKey: "login.personas.hrAdmin",
+ personaDescKey: "login.personas.hrAdminDesc",
+ },
+ user_fin: {
+ personaLabelKey: "login.personas.finance",
+ personaDescKey: "login.personas.financeDesc",
+ },
+ user_mem: {
+ personaLabelKey: "login.personas.member",
+ personaDescKey: "login.personas.memberDesc",
+ },
+};
+
+export function getDemoUserById(
+ id: string,
+ activeRole?: Role,
+): SessionUser {
+ const user = demoUsers.find((u) => u.id === id);
+ if (!user) throw new Error(`No demo user for id ${id}`);
+ const role =
+ activeRole && user.roles.includes(activeRole)
+ ? activeRole
+ : user.activeRole;
+ return { ...user, activeRole: role };
+}
+
export function getDemoUserByRole(role: Role): SessionUser {
- const user = demoUsers.find((u) => u.activeRole === role);
+ const user = demoUsers.find(
+ (u) => u.activeRole === role || u.roles.includes(role),
+ );
if (!user) throw new Error(`No demo user for role ${role}`);
return { ...user, activeRole: role };
}
+/** Personas for the login test-user dropdown. */
+export function listDemoPersonas(): DemoPersona[] {
+ return DEMO_PERSONA_IDS.map((id) => {
+ const user = demoUsers.find((u) => u.id === id);
+ if (!user) throw new Error(`Missing demo persona ${id}`);
+ const meta = PERSONA_META[id]!;
+ return { ...user, ...meta };
+ });
+}
+
+export function getDemoOrgName(): string {
+ return organisation.tradeName;
+}
+
export function canAccessClinical(role: Role): boolean {
return role === "SUPER_USER" || role === "MEMBER";
}
diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts
new file mode 100644
index 0000000..5458801
--- /dev/null
+++ b/src/lib/avatar.ts
@@ -0,0 +1,45 @@
+/** Deterministic portrait URL for demo personas / members (DiceBear). */
+export function portraitAvatarUrl(seed: string): string {
+ const safe = encodeURIComponent(seed.trim() || "gishen");
+ return `https://api.dicebear.com/9.x/lorelei/svg?seed=${safe}&backgroundColor=eef1e3,d4e8a8,c5d97a`;
+}
+
+export function getInitials(name: string, fallback = "?"): string {
+ const parts = name.split(/\s+/).filter(Boolean);
+ if (parts.length === 0) return fallback;
+ return parts
+ .slice(0, 2)
+ .map((p) => p[0]?.toUpperCase() ?? "")
+ .join("");
+}
+
+/** Resolve avatar URL: explicit value, else deterministic portrait from seed. */
+export function resolveAvatarUrl(
+ avatarUrl: string | undefined,
+ seed: string,
+): string {
+ return avatarUrl?.trim() || portraitAvatarUrl(seed);
+}
+
+/** Pharmacy ID card QR / barcode payload for pharmacist scan. */
+export function memberScanPayload(
+ memberId: string,
+ verificationId: string,
+): string {
+ return `gishen://member/${memberId}?v=${encodeURIComponent(verificationId)}`;
+}
+
+/** Display card ID — prefer cardId, else GSH- from verification / employee. */
+export function resolveCardId(opts: {
+ cardId?: string;
+ verificationId?: string;
+ employeeId?: string;
+ memberId: string;
+}): string {
+ if (opts.cardId) return opts.cardId;
+ const raw =
+ opts.verificationId?.replace(/^VID-?/i, "") ||
+ opts.employeeId?.replace(/^[A-Z]+-?/i, "") ||
+ opts.memberId.replace(/^mem_?/i, "");
+ return `GSH-${raw}`;
+}
diff --git a/src/lib/nav/quick-directory.ts b/src/lib/nav/quick-directory.ts
index a855146..8aa042d 100644
--- a/src/lib/nav/quick-directory.ts
+++ b/src/lib/nav/quick-directory.ts
@@ -11,6 +11,7 @@ import {
Plus,
Settings2,
Upload,
+ UserCircle2,
UserRound,
Users,
Wallet,
@@ -162,6 +163,15 @@ export const QUICK_DIRECTORY: QuickDirectoryEntry[] = [
keywords: ["orders", "purchases", "history"],
icon: Package,
},
+ {
+ id: "profile",
+ labelKey: "nav.profile",
+ href: "/profile",
+ group: "settings",
+ roles: ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
+ keywords: ["account", "me", "user", "settings", "avatar"],
+ icon: UserCircle2,
+ },
{
id: "verification",
labelKey: "nav.verification",
diff --git a/src/messages/am.json b/src/messages/am.json
index 5b2e8c6..e8494fc 100644
--- a/src/messages/am.json
+++ b/src/messages/am.json
@@ -39,6 +39,12 @@
"results": "ውጤቶች",
"requiredFields": "አስፈላጊ መስኮችን ይሙሉ"
},
+ "filters": {
+ "title": "ማጣሪያዎች",
+ "apply": "ተግብር",
+ "reset": "አጽዳ",
+ "active": "{count} ንቁ"
+ },
"roles": {
"superUser": "ሱፐር ተጠቃሚ",
"superUserDesc": "ሙሉ መዳረሻ — HR, Finance, Packages",
@@ -62,7 +68,16 @@
"verification": "ማረጋገጫ",
"myBenefits": "የእኔ ጥቅማጥቅሞች",
"myOrders": "የእኔ ትዕዛዎች",
- "prescriptions": "ማዘዛዎች"
+ "prescriptions": "ማዘዛዎች",
+ "profile": "መገለጫ",
+ "sections": {
+ "overview": "አጠቃላይ",
+ "organisation": "ድርጅት",
+ "people": "ሰዎች",
+ "benefits": "ጥቅማጥቅሞች",
+ "finance": "ፋይናንስ",
+ "account": "መለያ"
+ }
},
"bc": {
"aria": "Breadcrumb",
@@ -96,13 +111,15 @@
"prescriptions": "ማዘዛዎች",
"newPrescription": "አዲስ ማዘዣ",
"prescriptionDetail": "የማዘዛ ዝርዝር",
- "departmentDetail": "የክፍል ዝርዝር"
+ "departmentDetail": "የክፍል ዝርዝር",
+ "profile": "መገለጫ"
},
"tabs": {
"profile": "መገለጫ",
"entitlement": "መብት",
"dependants": "ጥገኞች",
"clinical": "ክሊኒክ",
+ "idCard": "መታወቂያ ካርድ",
"rules": "ደንቦች",
"perks": "ጥቅሞች",
"summary": "ማጠቃለያ",
@@ -114,6 +131,18 @@
"info": "መረጃ",
"basics": "መሠረታዊ"
},
+ "idCard": {
+ "title": "የፋርማሲ መታወቂያ ካርድ",
+ "subtitle": "ለፋርማሲ ማረጋገጫ የሚያገለግል ናሙና ካርድ — ያትሙ እና በግዢ ጊዜ ያሳዩ።",
+ "pharmacyId": "የሰራተኛ ፋርማሲ መታወቂያ",
+ "employeeId": "ሰራተኛ",
+ "cardId": "የካርድ መለያ",
+ "package": "እቅድ",
+ "orgCode": "ድርጅት",
+ "scanHint": "ፋርማሲስት፡ ለማረጋገጥ QR ይቃኙ",
+ "print": "መታወቂያ አትም",
+ "printHint": "ህትመት የክሬዲት ካርድ መጠን (85.6 × 54 ሚሜ) ይጠቀማል። ካርዱ ብቻ ይታተማል።"
+ },
"stats": {
"total": "ጠቅላላ",
"active": "ንቁ",
@@ -156,12 +185,42 @@
}
},
"login": {
- "title": "የተቋም ፖርታል",
- "subtitle": "Demo role switcher — pick a persona.",
+ "eyebrow": "የተቋም ፖርታል",
+ "title": "እንኳን ደህና መጡ",
+ "subtitle": "በሚና የፖርታሉን ለመሞከር የማሳያ ተጠቃሚ ይምረጡ።",
+ "brandBlurb": "ለድርጅቶች የኮርፖሬት የህክምና ጥቅማጥቅሞች — አባላት፣ ፓኬጆች፣ ፋይናንስ እና እንክብካቤ በአንድ ቦታ።",
+ "selectUser": "የሙከራ ተጠቃሚ",
+ "selectUserPlaceholder": "የማሳያ ተጠቃሚ ይምረጡ",
"continueAs": "እንደ {role} ቀጥል",
+ "getStarted": "ጀምር",
"registerOrg": "ድርጅት ይመዝገቡ",
"requestRegister": "ምዝገባ ይጠይቁ",
- "joinWithCode": "በኮድ ይቀላቀሉ"
+ "joinWithCode": "በኮድ ይቀላቀሉ",
+ "personas": {
+ "superUser": "ሱፐር ተጠቃሚ",
+ "superUserDesc": "ሙሉ መዳረሻ — HR፣ ፋይናንስ፣ ፓኬጆች እና ክሊኒክ። ብዙ ሚና መቀያየር ይቻላል።",
+ "hrAdmin": "HR አስተዳዳሪ",
+ "hrAdminDesc": "አባላት፣ ክፍሎች፣ ፓኬጆች እና ማስተላለፍ — ክሊኒክ ተደብቋል።",
+ "finance": "ፋይናንስ አጽዳቂ",
+ "financeDesc": "ወጢ፣ መግለጻዎች፣ ክሬዲት እና ማጽደቂያዎች — ክሊኒክ ተደብቋል።",
+ "member": "አባል",
+ "memberDesc": "የራስ ጥቅማጥቅሞች፣ ትእዛዎች እና ማዘዛዎች ብቻ።"
+ }
+ },
+ "profile": {
+ "title": "መገለጫ",
+ "subtitle": "የማሳያ መለያዎ፣ ንቁ ሚና እና ምርጫዎች።",
+ "accountDetails": "የመለያ ዝርዝሮች",
+ "organisation": "ድርጅት",
+ "activeRole": "ንቁ ሚና",
+ "localePreference": "የቋንቋ ምርጫ",
+ "userId": "የተጠቃሚ መለያ",
+ "switchRole": "ንቁ ሚና ቀይር",
+ "switchRoleHint": "የሚታዩ የፖርታል ክፍሎችን ይቀይራል። ለብዙ-ሚና የማሳያ ተጠቃሚዎች።",
+ "roleSwitched": "ወደ {role} ተቀይሯል",
+ "language": "ቋንቋ",
+ "languageHint": "ቋንቋ ለመቀየር ከላይ ባለው አሞሌ ላይ ያለውን",
+ "languageHintSuffix": "ይጠቀሙ።"
},
"dashboard": {
"title": "ዳሽቦርድ",
@@ -241,8 +300,9 @@
"sendInvite": "ግብዣ ላክ",
"statusInvited": "ተጋብዟል",
"statusInactive": "እንቅስቃሴ የለውም",
- "statusOffboarded": "ተወግዷል"
- },
+ "statusOffboarded": "ተወግዷል",
+ "withDependants": "ተመራጮች ያሉት",
+ "withoutDependants": "ተመራጮች የሌሉት" },
"packages": {
"title": "ፓኬጆች",
"subtitle": "Plans, caps, exclusions, perks.",
@@ -273,8 +333,10 @@
"stepPreview": "ቅድመ እይታ",
"stepCommit": "አረጋግጥ",
"file": "ፋይል",
- "rows": "ረድፎች"
- },
+ "rows": "ረድፎች",
+ "withErrors": "ስህተት ያለው",
+ "noErrors": "ስህተት የሌለው",
+ "createdBy": "ፈጣሪ" },
"finance": {
"title": "ፋይናንስ",
"subtitle": "Spend by dept/member/category — no clinical lines.",
@@ -287,7 +349,11 @@
"approve": "Approve",
"flag": "Flag",
"reason": "ምክንያት",
- "transactions": "ግብይቶች"
+ "transactions": "ግብይቶች",
+ "coverage": "ሽፋን",
+ "fullyCovered": "የተሸፈነ",
+ "uncovered": "ያልተሸፈነ",
+ "period": "ጊዜ"
},
"statements": {
"title": "መግለጻዎች",
diff --git a/src/messages/en.json b/src/messages/en.json
index d5f3c2f..66dd930 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -39,6 +39,12 @@
"results": "results",
"requiredFields": "Please fill required fields"
},
+ "filters": {
+ "title": "Filters",
+ "apply": "Apply",
+ "reset": "Reset",
+ "active": "{count} active"
+ },
"roles": {
"superUser": "Super User",
"superUserDesc": "Full portal access — HR, finance, packages, and support views",
@@ -62,7 +68,16 @@
"verification": "Verification",
"myBenefits": "My benefits",
"myOrders": "My orders",
- "prescriptions": "Prescriptions"
+ "prescriptions": "Prescriptions",
+ "profile": "Profile",
+ "sections": {
+ "overview": "Overview",
+ "organisation": "Organisation",
+ "people": "People",
+ "benefits": "Benefits",
+ "finance": "Finance",
+ "account": "Account"
+ }
},
"bc": {
"aria": "Breadcrumb",
@@ -96,13 +111,15 @@
"prescriptions": "Prescriptions",
"newPrescription": "New prescription",
"prescriptionDetail": "Prescription detail",
- "departmentDetail": "Department detail"
+ "departmentDetail": "Department detail",
+ "profile": "Profile"
},
"tabs": {
"profile": "Profile",
"entitlement": "Entitlement",
"dependants": "Dependants",
"clinical": "Clinical",
+ "idCard": "ID card",
"rules": "Rules",
"perks": "Perks",
"summary": "Summary",
@@ -114,6 +131,18 @@
"info": "Info",
"basics": "Basics"
},
+ "idCard": {
+ "title": "Pharmacy ID card",
+ "subtitle": "Sample badge for pharmacy verification — print and present at checkout.",
+ "pharmacyId": "Employee pharmacy ID",
+ "employeeId": "Employee",
+ "cardId": "Card ID",
+ "package": "Plan",
+ "orgCode": "Org",
+ "scanHint": "Pharmacist: scan QR to verify coverage",
+ "print": "Print ID card",
+ "printHint": "Print uses credit-card size (85.6 × 54 mm). Only the card is printed."
+ },
"stats": {
"total": "Total",
"active": "Active",
@@ -156,12 +185,42 @@
}
},
"login": {
- "title": "Institutional portal",
- "subtitle": "Demo client switcher — pick a persona to explore each experience.",
+ "eyebrow": "Institutional portal",
+ "title": "Welcome back",
+ "subtitle": "Choose a demo persona to explore the portal by role.",
+ "brandBlurb": "Corporate medical benefits for organisations — members, packages, finance, and care in one place.",
+ "selectUser": "Test user",
+ "selectUserPlaceholder": "Select a demo user",
"continueAs": "Continue as {role}",
+ "getStarted": "Get started",
"registerOrg": "Register organisation",
"requestRegister": "Request registration",
- "joinWithCode": "Join with code"
+ "joinWithCode": "Join with code",
+ "personas": {
+ "superUser": "Super User",
+ "superUserDesc": "Full access across HR, finance, packages, and clinical support views. Multi-role switcher enabled.",
+ "hrAdmin": "HR Administrator",
+ "hrAdminDesc": "Members, departments, packages, and migration — clinical detail withheld.",
+ "finance": "Finance Approver",
+ "financeDesc": "Spend, statements, credit, and approvals — clinical detail withheld.",
+ "member": "Member",
+ "memberDesc": "Own benefits, orders, and prescriptions only."
+ }
+ },
+ "profile": {
+ "title": "Profile",
+ "subtitle": "Your demo account, active role, and preferences.",
+ "accountDetails": "Account details",
+ "organisation": "Organisation",
+ "activeRole": "Active role",
+ "localePreference": "Locale preference",
+ "userId": "User ID",
+ "switchRole": "Switch active role",
+ "switchRoleHint": "Changes which portal areas you see. Available on multi-role demo users.",
+ "roleSwitched": "Switched to {role}",
+ "language": "Language",
+ "languageHint": "Use the language control in the top bar to change",
+ "languageHintSuffix": "for this session."
},
"dashboard": {
"title": "Dashboard",
@@ -241,7 +300,9 @@
"importSubtitle": "Quick Excel path — use Migration for full multi-entity onboarding.",
"statusInvited": "Invited",
"statusInactive": "Inactive",
- "statusOffboarded": "Offboarded"
+ "statusOffboarded": "Offboarded",
+ "withDependants": "With dependants",
+ "withoutDependants": "No dependants"
},
"packages": {
"title": "Corporate packages",
@@ -273,7 +334,10 @@
"stepMap": "Map columns",
"stepValidate": "Validate",
"stepPreview": "Preview",
- "stepCommit": "Commit"
+ "stepCommit": "Commit",
+ "withErrors": "With errors",
+ "noErrors": "No errors",
+ "createdBy": "Created by"
},
"finance": {
"title": "Finance",
@@ -287,7 +351,11 @@
"approve": "Approve",
"flag": "Flag",
"reason": "Reason",
- "transactions": "Transactions"
+ "transactions": "Transactions",
+ "coverage": "Coverage",
+ "fullyCovered": "Covered",
+ "uncovered": "Uncovered",
+ "period": "Period"
},
"statements": {
"title": "Statements",
diff --git a/src/mocks/data.ts b/src/mocks/data.ts
index 3651a0f..2620471 100644
--- a/src/mocks/data.ts
+++ b/src/mocks/data.ts
@@ -12,6 +12,7 @@ import type {
Statement,
LoyaltySummary,
} from "@/types";
+import { portraitAvatarUrl } from "@/lib/avatar";
export const DEMO_ORG_ID = "org_yaltopia";
@@ -37,16 +38,19 @@ export const organisation: Organisation = {
createdAt: "2025-11-12T10:00:00Z",
};
+/** Demo personas for login switcher — distinct users, one per primary role (+ multi-role). */
export const demoUsers: SessionUser[] = [
{
id: "user_super",
email: "super@yaltopia.com",
name: "Selam Super",
- roles: ["SUPER_USER"],
+ roles: ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
activeRole: "SUPER_USER",
orgId: DEMO_ORG_ID,
+ memberId: "mem_001",
locale: "en",
avatarInitials: "SS",
+ avatarUrl: portraitAvatarUrl("user_super"),
},
{
id: "user_hr",
@@ -57,6 +61,7 @@ export const demoUsers: SessionUser[] = [
orgId: DEMO_ORG_ID,
locale: "en",
avatarInitials: "HH",
+ avatarUrl: portraitAvatarUrl("user_hr"),
},
{
id: "user_fin",
@@ -65,8 +70,9 @@ export const demoUsers: SessionUser[] = [
roles: ["FINANCE"],
activeRole: "FINANCE",
orgId: DEMO_ORG_ID,
- locale: "en",
+ locale: "am",
avatarInitials: "FF",
+ avatarUrl: portraitAvatarUrl("user_fin"),
},
{
id: "user_mem",
@@ -76,11 +82,23 @@ export const demoUsers: SessionUser[] = [
activeRole: "MEMBER",
orgId: DEMO_ORG_ID,
memberId: "mem_001",
- locale: "en",
+ locale: "am",
avatarInitials: "AM",
+ avatarUrl: portraitAvatarUrl("mem_001"),
},
];
+/** Stable order for the login test-user dropdown. */
+export const DEMO_PERSONA_IDS = [
+ "user_super",
+ "user_hr",
+ "user_fin",
+ "user_mem",
+] as const;
+
+/** @deprecated Use DEMO_PERSONA_IDS */
+export const DEMO_LOGIN_USER_IDS = DEMO_PERSONA_IDS;
+
export const departments: Department[] = [
{
id: "dept_hq",
@@ -185,6 +203,8 @@ export const members: Member[] = [
allowanceRemainingEtb: 2150,
allowanceTotalEtb: 3500,
verificationId: "VID-1001",
+ cardId: "GSH-1001",
+ avatarUrl: portraitAvatarUrl("mem_001"),
hasActiveRx: true,
lastRxStatus: "under_review",
dependants: [
@@ -221,6 +241,8 @@ export const members: Member[] = [
allowanceRemainingEtb: 800,
allowanceTotalEtb: 2000,
verificationId: "VID-1044",
+ cardId: "GSH-1044",
+ avatarUrl: portraitAvatarUrl("mem_002"),
hasActiveRx: false,
dependants: [],
override: {
@@ -243,6 +265,9 @@ export const members: Member[] = [
startDate: "2026-04-01",
allowanceRemainingEtb: 1000,
allowanceTotalEtb: 1000,
+ verificationId: "VID-1102",
+ cardId: "GSH-1102",
+ avatarUrl: portraitAvatarUrl("mem_003"),
dependants: [],
},
{
@@ -260,6 +285,8 @@ export const members: Member[] = [
allowanceRemainingEtb: 0,
allowanceTotalEtb: 3500,
verificationId: "VID-1088",
+ cardId: "GSH-1088",
+ avatarUrl: portraitAvatarUrl("mem_004"),
dependants: [],
},
];
diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx
index 507ba6f..783d223 100644
--- a/src/providers/auth-provider.tsx
+++ b/src/providers/auth-provider.tsx
@@ -12,13 +12,17 @@ import {
import type { Role, SessionUser } from "@/types";
import {
SESSION_STORAGE_KEY,
+ getDemoUserById,
getDemoUserByRole,
} from "@/lib/auth/session";
type AuthContextValue = {
user: SessionUser | null;
ready: boolean;
+ /** @deprecated Prefer loginAsUser — kept for role-only callers */
loginAs: (role: Role) => SessionUser;
+ loginAsUser: (userId: string, activeRole?: Role) => SessionUser;
+ setActiveRole: (role: Role) => SessionUser | null;
logout: () => void;
switchDemoUser: () => void;
};
@@ -59,12 +63,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[persist],
);
+ const loginAsUser = useCallback(
+ (userId: string, activeRole?: Role) => {
+ const next = getDemoUserById(userId, activeRole);
+ persist(next);
+ return next;
+ },
+ [persist],
+ );
+
+ const setActiveRole = useCallback(
+ (role: Role) => {
+ if (!user || !user.roles.includes(role)) return null;
+ const next = { ...user, activeRole: role };
+ persist(next);
+ return next;
+ },
+ [persist, user],
+ );
+
const logout = useCallback(() => persist(null), [persist]);
const switchDemoUser = useCallback(() => persist(null), [persist]);
const value = useMemo(
- () => ({ user, ready, loginAs, logout, switchDemoUser }),
- [user, ready, loginAs, logout, switchDemoUser],
+ () => ({
+ user,
+ ready,
+ loginAs,
+ loginAsUser,
+ setActiveRole,
+ logout,
+ switchDemoUser,
+ }),
+ [user, ready, loginAs, loginAsUser, setActiveRole, logout, switchDemoUser],
);
return {children};
diff --git a/src/types/index.ts b/src/types/index.ts
index 37ab34c..ff64056 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -49,6 +49,8 @@ export interface SessionUser {
memberId?: string;
locale: LocaleCode;
avatarInitials: string;
+ /** Optional profile photo; demos use deterministic DiceBear URLs */
+ avatarUrl?: string;
}
export interface Organisation {
@@ -145,6 +147,10 @@ export interface Member {
override?: MemberOverride;
dependants: Dependant[];
verificationId?: string;
+ /** Pharmacy ID card number shown on printable badge (e.g. GSH-1001) */
+ cardId?: string;
+ /** Optional profile photo; demos use deterministic DiceBear URLs */
+ avatarUrl?: string;
/** Clinical fields — withheld from HR_ADMIN / FINANCE */
hasActiveRx?: boolean;
lastRxStatus?: PrescriptionStatus;