Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/admin/src/app/(shell)/journal/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AdminOnlyGate backHref={sectionPaths.dashboard} backLabel={messages.notFound.back}>
<Suspense>
<ActionLogContainer />
</Suspense>
</AdminOnlyGate>
);
}
16 changes: 16 additions & 0 deletions apps/admin/src/app/(shell)/reglages/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AdminOnlyGate backHref={sectionPaths.dashboard} backLabel={messages.notFound.back}>
<Suspense>
<SettingsContainer />
</Suspense>
</AdminOnlyGate>
);
}
76 changes: 76 additions & 0 deletions apps/admin/src/components/actions/ActionPanelContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex gap-12 text-body-s">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 flex-1 break-words text-right">{children}</dd>
</div>
);
}

/** A state of the target, as the journal kept it: the JSON, or « Rien à afficher ». */
function State({ title, state }: { title: string; state: unknown }) {
return (
<section className="flex flex-col gap-8">
<h3 className="text-label-strong">{title}</h3>
{state === null || state === undefined ? (
<p className="text-body-s text-muted-foreground">{texts.noState}</p>
) : (
<pre className="overflow-x-auto rounded-photo bg-secondary p-12 text-label">
{JSON.stringify(state, null, 2)}
</pre>
)}
</section>
);
}

/** 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 (
<div className="flex flex-col gap-16">
<dl className="flex flex-col gap-8">
<Line label={texts.when}>{formatFullDate(new Date(action.createdAt))}</Line>
<Line label={texts.actor}>{action.actor?.name ?? messages.actionLog.unknownActor}</Line>
<Line label={texts.action}>{actionLabelOf(action.action)}</Line>
<Line label={texts.target}>
<span className="flex flex-col items-end">
{targetName ?? targetTypeLabelOf(action.targetType)}
<span className="text-label text-muted-foreground">
{targetName ? `${targetTypeLabelOf(action.targetType)} · ` : ''}
{action.targetId}
</span>
{isGone ? (
<span className="text-label text-muted-foreground">{texts.targetGone}</span>
) : targetPath ? (
<Link href={targetPath} className="text-blue-ink underline">
{texts.openTarget}
</Link>
) : null}
</span>
</Line>
<Line label={texts.reason}>
{action.reason ?? <span className="text-muted-foreground">{texts.noReason}</span>}
</Line>
</dl>
<State title={texts.before} state={action.before} />
<State title={texts.after} state={action.after} />
</div>
);
}
60 changes: 60 additions & 0 deletions apps/admin/src/components/actions/ActionsTable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Table>
<thead>
<tr>
<TableHeaderCell>{texts.columns.date}</TableHeaderCell>
<TableHeaderCell>{texts.columns.actor}</TableHeaderCell>
<TableHeaderCell>{texts.columns.action}</TableHeaderCell>
<TableHeaderCell>{texts.columns.target}</TableHeaderCell>
<TableHeaderCell>{texts.columns.reason}</TableHeaderCell>
</tr>
</thead>
<tbody>
{isLoading ? (
<TableSkeleton rows={8} columns={columnCount} label={messages.list.loading} />
) : (
items.map((action) => (
<TableRow key={action.id} onOpen={() => onOpen(action)} isOpen={action.id === openId}>
<TableCell>{formatRelativeDate(new Date(action.createdAt))}</TableCell>
<TableCell>
{action.actor?.name ?? (
<span className="text-muted-foreground">{texts.unknownActor}</span>
)}
</TableCell>
<TableCell>{actionLabelOf(action.action)}</TableCell>
<TableCell>
<span className="flex flex-col">
<span>{targetTypeLabelOf(action.targetType)}</span>
<span className="max-w-dialog truncate text-label text-muted-foreground">
{targetNameOf(action) ?? action.targetId}
</span>
</span>
</TableCell>
<TableCell className="max-w-dialog truncate">{action.reason ?? ''}</TableCell>
</TableRow>
))
)}
</tbody>
</Table>
);
}
87 changes: 87 additions & 0 deletions apps/admin/src/components/settings/SettingEditDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ConfirmDialog
isOpen={isOpen}
onCancel={onCancel}
onConfirm={onConfirm}
title={texts.title(item?.label ?? setting.key)}
description={texts.description}
cancelLabel={messages.confirm.cancel}
closeLabel={messages.confirm.close}
confirmLabel={texts.confirm}
confirmVariant="primary"
isBusy={isBusy}
>
{Object.keys(fields).map((name) => {
const id = `setting-${name}`;
return (
<Field
key={name}
label={messages.settings.weights[name] ?? texts.value}
htmlFor={id}
isRequired
hint={hint}
>
<Input
id={id}
required
inputMode="decimal"
aria-describedby={`${id}-hint`}
value={fields[name] ?? ''}
onChange={(event) => onFieldChange(name, event.target.value)}
/>
</Field>
);
})}
<Field label={texts.reason} htmlFor="setting-reason" isRequired hint={texts.reasonHint}>
<Textarea
id="setting-reason"
required
maxLength={500}
aria-describedby="setting-reason-hint"
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
/>
</Field>
</ConfirmDialog>
);
}
89 changes: 89 additions & 0 deletions apps/admin/src/components/settings/SettingPanelContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { actionLabelOf } from '@/lib/action-display';
import { formatFullDate, formatRelativeDate } from '@/lib/dates';
import { boundsOf, formatSettingValue } from '@/lib/setting-display';
import { messages } from '@/messages/fr';
import type { AdminActionItem, AdminSetting, SettingValue } from '@widoo/shared';
import type { ReactNode } from 'react';

const texts = messages.settings.panel;

function Line({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex gap-12 text-body-s">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 flex-1 break-words text-right">{children}</dd>
</div>
);
}

/** The value a journal line kept in its `before` or `after`, or null when it has none. */
function valueOfState(state: unknown): SettingValue | null {
if (typeof state !== 'object' || state === null || !('value' in state)) {
return null;
}
const { value } = state;
return typeof value === 'number' || (typeof value === 'object' && value !== null)
? (value as SettingValue)
: null;
}

