shoc-frontend-new/src/components/notifications/notification-row.tsx

142 lines
4.4 KiB
TypeScript
Raw Normal View History

import { createElement } from "react";
import type { LucideIcon } from "lucide-react";
import {
AlertTriangle,
Bell,
CircleDollarSign,
Clock,
Inbox,
MessageSquare,
ShieldAlert,
UserPlus,
Wrench,
X,
} from "lucide-react";
import { Link } from "react-router";
import { Text } from "@/components/ui/text";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { workOrderViewUrl } from "@/domain/notifications/utils/notification-target-url";
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
const REASON_ICONS: Record<string, LucideIcon> = {
"severity-critical": AlertTriangle,
sla: AlertTriangle,
mentions: MessageSquare,
unassigned: Inbox,
"due-soon": Clock,
"no-vendor": Wrench,
"aveta-missing": ShieldAlert,
activity: MessageSquare,
"vendor-conflict": Wrench,
};
const ICON_PROPS = {
size: 15,
"aria-hidden": true,
className: "shrink-0",
style: { color: "var(--color-text-muted)", marginTop: 2 },
} as const;
// Recent activity mixes comments, assignments and uplift decisions; where the row leads tells them apart.
function ItemIcon({ item }: { item: NotificationItem }) {
if (item.reason === "activity" && item.target.tab === "uplifts") {
return <CircleDollarSign {...ICON_PROPS} />;
}
if (item.reason === "activity" && item.target.tab !== "comments") {
return <UserPlus {...ICON_PROPS} />;
}
return createElement(REASON_ICONS[item.reason] ?? Bell, ICON_PROPS);
}
type NotificationRowProps = {
item: NotificationItem;
unread: boolean;
onOpen: (item: NotificationItem, url?: string) => void;
onDismiss: (item: NotificationItem) => void;
};
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
return null;
}
return (
<span className="flex flex-wrap gap-2" style={{ paddingLeft: 31, marginTop: 2 }}>
{item.workOrders.map((workOrder) => {
const url = workOrderViewUrl(workOrder.id);
return (
<Link
key={workOrder.id}
to={url}
onClick={(event) => {
event.preventDefault();
onOpen(item, url);
}}
className="text-xs underline-offset-2 hover:underline"
style={{ color: "var(--color-primary)" }}
>
{workOrder.number != null ? `WO #${workOrder.number}` : `WO ${workOrder.id}`}
</Link>
);
})}
</span>
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
padding: "10px 8px",
marginInline: -8,
borderRadius: "var(--radius-md)",
borderBottom: "1px solid var(--color-bg-muted)",
}}
data-unread={unread}
>
<span className="flex min-w-0 flex-1 flex-col">
<button
type="button"
onClick={() => onOpen(item)}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
>
<span
aria-hidden
className="shrink-0 rounded-full"
style={{
width: 6,
height: 6,
marginTop: 7,
background: unread ? "var(--color-primary)" : "transparent",
}}
/>
<ItemIcon item={item} />
<Text
as="span"
variant="description"
sx={{ fontSize: 13, lineHeight: 1.45, fontWeight: unread ? 500 : 400, margin: 0 }}
>
{unread && <span className="sr-only">Unread: </span>}
{item.title}
</Text>
</button>
<WorkOrderLinks item={item} onOpen={onOpen} />
</span>
{isDismissable(item) && (
<button
type="button"
onClick={() => onDismiss(item)}
title="Dismiss"
aria-label="Dismiss notification"
className="shrink-0 cursor-pointer opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
style={{ color: "var(--color-text-muted)", marginTop: 2 }}
>
<X size={13} aria-hidden />
</button>
)}
</li>
);
}