mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
chore: add notification to packages
This commit is contained in:
@@ -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",
|
||||
|
||||
101
packages/types/src/freight/notifications.ts
Normal file
101
packages/types/src/freight/notifications.ts
Normal 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";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { NotificationBell, default } from "./NotificationBell";
|
||||
export type {
|
||||
NotificationBellProps,
|
||||
NotificationBellItem,
|
||||
} from "./NotificationBell";
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user