type SettingPanelContentProps = {
setting: AdminSetting;
/** The lines of the journal about this setting, newest first; null while they load. */
history: AdminActionItem[] | null;
hasHistoryError: boolean;
};

/** A setting in the detail panel (A-11): its value, default and bounds, and who changed it. */
export function SettingPanelContent({
setting,
history,
hasHistoryError,
}: SettingPanelContentProps) {
const written = (value: SettingValue | null) =>
value === null ? '?' : formatSettingValue(setting, value);
return (
<div className="flex flex-col gap-16">
<p className="text-body-s text-muted-foreground">
{messages.settings.items[setting.key]?.explanation}
</p>
<dl className="flex flex-col gap-8">
<Line label={texts.value}>{formatSettingValue(setting, setting.value)}</Line>
<Line label={texts.default}>{formatSettingValue(setting, setting.default)}</Line>
<Line label={texts.bounds}>{boundsOf(setting)}</Line>
<Line label={texts.updated}>
{setting.updatedAt ? formatFullDate(new Date(setting.updatedAt)) : texts.never}
</Line>
</dl>
<section className="flex flex-col gap-8">
<h3 className="text-label-strong">{texts.history}</h3>
{hasHistoryError ? (
<p className="text-body-s text-muted-foreground">{texts.historyError}</p>
) : history === null ? null : history.length === 0 ? (
<p className="text-body-s text-muted-foreground">{texts.noHistory}</p>
) : (
<ul className="flex flex-col gap-12">
{history.map((line) => (
<li key={line.id} className="flex flex-col text-body-s">
<span>
{texts.change(
written(valueOfState(line.before)),
written(valueOfState(line.after)),
)}
</span>
<span className="text-label text-muted-foreground">
{actionLabelOf(line.action)} ·{' '}
{line.actor?.name ?? messages.actionLog.unknownActor} ·{' '}
{formatRelativeDate(new Date(line.createdAt))}
</span>
{line.reason ? (
<span className="text-label text-muted-foreground">{line.reason}</span>
) : null}
</li>
))}
</ul>
)}
</section>
</div>
);
}
Loading
Loading