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

/** A-06. The state of the list and the open account are read from the address, in the browser. */
export default function UsersPage() {
return (
<Suspense>
<UsersContainer />
</Suspense>
);
}
60 changes: 60 additions & 0 deletions apps/admin/src/components/users/UserActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { messages } from '@/messages/fr';
import type { UserAction } from '@widoo/shared';
import { Button } from '@widoo/ui';

const texts = messages.users.actions;

export type UserActionName = Exclude<UserAction, 'suspend'> | 'suspend' | 'reactivate';

type UserActionsProps = {
/** What the role of the member allows on this account (`allowedUserActions`). */
allowed: readonly UserAction[];
isSuspended: boolean;
onAct: (action: UserActionName) => void;
isBusy: boolean;
};

/** The actions of the card of an account (A-06): only the ones the role of the member allows. */
export function UserActions({ allowed, isSuspended, onAct, isBusy }: UserActionsProps) {
const has = (action: UserAction) => allowed.includes(action);
return (
<>
{has('suspend') ? (
isSuspended ? (
<Button onClick={() => onAct('reactivate')} disabled={isBusy}>
{texts.reactivate}
</Button>
) : (
<Button variant="destructiveOutline" onClick={() => onAct('suspend')} disabled={isBusy}>
{texts.suspend}
</Button>
)
) : null}
{has('role') ? (
<Button variant="secondary" onClick={() => onAct('role')} disabled={isBusy}>
{texts.role}
</Button>
) : null}
{has('gift') ? (
<Button variant="secondary" onClick={() => onAct('gift')} disabled={isBusy}>
{texts.gift}
</Button>
) : null}
{has('certification') ? (
<Button variant="secondary" onClick={() => onAct('certification')} disabled={isBusy}>
{texts.certification}
</Button>
) : null}
{has('export') ? (
<Button variant="outline" onClick={() => onAct('export')} disabled={isBusy}>
{texts.export}
</Button>
) : null}
{has('delete') ? (
<Button variant="destructiveOutline" onClick={() => onAct('delete')} disabled={isBusy}>
{texts.delete}
</Button>
) : null}
</>
);
}
55 changes: 55 additions & 0 deletions apps/admin/src/components/users/UserChoiceDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { messages } from '@/messages/fr';
import { ConfirmDialog, RadioGroup, type RadioOption } from '@widoo/ui';

type UserChoiceDialogProps = {
isOpen: boolean;
/** What the window says: its title, what the choice changes, and its button. */
dialog: { title: string; description: string; confirm: string };
/** Name of the radio group, and its question. */
name: string;
legend: string;
options: readonly RadioOption[];
value: string;
onChange: (value: string) => void;
onCancel: () => void;
onConfirm: () => void;
isBusy: boolean;
};

/** One choice among a few for an account (its role, the badge), confirmed in a window. */
export function UserChoiceDialog({
isOpen,
dialog,
name,
legend,
options,
value,
onChange,
onCancel,
onConfirm,
isBusy,
}: UserChoiceDialogProps) {
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}
>
<RadioGroup
name={name}
legend={legend}
isRequired
options={options}
value={value}
onChange={onChange}
/>
</ConfirmDialog>
);
}
87 changes: 87 additions & 0 deletions apps/admin/src/components/users/UserGiftDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { messages } from '@/messages/fr';
import { ConfirmDialog, Field, Input, RadioGroup } from '@widoo/ui';

const texts = messages.users.dialogs.gift;

export type GiftKind = 'until' | 'route';

type UserGiftDialogProps = {
isOpen: boolean;
userName: string;
kind: GiftKind;
onKindChange: (kind: GiftKind) => void;
/** Last day of the Premium plan, `AAAA-MM-JJ`. */
until: string;
onUntilChange: (until: string) => void;
/** First day that may be chosen: tomorrow. */
minUntil: string;
routeId: string;
onRouteIdChange: (routeId: string) => void;
onCancel: () => void;
onConfirm: () => void;
isBusy: boolean;
};

/** « Offrir Premium »: until a date, or one Premium route, never both. */
export function UserGiftDialog({
isOpen,
userName,
kind,
onKindChange,
until,
onUntilChange,
minUntil,
routeId,
onRouteIdChange,
onCancel,
onConfirm,
isBusy,
}: UserGiftDialogProps) {
return (
<ConfirmDialog
isOpen={isOpen}
onCancel={onCancel}
onConfirm={onConfirm}
title={texts.title}
description={texts.description(userName)}
cancelLabel={messages.confirm.cancel}
closeLabel={messages.confirm.close}
confirmLabel={texts.confirm}
confirmVariant="primary"
isBusy={isBusy}
>
<RadioGroup
name="gift-kind"
legend={texts.legend}
options={[
{ value: 'until', label: texts.untilOption, description: texts.untilHint },
{ value: 'route', label: texts.routeOption, description: texts.routeHint },
]}
value={kind}
onChange={(value) => onKindChange(value === 'route' ? 'route' : 'until')}
/>
{kind === 'until' ? (
<Field label={texts.until} htmlFor="gift-until" isRequired>
<Input
id="gift-until"
type="date"
required
min={minUntil}
value={until}
onChange={(event) => onUntilChange(event.target.value)}
/>
</Field>
) : (
<Field label={texts.route} htmlFor="gift-route" isRequired>
<Input
id="gift-route"
required
maxLength={36}
value={routeId}
onChange={(event) => onRouteIdChange(event.target.value.trim())}
/>
</Field>
)}
</ConfirmDialog>
);
}
126 changes: 126 additions & 0 deletions apps/admin/src/components/users/UserPanelContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge';
import { formatRelativeDate } from '@/lib/dates';
import { planLabelOf } from '@/lib/user-display';
import { messages } from '@/messages/fr';
import type { AdminUserDetail } from '@widoo/shared';
import Link from 'next/link';
import type { ReactNode } from 'react';
import { UserRoleBadge } from './UserRoleBadge';

