chore: add notification to packages

This commit is contained in:
Nathnael
2026-07-06 06:52:47 +00:00
parent c8f65829d1
commit 01b92072aa
6 changed files with 445 additions and 35 deletions

View File

@@ -7,6 +7,7 @@ export * from "./overview";
export * from "./etrade";
export * from "./contracts";
export * from "./clearance-files.catalog";
export * from "./notifications";
export enum TradeDirection {
IMPORT = "IMPORT",

View File

@@ -0,0 +1,101 @@
/**
* Shared contracts for the freight in-app notification system.
*
* The notification *mechanism* (module, gateway, bell) ships first; individual
* domain triggers are wired later. The `NotificationType` values below seed the
* intended trigger set so the frontend can map icons/labels before any producer
* actually emits them.
*/
/** Which app surface a notification is addressed to. */
export enum NotificationAudience {
PORTAL = "PORTAL",
BACKOFFICE = "BACKOFFICE",
}
/** Drives channel fan-out: HIGH also pushes email/SMS, NORMAL is in-app only. */
export enum NotificationPriority {
NORMAL = "NORMAL",
HIGH = "HIGH",
}
/**
* Semantic type of a notification. Used by the frontend to pick an icon/label
* and by producers to categorize. `GENERIC` is the catch-all for ad-hoc calls.
*/
export enum NotificationType {
GENERIC = "GENERIC",
// Portal-facing (customer)
CLEARANCE_DECISION = "CLEARANCE_DECISION",
DOCUMENT_ACTION = "DOCUMENT_ACTION",
BOOKING_STATUS = "BOOKING_STATUS",
INVOICE_ISSUED = "INVOICE_ISSUED",
// Backoffice-facing (staff)
REQUEST_SUBMITTED = "REQUEST_SUBMITTED",
PAYMENT_RECEIVED = "PAYMENT_RECEIVED",
CLEARANCE_REVIEW = "CLEARANCE_REVIEW",
}
/** Optional per-channel delivery outcome recorded on the notification row. */
export interface NotificationChannelsSent {
email?: boolean;
sms?: boolean;
}
/** A persisted in-app notification as returned to the client. */
export interface NotificationDto {
id: string;
recipientUserId: string;
audience: NotificationAudience;
type: NotificationType;
title: string;
body: string;
/** Deep-link path within the app the item points to (e.g. `/contracts/:id`). */
link?: string | null;
/** Arbitrary structured payload (bookingId, invoiceId, contractId, …). */
data?: Record<string, unknown> | null;
priority: NotificationPriority;
isRead: boolean;
readAt?: string | null;
createdAt: string;
}
/** Target selector: any combination resolves to a set of recipient user ids. */
export interface NotificationRecipients {
/** Explicit IAM user ids — always honored. */
userIds?: string[];
/** Portal: all users linked to this company (via external profiles). */
companyId?: string;
/** Portal: resolved to the company, then to that company's users. */
companyProfileId?: string;
/** Backoffice: all current employees of this organization. */
organizationId?: string;
}
/** Input any subsystem passes to `NotificationInboxService.notify(...)`. */
export interface NotifyInput {
recipients: NotificationRecipients;
audience: NotificationAudience;
type: NotificationType;
title: string;
body: string;
link?: string | null;
data?: Record<string, unknown> | null;
priority?: NotificationPriority;
}
/** Paginated list envelope for the notifications list endpoint. */
export interface NotificationListResult {
items: NotificationDto[];
count: number;
unreadCount: number;
}
/** Socket.io event names pushed server → client on the `notifications` namespace. */
export const NOTIFICATION_WS_EVENTS = {
NEW: "notification:new",
UNREAD_COUNT: "notification:unread-count",
} as const;
/** Socket.io namespace the notifications gateway listens on. */
export const NOTIFICATION_WS_NAMESPACE = "notifications";

View File

@@ -0,0 +1,166 @@
import {
ActionIcon,
Box,
Button,
Group,
Indicator,
Loader,
Menu,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { Bell, CheckCheck } from "lucide-react";
/** Minimal shape the bell needs to render one row. */
export interface NotificationBellItem {
id: string;
title: string;
body: string;
createdAt: string;
isRead: boolean;
link?: string | null;
}
export interface NotificationBellProps {
items: NotificationBellItem[];
unreadCount: number;
loading?: boolean;
/** Fired when the dropdown opens — a good place to refetch. */
onOpen?: () => void;
onItemClick?: (item: NotificationBellItem) => void;
onMarkAllRead?: () => void;
emptyLabel?: string;
/** Extra className for the trigger button (e.g. the app's header "island"). */
triggerClassName?: string;
}
const timeAgo = (iso: string): string => {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return new Date(iso).toLocaleDateString();
};
/**
* Presentational notification bell + dropdown. Owns no data or transport — the
* hosting app wires react-query/websocket and passes items + handlers. Built on
* Mantine primitives to match the freight app headers.
*/
export function NotificationBell({
items,
unreadCount,
loading = false,
onOpen,
onItemClick,
onMarkAllRead,
emptyLabel = "You're all caught up",
triggerClassName,
}: NotificationBellProps) {
const hasUnread = unreadCount > 0;
return (
<Menu
width={360}
position="bottom-end"
offset={8}
radius="md"
shadow="md"
withinPortal
onOpen={onOpen}
>
<Menu.Target>
<Indicator
color="red"
size={16}
offset={4}
disabled={!hasUnread}
label={unreadCount > 99 ? "99+" : unreadCount}
styles={{ indicator: { fontSize: 10, fontWeight: 700, padding: "0 4px" } }}
>
<ActionIcon
variant="subtle"
radius="xl"
size={36}
aria-label="Notifications"
className={triggerClassName}
>
<Bell size={17} strokeWidth={1.8} />
</ActionIcon>
</Indicator>
</Menu.Target>
<Menu.Dropdown p={0}>
<Group justify="space-between" px="sm" py="xs" wrap="nowrap">
<Text fw={600} size="sm">
Notifications
</Text>
{hasUnread && onMarkAllRead && (
<Button
variant="subtle"
size="compact-xs"
leftSection={<CheckCheck size={13} />}
onClick={onMarkAllRead}
>
Mark all read
</Button>
)}
</Group>
<Menu.Divider m={0} />
{loading && items.length === 0 ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : items.length === 0 ? (
<Box py="xl" px="md">
<Text c="dimmed" size="sm" ta="center">
{emptyLabel}
</Text>
</Box>
) : (
<ScrollArea.Autosize mah={380} type="hover">
<Stack gap={0}>
{items.map((item) => (
<Box
key={item.id}
px="sm"
py="xs"
onClick={() => onItemClick?.(item)}
style={{
cursor: onItemClick ? "pointer" : "default",
background: item.isRead
? undefined
: "var(--mantine-color-blue-light)",
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap" gap={8}>
<Text fw={item.isRead ? 500 : 700} size="sm" lineClamp={1}>
{item.title}
</Text>
<Text c="dimmed" size="xs" style={{ whiteSpace: "nowrap" }}>
{timeAgo(item.createdAt)}
</Text>
</Group>
<Text c="dimmed" size="xs" lineClamp={2}>
{item.body}
</Text>
</Box>
))}
</Stack>
</ScrollArea.Autosize>
)}
</Menu.Dropdown>
</Menu>
);
}
export default NotificationBell;

View File

@@ -0,0 +1,5 @@
export { NotificationBell, default } from "./NotificationBell";
export type {
NotificationBellProps,
NotificationBellItem,
} from "./NotificationBell";

View File

@@ -28,6 +28,12 @@ export type { OperationDatePickerProps } from "./components/OperationDatePicker"
export { CountdownTimer } from "./components/CountdownTimer";
export type { CountdownTimerProps } from "./components/CountdownTimer";
export { NotificationBell } from "./components/NotificationBell";
export type {
NotificationBellProps,
NotificationBellItem,
} from "./components/NotificationBell";
export { Badge } from "./components/badge";
// export type { BadgeProps } from "./components/badge";