create-astram-app 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/README.md +18 -0
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +12 -0
- package/template/frontend/src/pages/notifications/NotificationsPage.jsx +550 -0
- package/template/frontend/src/pages/notifications/notificationList.js +268 -0
- package/template/frontend/src/pages/notifications/notificationList.test.mjs +273 -0
- package/template/frontend/src/shell/AnnouncementsMenu.jsx +3 -6
- package/template/frontend/src/shell/NotificationsMenu.jsx +50 -19
- package/template/frontend/src/shell/notificationsView.js +41 -2
- package/template/frontend/src/shell/notificationsView.test.mjs +38 -0
- package/template/server/_env.example +3 -1
- package/template/server/package.json +3 -3
- package/template/server/src/app.module.ts +16 -0
- package/template/server/src/notifications/app-notifications.service.spec.ts +26 -0
- package/template/server/src/notifications/app-notifications.service.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.",
|
|
6
6
|
"type": "module",
|
package/template/README.md
CHANGED
|
@@ -122,6 +122,24 @@ action })` for guarded ones. Anything unclassified is denied by design (403; wit
|
|
|
122
122
|
|
|
123
123
|
`server/src/features/` starts empty — it's your space.
|
|
124
124
|
|
|
125
|
+
## Send your own notifications
|
|
126
|
+
|
|
127
|
+
The header has a bell (personal notices) and an Announcements icon; both show unread notices,
|
|
128
|
+
and **View all notifications** opens the full page (`/notifications`) with search, filters,
|
|
129
|
+
mark unread and archive. Your features can post into the same menus:
|
|
130
|
+
|
|
131
|
+
1. With `auth: true`, register each kind in `ShieldModule.forRoot({ notificationKinds })`:
|
|
132
|
+
`category: 'personal'` (bell) or `'announcement'`, a `label` users see (≤ 40 characters) and
|
|
133
|
+
an optional `severity` (`info` | `success` | `warning`). See the comment in
|
|
134
|
+
`server/src/app.module.ts`.
|
|
135
|
+
2. Post with `server/src/notifications/app-notifications.service.ts` (an example to copy; add it
|
|
136
|
+
to your module's `providers`):
|
|
137
|
+
`notifyUsers(tenantId, [userId], { kind: 'app.task_assigned', title: 'Order 123 needs your approval', link: '/orders/123' })`.
|
|
138
|
+
Clicking the notice opens `link`; that page still checks access itself. Take `tenantId` from
|
|
139
|
+
the session or the record you saved, never from the request body.
|
|
140
|
+
3. How long notices are kept: `NOTIFICATION_READ_RETENTION_DAYS` (default 90) and
|
|
141
|
+
`NOTIFICATION_MAX_RETENTION_DAYS` (default 365) in `server/.env` (see `server/_env.example`).
|
|
142
|
+
|
|
125
143
|
## Bootstrap your organisation
|
|
126
144
|
|
|
127
145
|
`npm run bootstrap:org` (in `server/`) creates your organisation (tenants, companies,
|
|
@@ -41,6 +41,7 @@ import GrantsPage from './pages/cross-tenant/GrantsPage';
|
|
|
41
41
|
import GrantForm from './pages/cross-tenant/GrantForm';
|
|
42
42
|
import InboxPage from './pages/cross-tenant/InboxPage';
|
|
43
43
|
import SecuritySettingsPage from './pages/security/SecuritySettingsPage';
|
|
44
|
+
import NotificationsPage from './pages/notifications/NotificationsPage';
|
|
44
45
|
|
|
45
46
|
/** Only-when-signed-out (auth screens bounce authenticated users to the app). */
|
|
46
47
|
function PublicOnly({ children }) {
|
|
@@ -164,6 +165,17 @@ export default function App() {
|
|
|
164
165
|
</RequireAuth>
|
|
165
166
|
}
|
|
166
167
|
/>
|
|
168
|
+
{/* Notifications (G-035) — every notice of the signed-in user in the active company,
|
|
169
|
+
reached from the bell / Announcements "View all notifications". Signed in only, no
|
|
170
|
+
permission key: the server binds every call to the session's own user and company. */}
|
|
171
|
+
<Route
|
|
172
|
+
path="/notifications"
|
|
173
|
+
element={
|
|
174
|
+
<RequireAuth>
|
|
175
|
+
<NotificationsPage />
|
|
176
|
+
</RequireAuth>
|
|
177
|
+
}
|
|
178
|
+
/>
|
|
167
179
|
{/* Account — a persistent layout with per-tab child routes (bookmarkable, back
|
|
168
180
|
works). /account redirects to the Profile tab. */}
|
|
169
181
|
<Route
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationsPage — every notice of the signed-in user in the active company (G-035,
|
|
3
|
+
* notification-center SPEC §5F), reached from the bell and Announcements menus ("View all
|
|
4
|
+
* notifications"). Any signed-in user; no permission key: the server binds every call to the
|
|
5
|
+
* session's user and company, so a user only ever sees and changes their own notices.
|
|
6
|
+
*
|
|
7
|
+
* Tabs Notifications (`personal`) / Announcements (`announcement`); a server-paged EntityList
|
|
8
|
+
* with search, Type / Status / Date filters and page sizes 10/20/50/100. Tab, filters, search,
|
|
9
|
+
* page and limit live in the URL (notificationList.js), so a reload or a shared link keeps the
|
|
10
|
+
* view. A row click marks the notice read and follows its link when app-relative. Row actions
|
|
11
|
+
* and the bulk bar mark read / unread and archive / unarchive; "Mark all read" clears the tab.
|
|
12
|
+
* After each change the list reloads and the header badges refresh.
|
|
13
|
+
*
|
|
14
|
+
* Title, body and type label are plain React text (never HTML). No raw uuids: a row's uuid is a
|
|
15
|
+
* React key and a call argument only; the type shows `kindLabel`, never `kind`.
|
|
16
|
+
*/
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
18
|
+
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
19
|
+
import { Bell, CheckCheck, Loader2, MoreHorizontal, RotateCw } from 'lucide-react';
|
|
20
|
+
import { toast } from 'sonner';
|
|
21
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
22
|
+
import AppShell from '@/shell/AppShell';
|
|
23
|
+
import { useNotificationCounts } from '@/shell/useNotificationCounts';
|
|
24
|
+
import { badgeText, safeAppLink, severityInfo } from '@/shell/notificationsView';
|
|
25
|
+
import PageHeader from '@/components/PageHeader';
|
|
26
|
+
import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
|
|
27
|
+
import { Badge } from '@/components/ui/badge';
|
|
28
|
+
import { Button } from '@/components/ui/button';
|
|
29
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
30
|
+
import { DateField } from '@/components/ui/date-field';
|
|
31
|
+
import {
|
|
32
|
+
DropdownMenu,
|
|
33
|
+
DropdownMenuContent,
|
|
34
|
+
DropdownMenuItem,
|
|
35
|
+
DropdownMenuTrigger,
|
|
36
|
+
} from '@/components/ui/dropdown-menu';
|
|
37
|
+
import {
|
|
38
|
+
Select,
|
|
39
|
+
SelectContent,
|
|
40
|
+
SelectItem,
|
|
41
|
+
SelectTrigger,
|
|
42
|
+
SelectValue,
|
|
43
|
+
} from '@/components/ui/select';
|
|
44
|
+
import { cn } from '@/lib/utils';
|
|
45
|
+
import {
|
|
46
|
+
BULK_ACTIONS,
|
|
47
|
+
BULK_MAX,
|
|
48
|
+
DATE_OPTIONS,
|
|
49
|
+
PAGE_SIZES,
|
|
50
|
+
STATUS_OPTIONS,
|
|
51
|
+
TABS,
|
|
52
|
+
buildListQuery,
|
|
53
|
+
bulkDoneText,
|
|
54
|
+
dateRange,
|
|
55
|
+
emptyText,
|
|
56
|
+
keepSelection,
|
|
57
|
+
kindOptionsFor,
|
|
58
|
+
pageStateToParams,
|
|
59
|
+
readPageState,
|
|
60
|
+
receivedText,
|
|
61
|
+
rowActions,
|
|
62
|
+
statusLabel,
|
|
63
|
+
statusVariant,
|
|
64
|
+
} from './notificationList';
|
|
65
|
+
|
|
66
|
+
// Radix Select items cannot carry an empty value: '' (All / Any) travels as this sentinel.
|
|
67
|
+
const ALL = '__all__';
|
|
68
|
+
const toSelect = (v) => (v ? v : ALL);
|
|
69
|
+
const fromSelect = (v) => (v === ALL ? '' : v);
|
|
70
|
+
|
|
71
|
+
/** The single-notice service call for each action. */
|
|
72
|
+
const SINGLE_CALL = {
|
|
73
|
+
read: 'markNotificationRead',
|
|
74
|
+
unread: 'markNotificationUnread',
|
|
75
|
+
archive: 'archiveNotification',
|
|
76
|
+
unarchive: 'unarchiveNotification',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** One filter select (Type / Status / Date). */
|
|
80
|
+
function FilterSelect({ label, value, options, onChange, className }) {
|
|
81
|
+
return (
|
|
82
|
+
<Select value={toSelect(value)} onValueChange={(v) => onChange(fromSelect(v))}>
|
|
83
|
+
<SelectTrigger className={cn('h-9', className)} aria-label={label}>
|
|
84
|
+
<SelectValue />
|
|
85
|
+
</SelectTrigger>
|
|
86
|
+
<SelectContent>
|
|
87
|
+
{options.map((o) => (
|
|
88
|
+
<SelectItem key={o.value || ALL} value={toSelect(o.value)}>
|
|
89
|
+
{o.label}
|
|
90
|
+
</SelectItem>
|
|
91
|
+
))}
|
|
92
|
+
</SelectContent>
|
|
93
|
+
</Select>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The tab strip (WAI-ARIA tabs: arrow keys move between the two tabs). */
|
|
98
|
+
function CategoryTabs({ tab, counts, onChange }) {
|
|
99
|
+
const onKeyDown = (e) => {
|
|
100
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
const i = TABS.findIndex((t) => t.value === tab);
|
|
103
|
+
const next = TABS[(i + (e.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length];
|
|
104
|
+
onChange(next.value);
|
|
105
|
+
document.getElementById(`notifications-tab-${next.value}`)?.focus();
|
|
106
|
+
};
|
|
107
|
+
return (
|
|
108
|
+
<div role="tablist" aria-label="Notification type" className="mb-3 flex gap-1 border-b">
|
|
109
|
+
{TABS.map((t) => {
|
|
110
|
+
const active = t.value === tab;
|
|
111
|
+
const unread = badgeText(counts[t.value]);
|
|
112
|
+
return (
|
|
113
|
+
<button
|
|
114
|
+
key={t.value}
|
|
115
|
+
id={`notifications-tab-${t.value}`}
|
|
116
|
+
type="button"
|
|
117
|
+
role="tab"
|
|
118
|
+
aria-selected={active}
|
|
119
|
+
aria-controls="notifications-panel"
|
|
120
|
+
tabIndex={active ? 0 : -1}
|
|
121
|
+
onClick={() => onChange(t.value)}
|
|
122
|
+
onKeyDown={onKeyDown}
|
|
123
|
+
className={cn(
|
|
124
|
+
'-mb-px inline-flex items-center gap-2 border-b-2 px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
125
|
+
active
|
|
126
|
+
? 'border-primary text-foreground'
|
|
127
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
{t.label}
|
|
131
|
+
{unread && (
|
|
132
|
+
<Badge variant="info" className="px-1.5 py-0">
|
|
133
|
+
{unread}
|
|
134
|
+
<span className="sr-only"> unread</span>
|
|
135
|
+
</Badge>
|
|
136
|
+
)}
|
|
137
|
+
</button>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default function NotificationsPage() {
|
|
145
|
+
const { tokens, notifications } = useAuth();
|
|
146
|
+
const counts = useNotificationCounts();
|
|
147
|
+
const { refresh } = counts;
|
|
148
|
+
const navigate = useNavigate();
|
|
149
|
+
const token = tokens?.accessToken ?? null;
|
|
150
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
151
|
+
// The URL is the one source of the view; every change writes it back (replace, no history spam).
|
|
152
|
+
const state = useMemo(() => readPageState(searchParams), [searchParams]);
|
|
153
|
+
|
|
154
|
+
const [data, setData] = useState({ status: 'loading', rows: [], total: 0, error: '' });
|
|
155
|
+
const [kinds, setKinds] = useState([]);
|
|
156
|
+
const [selected, setSelected] = useState(() => new Set());
|
|
157
|
+
const [busy, setBusy] = useState(false);
|
|
158
|
+
// Bumped per load: only the latest answer applies (a slow older answer never overwrites it).
|
|
159
|
+
const loadSeq = useRef(0);
|
|
160
|
+
|
|
161
|
+
const update = useCallback(
|
|
162
|
+
(patch) => setSearchParams(pageStateToParams({ ...state, ...patch }), { replace: true }),
|
|
163
|
+
[state, setSearchParams],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const load = useCallback(
|
|
167
|
+
async (signal) => {
|
|
168
|
+
if (!token) return;
|
|
169
|
+
const seq = ++loadSeq.current;
|
|
170
|
+
setData((d) => ({ ...d, status: 'loading' }));
|
|
171
|
+
const r = await notifications.listNotifications(
|
|
172
|
+
token,
|
|
173
|
+
buildListQuery(state, Date.now()),
|
|
174
|
+
signal,
|
|
175
|
+
);
|
|
176
|
+
if (signal?.aborted || seq !== loadSeq.current) return;
|
|
177
|
+
if (r.ok) {
|
|
178
|
+
setData({ status: 'ready', rows: r.rows, total: r.total, error: '' });
|
|
179
|
+
setSelected((s) => keepSelection(s, r.rows));
|
|
180
|
+
} else {
|
|
181
|
+
setData({ status: 'error', rows: [], total: 0, error: r.error });
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
[notifications, token, state],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const ac = new AbortController();
|
|
189
|
+
void load(ac.signal);
|
|
190
|
+
return () => ac.abort();
|
|
191
|
+
}, [load]);
|
|
192
|
+
|
|
193
|
+
// The Type filter's options: the kinds this user has notices of (failure ⇒ only "All types").
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!token) return undefined;
|
|
196
|
+
const ac = new AbortController();
|
|
197
|
+
void (async () => {
|
|
198
|
+
const r = await notifications.listNotificationKinds(token);
|
|
199
|
+
if (!ac.signal.aborted && r.ok) setKinds(r.kinds);
|
|
200
|
+
})();
|
|
201
|
+
return () => ac.abort();
|
|
202
|
+
}, [notifications, token]);
|
|
203
|
+
|
|
204
|
+
// Past the last page (e.g. the last row on it was archived away): step back to the last page.
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (data.status !== 'ready' || data.rows.length > 0 || state.page <= 1 || data.total <= 0) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
update({ page: Math.max(1, Math.ceil(data.total / state.limit)) });
|
|
210
|
+
}, [data, state.page, state.limit, update]);
|
|
211
|
+
|
|
212
|
+
const kindOptions = useMemo(
|
|
213
|
+
() => [{ value: '', label: 'All types' }, ...kindOptionsFor(kinds, state.tab)],
|
|
214
|
+
[kinds, state.tab],
|
|
215
|
+
);
|
|
216
|
+
const range = dateRange(state);
|
|
217
|
+
|
|
218
|
+
/** After a successful change: reload this page and the header badges. */
|
|
219
|
+
const afterChange = useCallback(() => {
|
|
220
|
+
void load();
|
|
221
|
+
void refresh();
|
|
222
|
+
}, [load, refresh]);
|
|
223
|
+
|
|
224
|
+
// One notice, one action. A failure shows the fixed error text and leaves the row as it was.
|
|
225
|
+
const actOne = useCallback(
|
|
226
|
+
async (action, row) => {
|
|
227
|
+
if (!token || busy) return;
|
|
228
|
+
setBusy(true);
|
|
229
|
+
const r = await notifications[SINGLE_CALL[action]](token, row.uuid);
|
|
230
|
+
setBusy(false);
|
|
231
|
+
if (!r.ok) {
|
|
232
|
+
toast.error(r.error);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
afterChange();
|
|
236
|
+
},
|
|
237
|
+
[notifications, token, busy, afterChange],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const actBulk = async (action) => {
|
|
241
|
+
const uuids = [...selected].slice(0, BULK_MAX);
|
|
242
|
+
if (!token || busy || uuids.length === 0) return;
|
|
243
|
+
setBusy(true);
|
|
244
|
+
const r = await notifications.bulkUpdateNotifications(token, action, uuids);
|
|
245
|
+
setBusy(false);
|
|
246
|
+
if (!r.ok) {
|
|
247
|
+
toast.error(r.error);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
toast.success(bulkDoneText(action, r.updated));
|
|
251
|
+
setSelected(new Set());
|
|
252
|
+
afterChange();
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const markAllRead = async () => {
|
|
256
|
+
if (!token || busy) return;
|
|
257
|
+
setBusy(true);
|
|
258
|
+
const r = await notifications.markAllNotificationsRead(token, state.tab);
|
|
259
|
+
setBusy(false);
|
|
260
|
+
if (!r.ok) {
|
|
261
|
+
toast.error(r.error);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
toast.success(bulkDoneText('read', r.updated));
|
|
265
|
+
afterChange();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Row click: mark read (if unread), then follow an app-relative link; else reload in place.
|
|
269
|
+
const openRow = useCallback(
|
|
270
|
+
async (row) => {
|
|
271
|
+
if (busy) return;
|
|
272
|
+
if (!row.readAt && token) {
|
|
273
|
+
const r = await notifications.markNotificationRead(token, row.uuid);
|
|
274
|
+
if (!r.ok) toast.error(r.error);
|
|
275
|
+
}
|
|
276
|
+
const link = safeAppLink(row.link);
|
|
277
|
+
void refresh();
|
|
278
|
+
if (link) navigate(link);
|
|
279
|
+
else void load();
|
|
280
|
+
},
|
|
281
|
+
[busy, token, notifications, refresh, navigate, load],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const pageUuids = data.rows.map((r) => r.uuid);
|
|
285
|
+
const selectedOnPage = pageUuids.filter((u) => selected.has(u)).length;
|
|
286
|
+
const allChecked =
|
|
287
|
+
selectedOnPage > 0 && selectedOnPage === pageUuids.length
|
|
288
|
+
? true
|
|
289
|
+
: selectedOnPage > 0
|
|
290
|
+
? 'indeterminate'
|
|
291
|
+
: false;
|
|
292
|
+
|
|
293
|
+
const toggleOne = (uuid, on) =>
|
|
294
|
+
setSelected((s) => {
|
|
295
|
+
const next = new Set(s);
|
|
296
|
+
if (on) next.add(uuid);
|
|
297
|
+
else next.delete(uuid);
|
|
298
|
+
return next;
|
|
299
|
+
});
|
|
300
|
+
const toggleAll = (on) => setSelected(on ? new Set(pageUuids) : new Set());
|
|
301
|
+
|
|
302
|
+
const columns = [
|
|
303
|
+
{
|
|
304
|
+
key: 'select',
|
|
305
|
+
label: (
|
|
306
|
+
<Checkbox
|
|
307
|
+
checked={allChecked}
|
|
308
|
+
onCheckedChange={(v) => toggleAll(v === true)}
|
|
309
|
+
disabled={data.rows.length === 0}
|
|
310
|
+
aria-label="Select all notifications on this page"
|
|
311
|
+
/>
|
|
312
|
+
),
|
|
313
|
+
render: (row) => (
|
|
314
|
+
<Checkbox
|
|
315
|
+
checked={selected.has(row.uuid)}
|
|
316
|
+
onCheckedChange={(v) => toggleOne(row.uuid, v === true)}
|
|
317
|
+
aria-label={`Select “${row.title}”`}
|
|
318
|
+
/>
|
|
319
|
+
),
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
key: 'severity',
|
|
323
|
+
label: <span className="sr-only">Severity</span>,
|
|
324
|
+
render: (row) => {
|
|
325
|
+
const s = severityInfo(row.severity);
|
|
326
|
+
return (
|
|
327
|
+
<span className="inline-flex items-center">
|
|
328
|
+
<span aria-hidden="true" className={cn('size-2 rounded-full', s.dotClass)} />
|
|
329
|
+
<span className="sr-only">{s.label}</span>
|
|
330
|
+
</span>
|
|
331
|
+
);
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
key: 'notification',
|
|
336
|
+
label: 'Notification',
|
|
337
|
+
render: (row) => (
|
|
338
|
+
<div className="min-w-0 max-w-xl text-left">
|
|
339
|
+
<div className={cn('text-sm', row.readAt ? 'font-normal' : 'font-semibold')}>
|
|
340
|
+
{row.title}
|
|
341
|
+
</div>
|
|
342
|
+
{row.body && (
|
|
343
|
+
<div className="mt-0.5 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
|
|
344
|
+
{row.body}
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
<div className="mt-1 text-[11px] text-muted-foreground">{row.kindLabel}</div>
|
|
348
|
+
</div>
|
|
349
|
+
),
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
key: 'received',
|
|
353
|
+
label: 'Received',
|
|
354
|
+
render: (row) => (
|
|
355
|
+
<span className="whitespace-nowrap text-sm">{receivedText(row.createdAt) || '—'}</span>
|
|
356
|
+
),
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
key: 'status',
|
|
360
|
+
label: 'Status',
|
|
361
|
+
render: (row) => <Badge variant={statusVariant(row)}>{statusLabel(row)}</Badge>,
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: 'actions',
|
|
365
|
+
label: <span className="sr-only">Actions</span>,
|
|
366
|
+
render: (row) => (
|
|
367
|
+
<DropdownMenu>
|
|
368
|
+
<DropdownMenuTrigger asChild>
|
|
369
|
+
<button
|
|
370
|
+
type="button"
|
|
371
|
+
disabled={busy}
|
|
372
|
+
aria-label={`Actions for “${row.title}”`}
|
|
373
|
+
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
374
|
+
>
|
|
375
|
+
<MoreHorizontal className="size-4" />
|
|
376
|
+
</button>
|
|
377
|
+
</DropdownMenuTrigger>
|
|
378
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
379
|
+
{rowActions(row).map((a) => (
|
|
380
|
+
<DropdownMenuItem key={a.action} onClick={() => void actOne(a.action, row)}>
|
|
381
|
+
{a.label}
|
|
382
|
+
</DropdownMenuItem>
|
|
383
|
+
))}
|
|
384
|
+
</DropdownMenuContent>
|
|
385
|
+
</DropdownMenu>
|
|
386
|
+
),
|
|
387
|
+
},
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
// EntityList keys rows by `id` (never shown).
|
|
391
|
+
const listRows = data.rows.map((row) => ({ ...row, id: row.uuid }));
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<AppShell title="Notifications">
|
|
395
|
+
<div className="p-4">
|
|
396
|
+
<PageHeader
|
|
397
|
+
title="Notifications"
|
|
398
|
+
icon={Bell}
|
|
399
|
+
actions={
|
|
400
|
+
<>
|
|
401
|
+
<EntityListControls
|
|
402
|
+
search={state.search}
|
|
403
|
+
onSearchChange={(search) => update({ search, page: 1 })}
|
|
404
|
+
searchPlaceholder="Search notifications…"
|
|
405
|
+
/>
|
|
406
|
+
<Button
|
|
407
|
+
type="button"
|
|
408
|
+
size="sm"
|
|
409
|
+
variant="outline"
|
|
410
|
+
className="shrink-0"
|
|
411
|
+
disabled={busy || data.status !== 'ready'}
|
|
412
|
+
onClick={() => void markAllRead()}
|
|
413
|
+
>
|
|
414
|
+
<CheckCheck className="size-4" aria-hidden="true" />
|
|
415
|
+
Mark all read
|
|
416
|
+
</Button>
|
|
417
|
+
</>
|
|
418
|
+
}
|
|
419
|
+
/>
|
|
420
|
+
|
|
421
|
+
<CategoryTabs
|
|
422
|
+
tab={state.tab}
|
|
423
|
+
counts={counts}
|
|
424
|
+
onChange={(tab) => {
|
|
425
|
+
if (tab !== state.tab) update({ tab, kind: '', page: 1 });
|
|
426
|
+
}}
|
|
427
|
+
/>
|
|
428
|
+
|
|
429
|
+
<div
|
|
430
|
+
id="notifications-panel"
|
|
431
|
+
role="tabpanel"
|
|
432
|
+
aria-labelledby={`notifications-tab-${state.tab}`}
|
|
433
|
+
>
|
|
434
|
+
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
435
|
+
<FilterSelect
|
|
436
|
+
label="Type"
|
|
437
|
+
value={state.kind}
|
|
438
|
+
options={kindOptions}
|
|
439
|
+
onChange={(kind) => update({ kind, page: 1 })}
|
|
440
|
+
className="w-44"
|
|
441
|
+
/>
|
|
442
|
+
<FilterSelect
|
|
443
|
+
label="Status"
|
|
444
|
+
value={state.status}
|
|
445
|
+
options={STATUS_OPTIONS}
|
|
446
|
+
onChange={(status) => update({ status, page: 1 })}
|
|
447
|
+
className="w-32"
|
|
448
|
+
/>
|
|
449
|
+
<FilterSelect
|
|
450
|
+
label="Date"
|
|
451
|
+
value={state.date}
|
|
452
|
+
options={DATE_OPTIONS}
|
|
453
|
+
onChange={(date) =>
|
|
454
|
+
update(date === 'custom' ? { date, page: 1 } : { date, from: '', to: '', page: 1 })
|
|
455
|
+
}
|
|
456
|
+
className="w-36"
|
|
457
|
+
/>
|
|
458
|
+
{state.date === 'custom' && (
|
|
459
|
+
<>
|
|
460
|
+
<DateField
|
|
461
|
+
value={state.from}
|
|
462
|
+
max={state.to || undefined}
|
|
463
|
+
onChange={(e) => update({ from: e.target.value, page: 1 })}
|
|
464
|
+
aria-label="From date"
|
|
465
|
+
aria-invalid={range.invalid || undefined}
|
|
466
|
+
className="w-40"
|
|
467
|
+
/>
|
|
468
|
+
<span className="text-sm text-muted-foreground">to</span>
|
|
469
|
+
<DateField
|
|
470
|
+
value={state.to}
|
|
471
|
+
min={state.from || undefined}
|
|
472
|
+
onChange={(e) => update({ to: e.target.value, page: 1 })}
|
|
473
|
+
aria-label="To date"
|
|
474
|
+
aria-invalid={range.invalid || undefined}
|
|
475
|
+
className="w-40"
|
|
476
|
+
/>
|
|
477
|
+
</>
|
|
478
|
+
)}
|
|
479
|
+
{range.invalid && (
|
|
480
|
+
<p role="alert" className="text-xs text-destructive">
|
|
481
|
+
The start date is after the end date; no date filter is applied.
|
|
482
|
+
</p>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
{selected.size > 0 && (
|
|
487
|
+
<div
|
|
488
|
+
role="region"
|
|
489
|
+
aria-label="Selected notifications"
|
|
490
|
+
className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2 text-sm"
|
|
491
|
+
>
|
|
492
|
+
<span className="mr-1 font-medium">{selected.size} selected</span>
|
|
493
|
+
{BULK_ACTIONS.map((a) => (
|
|
494
|
+
<Button
|
|
495
|
+
key={a.action}
|
|
496
|
+
type="button"
|
|
497
|
+
size="sm"
|
|
498
|
+
variant="outline"
|
|
499
|
+
disabled={busy}
|
|
500
|
+
onClick={() => void actBulk(a.action)}
|
|
501
|
+
>
|
|
502
|
+
{a.label}
|
|
503
|
+
</Button>
|
|
504
|
+
))}
|
|
505
|
+
<Button
|
|
506
|
+
type="button"
|
|
507
|
+
size="sm"
|
|
508
|
+
variant="ghost"
|
|
509
|
+
disabled={busy}
|
|
510
|
+
onClick={() => setSelected(new Set())}
|
|
511
|
+
>
|
|
512
|
+
Clear selection
|
|
513
|
+
</Button>
|
|
514
|
+
{busy && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
|
|
518
|
+
<EntityList
|
|
519
|
+
columns={columns}
|
|
520
|
+
rows={listRows}
|
|
521
|
+
loading={data.status === 'loading'}
|
|
522
|
+
error={data.status === 'error' ? data.error : ''}
|
|
523
|
+
emptyLabel={emptyText(state)}
|
|
524
|
+
onRowClick={(row) => void openRow(row)}
|
|
525
|
+
/>
|
|
526
|
+
|
|
527
|
+
{data.status === 'error' && (
|
|
528
|
+
<div className="mt-3 flex justify-end">
|
|
529
|
+
<Button type="button" size="sm" variant="outline" onClick={() => void load()}>
|
|
530
|
+
<RotateCw className="size-4" aria-hidden="true" />
|
|
531
|
+
Try again
|
|
532
|
+
</Button>
|
|
533
|
+
</div>
|
|
534
|
+
)}
|
|
535
|
+
|
|
536
|
+
<EntityListPager
|
|
537
|
+
total={data.total}
|
|
538
|
+
page={state.page}
|
|
539
|
+
limit={state.limit}
|
|
540
|
+
loading={data.status === 'loading'}
|
|
541
|
+
error={data.status === 'error' ? data.error : ''}
|
|
542
|
+
pageSizes={PAGE_SIZES}
|
|
543
|
+
onPageChange={(page) => update({ page })}
|
|
544
|
+
onLimitChange={(limit) => update({ limit, page: 1 })}
|
|
545
|
+
/>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
</AppShell>
|
|
549
|
+
);
|
|
550
|
+
}
|