const texts = messages.users.panel;

/** A line of the panel: what it is on the left, its value on the right. */
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 titled list of objects of the account, or « Aucun ». */
function Section({ title, children }: { title: string; children: ReactNode[] }) {
return (
<section className="flex flex-col gap-8">
<h3 className="text-label-strong">{title}</h3>
{children.length === 0 ? (
<p className="text-body-s text-muted-foreground">{texts.none}</p>
) : (
<ul className="flex flex-col gap-8">{children}</ul>
)}
</section>
);
}

type UserPanelContentProps = {
user: AdminUserDetail;
/** Address of the sheet of a route, to open it from the card. */
routePathOf: (routeId: string) => string;
};

/** The card of an account inside the detail panel (A-06): profile, plan, routes, reports. */
export function UserPanelContent({ user, routePathOf }: UserPanelContentProps) {
const routeLink = (route: { id: string; title: string }) => (
<Link href={routePathOf(route.id)} className="text-blue-ink underline">
{route.title}
</Link>
);
const reportItem = (report: AdminUserDetail['reportsMade'][number]) => (
<li key={report.id} className="flex flex-col text-body-s">
<span>{routeLink(report.route)}</span>
<span className="text-label text-muted-foreground">
{messages.reports.reasons[report.reason]} ·{' '}
{messages.reports.routes.statuses[report.status]} ·{' '}
{formatRelativeDate(new Date(report.createdAt))}
</span>
</li>
);
return (
<div className="flex flex-col gap-16">
<dl className="flex flex-col gap-8">
<Line label={texts.email}>{user.email ?? messages.users.noEmail}</Line>
<Line label={texts.id}>
<span className="text-label">{user.id}</span>
</Line>
<Line label={texts.role}>
<UserRoleBadge role={user.role} />
</Line>
<Line label={texts.plan}>{planLabelOf(user.plan)}</Line>
<Line label={texts.joined}>{formatRelativeDate(new Date(user.createdAt))}</Line>
<Line label={texts.profile}>
{user.isPublic && user.suspendedAt === null ? texts.profilePublic : texts.profileMasked}
</Line>
{user.suspensionReason ? (
<Line label={texts.suspension}>{user.suspensionReason}</Line>
) : null}
<Line label={texts.certification}>
<span className="flex flex-col items-end">
{user.certification.isCertified ? texts.certified : texts.notCertified}
<span className="text-label text-muted-foreground">
{texts.certificationDetail(
user.certification.computed,
texts.overrides[user.certification.override],
)}
</span>
</span>
</Line>
</dl>
<Section title={texts.unlocked}>
{user.unlockedRoutes.map((route) => (
<li key={route.id} className="text-body-s">
{routeLink(route)}
{route.source === 'gift' ? (
<span className="text-label text-muted-foreground"> · {texts.gift}</span>
) : null}
</li>
))}
</Section>
<Section title={texts.created}>
{user.createdRoutes.map((route) => (
<li key={route.id} className="flex items-center justify-between gap-8 text-body-s">
{routeLink(route)}
<RouteStatusBadge status={route.status} />
</li>
))}
</Section>
<Section title={texts.reportsMade}>{user.reportsMade.map(reportItem)}</Section>
<Section title={texts.reportsReceived}>{user.reportsReceived.map(reportItem)}</Section>
<Section title={texts.ratingsGiven}>{[]}</Section>
<Section title={texts.ratingsReceived}>{[]}</Section>
<p className="text-label text-muted-foreground">{texts.ratingsNotice}</p>
<Section title={texts.support}>
{user.supportMessages.map((message) => (
<li key={message.id} className="text-body-s">
{messages.users.topics[message.topic]} ·{' '}
{formatRelativeDate(new Date(message.createdAt))}
<span className="text-label text-muted-foreground">
{' '}
· {message.handledAt ? texts.handled : texts.unhandled}
</span>
</li>
))}
</Section>
</div>
);
}
55 changes: 55 additions & 0 deletions apps/admin/src/components/users/UserReasonDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { messages } from '@/messages/fr';
import { ConfirmDialog, Field, Textarea } from '@widoo/ui';

const texts = messages.users.dialogs;

type UserReasonDialogProps = {
isOpen: boolean;
/** What the window says: its question, the effect on the account, and its button. */
dialog: { title: string; description: string; confirm: string };
/** Red for a suspension, which refuses an account; blue for lifting it. */
isDestructive: boolean;
reason: string;
onReasonChange: (reason: string) => void;
onCancel: () => void;
onConfirm: () => void;
isBusy: boolean;
};

/** Suspension or reactivation of an account, with the required reason kept in the journal. */
export function UserReasonDialog({
isOpen,
dialog,
isDestructive,
reason,
onReasonChange,
onCancel,
onConfirm,
isBusy,
}: UserReasonDialogProps) {
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={isDestructive ? 'destructive' : 'primary'}
isBusy={isBusy}
>
<Field label={texts.reason} htmlFor="user-reason" isRequired hint={texts.reasonHint}>
<Textarea
id="user-reason"
required
maxLength={500}
aria-describedby="user-reason-hint"
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
/>
</Field>
</ConfirmDialog>
);
}
Loading
Loading