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
15 changes: 15 additions & 0 deletions apps/admin/src/app/(shell)/signalements/avis/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ReportsTabs } from '@/components/reports/ReportsTabs';
import { ReportRatingsContainer } from '@/containers/ReportRatingsContainer';
import { Suspense } from 'react';

/** A-03, the ratings with a comment. */
export default function ReportRatingsPage() {
return (
<div className="flex flex-col gap-16">
<ReportsTabs current="ratings" />
<Suspense>
<ReportRatingsContainer />
</Suspense>
</div>
);
}
15 changes: 15 additions & 0 deletions apps/admin/src/app/(shell)/signalements/lieux/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ReportsTabs } from '@/components/reports/ReportsTabs';
import { ReportPlacesContainer } from '@/containers/ReportPlacesContainer';
import { Suspense } from 'react';

/** A-03, the places the community flagged. */
export default function ReportPlacesPage() {
return (
<div className="flex flex-col gap-16">
<ReportsTabs current="places" />
<Suspense>
<ReportPlacesContainer />
</Suspense>
</div>
);
}
7 changes: 7 additions & 0 deletions apps/admin/src/app/(shell)/signalements/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { reportsPaths } from '@/components/reports/ReportsTabs';
import { redirect } from 'next/navigation';

/** A-03 opens on the reports of routes, where what waits for the team is counted. */
export default function ReportsPage() {
redirect(reportsPaths.routes);
}
15 changes: 15 additions & 0 deletions apps/admin/src/app/(shell)/signalements/parcours/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ReportsTabs } from '@/components/reports/ReportsTabs';
import { ReportRoutesContainer } from '@/containers/ReportRoutesContainer';
import { Suspense } from 'react';

/** A-03, the open reports of routes. The state of the list is read from the address. */
export default function ReportRoutesPage() {
return (
<div className="flex flex-col gap-16">
<ReportsTabs current="routes" />
<Suspense>
<ReportRoutesContainer />
</Suspense>
</div>
);
}
28 changes: 9 additions & 19 deletions apps/admin/src/components/catalog/CatalogTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { SectionTabs } from '@/components/navigation/SectionTabs';
import { sectionPaths } from '@/config/navigation';
import { messages } from '@/messages/fr';
import { cn } from '@widoo/ui';
import Link from 'next/link';

const texts = messages.catalog.tabs;

