Ship leftover Yimaru login/personas, sidebar sections, filter modal, chart/stat clip fixes, and matching feature docs so the polish work is fully committed. Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
7.1 KiB
TypeScript
196 lines
7.1 KiB
TypeScript
"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<Organisation, "tradeName" | "legalName" | "joinCode">;
|
|
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 (
|
|
<div className={cn("space-y-4", className)}>
|
|
{!hideActions ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-3 print:hidden">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">{t("title")}</p>
|
|
<p className="text-xs text-muted-foreground">{t("subtitle")}</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="gap-2"
|
|
onClick={handlePrint}
|
|
disabled={member.status === "offboarded"}
|
|
>
|
|
<Printer className="size-4" />
|
|
{t("print")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Screen preview + print target */}
|
|
<div className="flex justify-center print:block">
|
|
<div
|
|
id="member-id-card-print"
|
|
className={cn(
|
|
"member-id-card relative w-full max-w-[340px] overflow-hidden rounded-xl",
|
|
"border border-[#c5d97a] bg-white text-[#141414] shadow-sm",
|
|
"aspect-[85.6/54]",
|
|
"print:max-w-none print:rounded-none print:border print:shadow-none",
|
|
)}
|
|
>
|
|
{/* Accent stripe */}
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-x-0 top-0 h-1.5 bg-[#a8c73a]"
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute -right-8 -top-10 size-28 rounded-full bg-[#a8c73a]/20"
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute -bottom-10 -left-6 size-24 rounded-full bg-[#141414]/05"
|
|
/>
|
|
|
|
<div className="relative flex h-full flex-col p-3.5 sm:p-4">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Image
|
|
src={brand.logo}
|
|
alt={brand.shortName}
|
|
width={28}
|
|
height={28}
|
|
className="size-7 shrink-0 rounded-md bg-white object-contain ring-1 ring-[#e2e5d8]"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-display text-[11px] font-semibold leading-tight tracking-tight">
|
|
{brand.shortName}
|
|
</p>
|
|
<p className="truncate text-[9px] leading-tight text-[#5c5f56]">
|
|
{t("pharmacyId")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={member.status} />
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="mt-2.5 flex min-h-0 flex-1 gap-3">
|
|
<div className="relative size-[72px] shrink-0 overflow-hidden rounded-lg bg-[#eef1e3] ring-1 ring-[#e2e5d8] sm:size-[80px]">
|
|
{/* External DiceBear portrait — not next/image remotePatterns */}
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={photoUrl}
|
|
alt={member.name}
|
|
className="size-full object-cover"
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<p className="truncate font-display text-sm font-semibold leading-tight tracking-tight sm:text-base">
|
|
{member.name}
|
|
</p>
|
|
<p className="truncate text-[10px] text-[#5c5f56]">
|
|
{organisation.tradeName}
|
|
</p>
|
|
<dl className="mt-1.5 grid gap-0.5 text-[9px] leading-snug sm:text-[10px]">
|
|
<div className="flex gap-1.5">
|
|
<dt className="shrink-0 text-[#5c5f56]">{t("employeeId")}</dt>
|
|
<dd className="truncate font-mono font-medium">
|
|
{member.employeeId}
|
|
</dd>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
<dt className="shrink-0 text-[#5c5f56]">{t("cardId")}</dt>
|
|
<dd className="truncate font-mono font-medium">{cardId}</dd>
|
|
</div>
|
|
{packageName ? (
|
|
<div className="flex gap-1.5">
|
|
<dt className="shrink-0 text-[#5c5f56]">{t("package")}</dt>
|
|
<dd className="truncate font-medium">{packageName}</dd>
|
|
</div>
|
|
) : null}
|
|
</dl>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 flex-col items-center justify-center gap-0.5">
|
|
<div className="rounded-md bg-white p-0.5 ring-1 ring-[#e2e5d8]">
|
|
<QRCodeSVG
|
|
value={scanPayload}
|
|
size={64}
|
|
level="M"
|
|
bgColor="#ffffff"
|
|
fgColor="#141414"
|
|
marginSize={0}
|
|
/>
|
|
</div>
|
|
<span className="max-w-[68px] truncate text-center font-mono text-[7px] text-[#5c5f56]">
|
|
{verificationId}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="mt-2 flex items-end justify-between gap-2 border-t border-[#e2e5d8] pt-1.5">
|
|
<p className="text-[8px] leading-tight text-[#5c5f56] sm:text-[9px]">
|
|
{t("scanHint")}
|
|
{organisation.joinCode
|
|
? ` · ${t("orgCode")}: ${organisation.joinCode}`
|
|
: null}
|
|
</p>
|
|
<p className="shrink-0 font-mono text-[8px] text-[#5c5f56]">
|
|
{cardId}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-center text-xs text-muted-foreground print:hidden">
|
|
{t("printHint")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|