2026-09-18 13:42:13 -03:00
|
|
|
import { createElement } from "react";
|
2026-09-18 12:55:54 -03:00
|
|
|
import type { LucideIcon } from "lucide-react";
|
|
|
|
|
import {
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
Bell,
|
2026-09-18 13:42:13 -03:00
|
|
|
CircleDollarSign,
|
2026-09-18 12:55:54 -03:00
|
|
|
Clock,
|
|
|
|
|
Inbox,
|
|
|
|
|
MessageSquare,
|
|
|
|
|
ShieldAlert,
|
2026-09-18 13:42:13 -03:00
|
|
|
UserPlus,
|
2026-09-18 12:55:54 -03:00
|
|
|
Wrench,
|
|
|
|
|
X,
|
|
|
|
|
} from "lucide-react";
|
2026-09-18 13:34:12 -03:00
|
|
|
import { Link } from "react-router";
|
2026-09-18 12:55:54 -03:00
|
|
|
import { Text } from "@/components/ui/text";
|
|
|
|
|
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
2026-09-18 13:34:12 -03:00
|
|
|
import { workOrderViewUrl } from "@/domain/notifications/utils/notification-target-url";
|
2026-09-18 12:55:54 -03:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-18 13:42:13 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:55:54 -03:00
|
|
|
type NotificationRowProps = {
|
|
|
|
|
item: NotificationItem;
|
|
|
|
|
unread: boolean;
|
2026-09-18 13:34:12 -03:00
|
|
|
onOpen: (item: NotificationItem, url?: string) => void;
|
2026-09-18 12:55:54 -03:00
|
|
|
onDismiss: (item: NotificationItem) => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-18 13:34:12 -03:00
|
|
|
/** 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:55:54 -03:00
|
|
|
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}
|
|
|
|
|
>
|
2026-09-18 13:34:12 -03:00
|
|
|
<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"
|
2026-09-18 12:55:54 -03:00
|
|
|
>
|
2026-09-18 13:34:12 -03:00
|
|
|
<span
|
|
|
|
|
aria-hidden
|
|
|
|
|
className="shrink-0 rounded-full"
|
|
|
|
|
style={{
|
|
|
|
|
width: 6,
|
|
|
|
|
height: 6,
|
|
|
|
|
marginTop: 7,
|
|
|
|
|
background: unread ? "var(--color-primary)" : "transparent",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-09-18 13:43:48 -03:00
|
|
|
<ItemIcon item={item} />
|
2026-09-18 13:34:12 -03:00
|
|
|
<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>
|
2026-09-18 12:55:54 -03:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|