metis-desk/modules/companies/src/web/mail/mailboxes-page.tsx
David 6375fa1b32 feat(mailbox): implement mailbox and mailbox use management features
- Add mailbox persistence mapper for domain object mapping.
- Create Sequelize repository for mailbox data access.
- Define common mailbox schemas and types for validation.
- Implement mailbox API for frontend communication.
- Develop mailbox form component for creating and editing mailboxes.
- Add internationalization support for mailbox labels and messages.
- Create mailbox use form for managing mailbox usage periods.
- Build mailboxes page to display and manage mailboxes and their usage.
2026-10-05 10:45:02 +02:00

208 lines
7.6 KiB
TypeScript

import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { MailFeedback } from "./mail-feedback";
import { mailboxApi, mailboxKeys } from "./mailbox-api";
import { MailboxForm } from "./mailbox-form";
import { MailboxUseForm } from "./mailbox-use-form";
export function MailboxesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t, i18n } = useTranslation("companies");
const [editor, setEditor] = useState<string>();
const [useEditor, setUseEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
queryKey: mailboxKeys.company(company.id),
queryFn: () => mailboxApi.request(company.id),
});
const edited = query.data?.mailboxes.find((item) => item.id === editor);
const use = query.data?.uses.find((item) => item.id === useEditor);
const viewed = query.data?.mailboxes.find((item) => item.id === view);
const name = (id: string) => company.people.find((person) => person.id === id)?.name ?? id;
const boxes = query.data?.mailboxes ?? [];
const filtered = boxes.filter((item) =>
[item.values.address, item.values.context ?? ""].some((value) =>
value.toLowerCase().includes(search.trim().toLowerCase())
)
);
return (
<div className="space-y-5">
<header className="flex flex-wrap justify-between items-center gap-4">
<h2 className="text-xl font-semibold">{t("mailbox.title")}</h2>
<div className="flex gap-2">
<Button disabled={!query.data} onClick={() => setUseEditor("new")} variant="outline">
{t("mailbox.linkPerson")}
</Button>
<Button disabled={!query.data} onClick={() => setEditor("new")}>
{t("mailbox.new")}
</Button>
</div>
</header>
<Input
aria-label={t("mailbox.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("mailbox.search")}
value={search}
/>
{query.isPending && <p role="status">{t("loading")}</p>}
<MailFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(boxes.length ? "noResults" : "mailbox.empty")}
</p>
)}
{filtered.map((item) => (
<div className="py-4 space-y-3" key={item.id}>
<div className="flex flex-wrap justify-between gap-3">
<div>
<h3 className="font-medium">{item.values.address}</h3>
<p className="text-sm text-muted-foreground">
{t(`mailbox.${item.values.kind}`)} ·{" "}
{t(item.values.active ? "active" : "inactive")}
{item.values.context ? ` · ${item.values.context}` : ""}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("mail.history")}
</Button>
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
</div>
<ul className="space-y-2">
{query.data?.uses
.filter((use) => use.values.mailboxId === item.id)
.map((use) => (
<li className="flex justify-between gap-3 text-sm" key={use.id}>
<span>
{name(use.values.personId)} · {use.values.startDate} →{" "}
{use.values.endDate ?? t("mailbox.openPeriod")}
</span>
{!use.values.endDate && (
<Button onClick={() => setUseEditor(use.id)} size="sm" variant="outline">
{t("mailbox.closeUse")}
</Button>
)}
</li>
))}
</ul>
</div>
))}
</CardContent>
</Card>
)}
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "mailbox.edit" : "mailbox.new")}
wide
>
<MailboxForm
company={company}
csrfToken={csrfToken}
mailbox={edited}
onSaved={() => setEditor(undefined)}
/>
</FormDialog>
)}
{useEditor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setUseEditor(undefined);
}
}}
open
title={t(use ? "mailbox.closeUse" : "mailbox.linkPerson")}
wide
>
<MailboxUseForm
company={company}
csrfToken={csrfToken}
onSaved={() => setUseEditor(undefined)}
use={use}
/>
</FormDialog>
)}
{viewed && (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setView(undefined);
}
}}
open
title={t("mail.history")}
wide
>
<ol className="space-y-3">
{[...viewed.history].reverse().map((revision) => (
<li className="border rounded-md p-3 text-sm" key={revision.id}>
<p>
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>
{revision.values.address} · {t(`mailbox.${revision.values.kind}`)} ·{" "}
{t(revision.values.active ? "active" : "inactive")}
</p>
<p>{revision.values.context}</p>
<p>{revision.values.notes}</p>
</li>
))}
</ol>
{query.data?.uses
.filter((use) => use.values.mailboxId === viewed.id)
.map((use) => (
<div className="mt-4 rounded-md border p-3 text-sm" key={use.id}>
<h3 className="font-medium">{name(use.values.personId)}</h3>
{use.history.map((revision) => (
<p key={revision.id}>
{revision.values.startDate} →{" "}
{revision.values.endDate ?? t("mailbox.openPeriod")} · {revision.actorName} ·{" "}
{revision.recordedAt}
</p>
))}
</div>
))}
</FormDialog>
)}
</div>
);
}