Expand All @@ -14,22 +13,13 @@ export const catalogPaths = {
/** The two lists of the catalog (A-04), as tabs above them: places, then routes. */
export function CatalogTabs({ current }: { current: keyof typeof catalogPaths }) {
return (
<nav aria-label={texts.label} className="flex gap-4 border-b border-border">
{(['places', 'routes'] as const).map((tab) => (
<Link
key={tab}
href={catalogPaths[tab]}
aria-current={tab === current ? 'page' : undefined}
className={cn(
'border-b-2 px-12 py-10 text-control',
tab === current
? 'border-primary text-foreground'
: 'border-background text-muted-foreground hover:text-foreground',
)}
>
{texts[tab]}
</Link>
))}
</nav>
<SectionTabs
label={texts.label}
current={current}
tabs={[
{ id: 'places', label: texts.places, href: catalogPaths.places },
{ id: 'routes', label: texts.routes, href: catalogPaths.routes },
]}
/>
);
}
36 changes: 36 additions & 0 deletions apps/admin/src/components/navigation/SectionTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { cn } from '@widoo/ui';
import Link from 'next/link';

type SectionTabsProps<TabId extends string> = {
/** Name of the navigation for screen readers. */
label: string;
tabs: readonly { id: TabId; label: string; href: string }[];
current: TabId;
};

/** The lists of a section as tabs above them, each one its own address. */
export function SectionTabs<TabId extends string>({
label,
tabs,
current,
}: SectionTabsProps<TabId>) {
return (
<nav aria-label={label} className="flex gap-4 border-b border-border">
{tabs.map((tab) => (
<Link
key={tab.id}
href={tab.href}
aria-current={tab.id === current ? 'page' : undefined}
className={cn(
'border-b-2 px-12 py-10 text-control',
tab.id === current
? 'border-primary text-foreground'
: 'border-background text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
))}
</nav>
);
}
51 changes: 51 additions & 0 deletions apps/admin/src/components/reports/ClosePlaceDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { messages } from '@/messages/fr';
import { ConfirmDialog, Field, Textarea } from '@widoo/ui';

const texts = messages.reports.places.closeDialog;

type ClosePlaceDialogProps = {
isOpen: boolean;
/** Published routes that go through the place: they become « à corriger ». */
routeCount: number;
reason: string;
onReasonChange: (reason: string) => void;
onCancel: () => void;
onConfirm: () => void;
isBusy: boolean;
};

/** « Fermer le lieu »: the effect on its routes, and a required reason kept in the journal. */
export function ClosePlaceDialog({
isOpen,
routeCount,
reason,
onReasonChange,
onCancel,
onConfirm,
isBusy,
}: ClosePlaceDialogProps) {
return (
<ConfirmDialog
isOpen={isOpen}
onCancel={onCancel}
onConfirm={onConfirm}
title={texts.title}
description={texts.description(routeCount)}
cancelLabel={messages.confirm.cancel}
closeLabel={messages.confirm.close}
confirmLabel={texts.confirm}
isBusy={isBusy}
>
<Field label={texts.reason} htmlFor="close-place-reason" isRequired hint={texts.reasonHint}>
<Textarea
id="close-place-reason"
required
maxLength={500}
aria-describedby="close-place-reason-hint"
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
/>
</Field>
</ConfirmDialog>
);
}
66 changes: 66 additions & 0 deletions apps/admin/src/components/reports/RatingsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { StarIcon } from '@phosphor-icons/react/dist/ssr';
import { formatRelativeDate } from '@/lib/dates';
import { messages } from '@/messages/fr';
import type { AdminRating } from '@widoo/shared';
import { StatusBadge, Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';

const texts = messages.reports.ratings;
const columnCount = 5;

type RatingsTableProps = {
/** Rows of the page; ignored while `isLoading`. */
items: AdminRating[];
isLoading: boolean;
};

/** The ratings with a comment as a table (A-03): the note, the comment, the route and its author. */
export function RatingsTable({ items, isLoading }: RatingsTableProps) {
return (
<Table>
<thead>
<tr>
<TableHeaderCell>{texts.columns.rating}</TableHeaderCell>
<TableHeaderCell>{texts.columns.comment}</TableHeaderCell>
<TableHeaderCell>{texts.columns.route}</TableHeaderCell>
<TableHeaderCell>{texts.columns.author}</TableHeaderCell>
<TableHeaderCell className="text-right">{texts.columns.date}</TableHeaderCell>
</tr>
</thead>
<tbody>
{isLoading ? (
<TableSkeleton rows={8} columns={columnCount} label={messages.list.loading} />
) : (
items.map((rating) => (
<TableRow key={rating.id}>
<TableCell>
<span className="inline-flex items-center gap-4 text-control">
<StarIcon aria-hidden weight="fill" className="size-16" />
{rating.rating}
</span>
</TableCell>
<TableCell className="py-8">
<span className="flex flex-col items-start gap-4">
{rating.comment}
{rating.isHidden ? (
<StatusBadge tone="neutral">{texts.hidden}</StatusBadge>
) : null}
</span>
</TableCell>
<TableCell>{rating.route.title}</TableCell>
<TableCell>
{rating.author ? (
(rating.author.firstName ?? '')
) : (
<span className="text-muted-foreground">{texts.unknownAuthor}</span>
)}
</TableCell>
<TableCell className="text-right">
{formatRelativeDate(new Date(rating.createdAt))}
</TableCell>
</TableRow>
))
)}
</tbody>
</Table>
);
}
52 changes: 52 additions & 0 deletions apps/admin/src/components/reports/ReportActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { messages } from '@/messages/fr';
import { Button } from '@widoo/ui';
import Link from 'next/link';

const texts = messages.reports.routes;

export type ReportActionName = 'accept' | 'reject' | 'keep' | 'requestChanges' | 'unpublish';

type ReportActionsProps = {
/** A note-based report offers « Garder », « Demander des modifications » and « Dépublier ». */
isLowRating: boolean;
/** Address of the sheet of the route, to read it before deciding. */
routePath: string;
onAct: (action: ReportActionName) => void;
isBusy: boolean;
};

/**
* The decisions on a report (A-03). A report of a reader is accepted or rejected, with a note; a
* low rating, which has no reader to thank, is kept, sent back for changes, or taken offline.
*/
export function ReportActions({ isLowRating, routePath, onAct, isBusy }: ReportActionsProps) {
return (
<>
<Button asChild variant="outline">
<Link href={routePath}>{texts.panel.openRoute}</Link>
</Button>
{isLowRating ? (
<>
<Button onClick={() => onAct('keep')} disabled={isBusy}>
{texts.actions.keep}
</Button>
<Button variant="secondary" onClick={() => onAct('requestChanges')} disabled={isBusy}>
{texts.actions.requestChanges}
</Button>
<Button variant="destructiveOutline" onClick={() => onAct('unpublish')} disabled={isBusy}>
{texts.actions.unpublish}
</Button>
</>
) : (
<>
<Button onClick={() => onAct('accept')} disabled={isBusy}>
{texts.actions.accept}
</Button>
<Button variant="secondary" onClick={() => onAct('reject')} disabled={isBusy}>
{texts.actions.reject}
</Button>
</>
)}
</>
);
}
51 changes: 51 additions & 0 deletions apps/admin/src/components/reports/ReportNoteDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { messages } from '@/messages/fr';
import { ConfirmDialog, Field, Textarea } from '@widoo/ui';

const texts = messages.reports.routes.dialogs;

type ReportNoteDialogProps = {
isOpen: boolean;
/** What the window says: its question, the effect of the decision, and its button. */
dialog: { title: string; description: string; confirm: string };
note: string;
onNoteChange: (note: string) => void;
onCancel: () => void;
onConfirm: () => void;
isBusy: boolean;
};

/** A decision on a report with an optional note for the team, kept in the journal. */
export function ReportNoteDialog({
isOpen,
dialog,
note,
onNoteChange,
onCancel,
onConfirm,
isBusy,
}: ReportNoteDialogProps) {
return (
<ConfirmDialog
isOpen={isOpen}
onCancel={onCancel}
onConfirm={onConfirm}
title={dialog.title}
description={dialog.description}
cancelLabel={messages.confirm.cancel}
closeLabel={messages.confirm.close}
confirmLabel={dialog.confirm}
confirmVariant="primary"
isBusy={isBusy}
>
<Field label={texts.note} htmlFor="report-note" hint={texts.noteHint}>
<Textarea
id="report-note"
maxLength={500}
aria-describedby="report-note-hint"
value={note}
onChange={(event) => onNoteChange(event.target.value)}
/>
</Field>
</ConfirmDialog>
);
}
Loading
Loading