diff --git a/apps/admin/src/app/(shell)/journal/page.tsx b/apps/admin/src/app/(shell)/journal/page.tsx new file mode 100644 index 00000000..e87ad360 --- /dev/null +++ b/apps/admin/src/app/(shell)/journal/page.tsx @@ -0,0 +1,16 @@ +import { sectionPaths } from '@/config/navigation'; +import { ActionLogContainer } from '@/containers/ActionLogContainer'; +import { AdminOnlyGate } from '@/containers/AdminOnlyGate'; +import { messages } from '@/messages/fr'; +import { Suspense } from 'react'; + +/** A-12, admins only. The filters and the open line are read from the address, in the browser. */ +export default function ActionLogPage() { + return ( + + + + + + ); +} diff --git a/apps/admin/src/app/(shell)/reglages/page.tsx b/apps/admin/src/app/(shell)/reglages/page.tsx new file mode 100644 index 00000000..0155e820 --- /dev/null +++ b/apps/admin/src/app/(shell)/reglages/page.tsx @@ -0,0 +1,16 @@ +import { sectionPaths } from '@/config/navigation'; +import { AdminOnlyGate } from '@/containers/AdminOnlyGate'; +import { SettingsContainer } from '@/containers/SettingsContainer'; +import { messages } from '@/messages/fr'; +import { Suspense } from 'react'; + +/** A-11, admins only. The open setting is read from the address, in the browser only. */ +export default function SettingsPage() { + return ( + + + + + + ); +} diff --git a/apps/admin/src/components/actions/ActionPanelContent.tsx b/apps/admin/src/components/actions/ActionPanelContent.tsx new file mode 100644 index 00000000..6aa7fd4a --- /dev/null +++ b/apps/admin/src/components/actions/ActionPanelContent.tsx @@ -0,0 +1,76 @@ +import { + actionLabelOf, + isTargetGone, + targetNameOf, + targetPathOf, + targetTypeLabelOf, +} from '@/lib/action-display'; +import { formatFullDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import type { AdminActionItem } from '@widoo/shared'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +const texts = messages.actionLog.panel; + +function Line({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** A state of the target, as the journal kept it: the JSON, or « Rien à afficher ». */ +function State({ title, state }: { title: string; state: unknown }) { + return ( +
+

{title}

+ {state === null || state === undefined ? ( +

{texts.noState}

+ ) : ( +
+          {JSON.stringify(state, null, 2)}
+        
+ )} +
+ ); +} + +/** A line of the journal in the detail panel (A-12): what was done, and the state before and after. */ +export function ActionPanelContent({ action }: { action: AdminActionItem }) { + const targetPath = targetPathOf(action.targetType, action.targetId); + const targetName = targetNameOf(action); + const isGone = isTargetGone(action); + return ( +
+
+ {formatFullDate(new Date(action.createdAt))} + {action.actor?.name ?? messages.actionLog.unknownActor} + {actionLabelOf(action.action)} + + + {targetName ?? targetTypeLabelOf(action.targetType)} + + {targetName ? `${targetTypeLabelOf(action.targetType)} · ` : ''} + {action.targetId} + + {isGone ? ( + {texts.targetGone} + ) : targetPath ? ( + + {texts.openTarget} + + ) : null} + + + + {action.reason ?? {texts.noReason}} + +
+ + +
+ ); +} diff --git a/apps/admin/src/components/actions/ActionsTable.tsx b/apps/admin/src/components/actions/ActionsTable.tsx new file mode 100644 index 00000000..fae50eb3 --- /dev/null +++ b/apps/admin/src/components/actions/ActionsTable.tsx @@ -0,0 +1,60 @@ +import { actionLabelOf, targetNameOf, targetTypeLabelOf } from '@/lib/action-display'; +import { formatRelativeDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import type { AdminActionItem } from '@widoo/shared'; +import { Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui'; + +const texts = messages.actionLog; +const columnCount = 5; + +type ActionsTableProps = { + /** Rows of the page; ignored while `isLoading`. */ + items: AdminActionItem[]; + isLoading: boolean; + /** The line whose detail is open in the panel. */ + openId: string | null; + onOpen: (action: AdminActionItem) => void; +}; + +/** The journal of the admin as a table (A-12), most recent first; a row opens its detail. */ +export function ActionsTable({ items, isLoading, openId, onOpen }: ActionsTableProps) { + return ( + + + + {texts.columns.date} + {texts.columns.actor} + {texts.columns.action} + {texts.columns.target} + {texts.columns.reason} + + + + {isLoading ? ( + + ) : ( + items.map((action) => ( + onOpen(action)} isOpen={action.id === openId}> + {formatRelativeDate(new Date(action.createdAt))} + + {action.actor?.name ?? ( + {texts.unknownActor} + )} + + {actionLabelOf(action.action)} + + + {targetTypeLabelOf(action.targetType)} + + {targetNameOf(action) ?? action.targetId} + + + + {action.reason ?? ''} + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/components/settings/SettingEditDialog.tsx b/apps/admin/src/components/settings/SettingEditDialog.tsx new file mode 100644 index 00000000..f6bc0031 --- /dev/null +++ b/apps/admin/src/components/settings/SettingEditDialog.tsx @@ -0,0 +1,87 @@ +import { formatSettingNumber } from '@/lib/setting-display'; +import type { SettingFields } from '@/lib/setting-form'; +import { messages } from '@/messages/fr'; +import type { AdminSetting } from '@widoo/shared'; +import { ConfirmDialog, Field, Input, Textarea } from '@widoo/ui'; + +const texts = messages.settings.edit; + +type SettingEditDialogProps = { + isOpen: boolean; + setting: AdminSetting; + fields: SettingFields; + onFieldChange: (name: string, text: string) => void; + reason: string; + onReasonChange: (reason: string) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** + * « Modifier » a setting: its value (one field, or one per weight) and the required reason kept + * in the journal. The API holds the value to the bounds, which the window tells beforehand. + */ +export function SettingEditDialog({ + isOpen, + setting, + fields, + onFieldChange, + reason, + onReasonChange, + onCancel, + onConfirm, + isBusy, +}: SettingEditDialogProps) { + const item = messages.settings.items[setting.key]; + const hint = + setting.max === null + ? texts.rangeFrom(formatSettingNumber(setting.min)) + : texts.range(formatSettingNumber(setting.min), formatSettingNumber(setting.max)); + return ( + + {Object.keys(fields).map((name) => { + const id = `setting-${name}`; + return ( + + onFieldChange(name, event.target.value)} + /> + + ); + })} + +