Refactorización

This commit is contained in:
David Arranz 2026-10-08 14:14:01 +02:00
parent 78c05eae6d
commit 7f336df146
125 changed files with 3802 additions and 3306 deletions

View File

@ -9,6 +9,8 @@ Antes de modificar código: comprobar pwd, git status --short, node --version y
Usar Node.js 24.x y la versión de PNPM fijada en package.json.
No imprimir, copiar ni versionar .env locales o secretos.
`packages/shadcn-ui` contiene código de terceros/vendor. No refactorizar, reformatear, reorganizar ni modificar su implementación salvo instrucción explícita del usuario. Puede consultarse su API pública para integrarlo desde código propio.
## Convenciones
- Código e identificadores en inglés; comentarios, TSDoc y documentación en castellano.

View File

@ -44,3 +44,48 @@ test("HTTP real: estado, contrato, errores y trazas sin datos sensibles", async
});
}
});
test("HTTP real: entrada JSON inválida y errores internos conservan un contrato seguro", async () => {
const entries: HttpLogEntry[] = [];
const server = createApp((entry) => entries.push(entry), {
middleware: (_request, _response, next) => next(),
register: (app) => {
app.get("/api/failure", () => {
throw new Error("secret-test-value: internal SQL and credentials");
});
},
}).listen(0, "127.0.0.1");
try {
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
const baseUrl = `http://127.0.0.1:${address.port}`;
for (const [body, contentType, status] of [
['{"token":"secret-test-value",', "application/json", 400],
[JSON.stringify({ value: "x".repeat(64 * 1024) }), "application/json", 413],
["{}", "application/json; charset=unsupported", 415],
] as const) {
const response = await fetch(`${baseUrl}/api/__health`, {
method: "POST",
headers: { "content-type": contentType },
body,
});
assert.equal(response.status, status);
const error = HttpErrorResponseSchema.parse(await response.json());
assert.equal(error.code, "INVALID_REQUEST");
assert.equal(error.requestId, response.headers.get("x-request-id"));
}
const response = await fetch(`${baseUrl}/api/failure`);
assert.equal(response.status, 500);
const error = HttpErrorResponseSchema.parse(await response.json());
assert.equal(error.code, "INTERNAL_ERROR");
assert.equal(error.requestId, response.headers.get("x-request-id"));
assert.equal(entries.length, 4);
assert.equal(JSON.stringify(entries).includes("secret-test-value"), false);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
}
});

View File

@ -47,17 +47,38 @@ export function createApp(
};
response.status(404).json(error);
});
const errorHandler: ErrorRequestHandler = (_error: unknown, _request, response, next) => {
const errorHandler: ErrorRequestHandler = (failure: unknown, _request, response, next) => {
if (response.headersSent) {
next(_error);
next(failure);
return;
}
const status = bodyFailureStatus(failure);
const error: HttpErrorResponseDTO = {
code: "INTERNAL_ERROR",
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
requestId: response.getHeader("x-request-id")?.toString() ?? "",
};
response.status(500).json(error);
response.status(status).json(error);
};
app.use(errorHandler);
return app;
}
function bodyFailureStatus(failure: unknown): number {
if (!(failure instanceof Error && "type" in failure)) {
return 500;
}
// Solo se reconocen errores públicos del parser, nunca mensajes o status arbitrarios.
switch (failure.type) {
case "entity.parse.failed":
case "request.aborted":
case "request.size.invalid":
return 400;
case "entity.too.large":
return 413;
case "charset.unsupported":
case "encoding.unsupported":
return 415;
default:
return 500;
}
}

View File

@ -25,7 +25,8 @@ export function createDatabase(environment: ReturnType<typeof readDatabaseEnviro
dialect: "mariadb",
host: environment.DB_HOST,
port: environment.DB_PORT,
logging: true,
// El SQL puede contener credenciales, cookies y datos de clientes incluso en pruebas.
logging: false,
pool: { max: 5, min: 0, idle: 10000 },
});
}

View File

@ -20,9 +20,18 @@ async function start(): Promise<void> {
console.error(JSON.stringify({ event: "server_start_failed" }));
process.exitCode = 1;
});
let stopping = false;
const shutdown = () => {
if (stopping) {
return;
}
stopping = true;
identity.stop();
server.close(() => {
server.close((error) => {
if (error) {
console.error(JSON.stringify({ event: "server_close_failed" }));
process.exitCode = 1;
}
database.close().catch(() => {
console.error(JSON.stringify({ event: "database_close_failed" }));
process.exitCode = 1;
@ -32,7 +41,10 @@ async function start(): Promise<void> {
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
} catch (error) {
await database.close();
// El fallo del cleanup no debe sustituir la causa original del arranque.
await database.close().catch(() => {
console.error(JSON.stringify({ event: "database_close_failed" }));
});
throw error;
}
}

View File

@ -1,66 +1,8 @@
import { CompaniesWorkspace } from "@metis/companies/client";
import { IdentityWorkspace, identityApi, identityKeys } from "@metis/identity/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components/select";
import { useQuery } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
function LanguageSwitcher() {
const { t, i18n } = useTranslation("core");
const languageOptions = ["es", "en"].map((value) => ({
value,
label: t(`languageNames.${value}`),
}));
const [languageError, setLanguageError] = useState(false);
const languageLabelId = useId();
return (
<div className="desk-language">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span id={languageLabelId}>{t("language")}</span>
<Select
items={languageOptions}
onValueChange={(language) => {
if (language !== "es" && language !== "en") {
return;
}
setLanguageError(false);
i18n
.changeLanguage(language)
.then(() => {
document.documentElement.lang = i18n.resolvedLanguage ?? "es";
})
.catch(() => setLanguageError(true));
}}
value={i18n.resolvedLanguage ?? "es"}
>
<SelectTrigger aria-labelledby={languageLabelId} className="min-w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languageOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{languageError && (
<p className="text-destructive" role="alert">
{t("languageError")}
</p>
)}
</div>
);
}
import { LanguageSwitcher } from "./language-switcher";
export function App() {
const session = useQuery({

View File

@ -0,0 +1,60 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components/select";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
export function LanguageSwitcher() {
const { t, i18n } = useTranslation("core");
const languageOptions = ["es", "en"].map((value) => ({
value,
label: t(`languageNames.${value}`),
}));
const [languageError, setLanguageError] = useState(false);
const languageLabelId = useId();
return (
<div className="desk-language">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span id={languageLabelId}>{t("language")}</span>
<Select
items={languageOptions}
onValueChange={(language) => {
if (language !== "es" && language !== "en") {
return;
}
setLanguageError(false);
i18n
.changeLanguage(language)
.then(() => {
document.documentElement.lang = i18n.resolvedLanguage ?? "es";
})
.catch(() => setLanguageError(true));
}}
value={i18n.resolvedLanguage ?? "es"}
>
<SelectTrigger aria-labelledby={languageLabelId} className="min-w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languageOptions.map((language) => (
<SelectItem key={language.value} value={language.value}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{languageError && (
<p className="text-destructive" role="alert">
{t("languageError")}
</p>
)}
</div>
);
}

View File

@ -18,21 +18,16 @@ import { AssetInputSchema, AssetStatusSchema } from "../common/inventory";
import { AssetCharacteristicControl } from "./asset-characteristic-control";
import { BrandSelect } from "./brand-select";
import { CatalogTemplateForm } from "./catalog-template-form";
import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { InventoryFeedback } from "./inventory-feedback";
export type InventoryLookups = {
sites: readonly { id: string; name: string }[];
people: readonly { id: string; name: string; active: boolean }[];
onCreateSite: (onCreated: (id: string) => void) => void;
onCreatePerson: (onCreated: (id: string) => void) => void;
};
export function InventoryFeedback({ error }: { error: Error | null }) {
const { t } = useTranslation("assets");
return error ? (
<p className="text-sm text-destructive" role="alert">
{t(`inventoryErrors.${error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR"}`)}
</p>
) : null;
}
export function AssetForm({
companyId,
csrfToken,

View File

@ -1,19 +1,14 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { RelationshipDTO, RelationshipInputDTO } from "../common";
import type { RelationshipDTO } from "../common";
import { InventoryFeedback } from "./asset-form";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogRelationForm } from "./catalog-relation-form";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { InventoryFeedback } from "./inventory-feedback";
import { RelationshipForm } from "./relationship-form";
export function AssetRelationships({
companyId,
@ -148,121 +143,3 @@ export function AssetRelationships({
</section>
);
}
function RelationshipForm({
companyId,
csrfToken,
assetId,
onSaved,
}: {
companyId: string;
csrfToken: string;
assetId: string;
onSaved: () => void;
}) {
const { t } = useTranslation("assets");
const id = useId();
const client = useQueryClient();
const [typeId, setTypeId] = useState("");
const [targetId, setTargetId] = useState("");
const [notes, setNotes] = useState("");
const [quick, setQuick] = useState(false);
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const mutation = useMutation({
mutationFn: (values: RelationshipInputDTO) => inventoryApi.link(companyId, values, csrfToken),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
onSaved();
},
});
return (
<>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate({ sourceId: assetId, targetId, typeId, notes });
}}
>
<div className="space-y-2">
<Label htmlFor={`${id}-type`}>{t("relationshipType")}</Label>
<SelectWithAction
action={{ label: t("newRelation"), onClick: () => setQuick(true) }}
disabled={mutation.isPending || catalog.isPending}
id={`${id}-type`}
label={t("relationshipType")}
onValueChange={setTypeId}
options={(catalog.data?.items ?? [])
.filter((item) => item.values.kind === "relation" && item.effectiveActive)
.map((item) => ({ value: item.id, label: item.values.name }))}
placeholder={t("chooseRelation")}
required
value={typeId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-target`}>{t("targetAsset")}</Label>
<SelectWithAction
disabled={mutation.isPending || inventory.isPending}
id={`${id}-target`}
label={t("targetAsset")}
onValueChange={setTargetId}
options={(inventory.data?.assets ?? [])
.filter((asset) => asset.id !== assetId)
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("chooseAsset")}
required
value={targetId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-notes`}>{t("notes")}</Label>
<Textarea
id={`${id}-notes`}
maxLength={10000}
onChange={(event) => setNotes(event.target.value)}
value={notes}
/>
</div>
<InventoryFeedback error={mutation.error ?? catalog.error ?? inventory.error} />
<ContextHelp
description={t("relationshipHelp")}
examples={t("relationshipExample")}
examplesLabel={t("examplesLabel")}
guidance={t("relationshipGuidance")}
title={t("helpTitle")}
/>
<FormActions>
<Button disabled={!(typeId && targetId) || mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setQuick}
open
title={t("newRelation")}
>
<p className="text-sm text-muted-foreground">{t("quickRelationNotice")}</p>
<CatalogRelationForm
csrfToken={csrfToken}
onSaved={(savedId) => {
setTypeId(savedId);
setQuick(false);
}}
scope={companyId}
/>
</FormDialog>
)}
</>
);
}

View File

@ -0,0 +1,63 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../common/inventory";
import { AssetDetail } from "./asset-detail";
import type { InventoryLookups } from "./asset-form";
import { AssetRelationships } from "./asset-relationships";
import { AssetSupport } from "./asset-support";
export function AssetSelectionPanel({
companyId,
csrfToken,
asset,
open,
lookups,
canEdit,
onClose,
onView,
onEdit,
}: {
companyId: string;
csrfToken: string;
asset: AssetDTO | undefined;
open: boolean;
lookups: InventoryLookups;
canEdit: boolean;
onClose: () => void;
onView: (id: string) => void;
onEdit: (id: string) => void;
}) {
const { t } = useTranslation("assets");
if (!(asset && open)) {
return null;
}
return (
<aside aria-label={t("assetPanel")} className="asset-side-panel">
<header className="flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-lg font-semibold">{asset.values.name}</h2>
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="sm" variant="ghost">
×
</Button>
</header>
<AssetDetail asset={asset} compact lookups={lookups}>
<AssetRelationships assetId={asset.id} companyId={companyId} csrfToken={csrfToken} />
<AssetSupport
assetId={asset.id}
companyId={companyId}
csrfToken={csrfToken}
retired={asset.values.status === "retired"}
/>
</AssetDetail>
<div className="flex flex-wrap gap-2">
<Button onClick={() => onView(asset.id)} variant="outline">
{t("viewAsset")}
</Button>
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} variant="outline">
{t("edit")}
</Button>
</div>
</aside>
);
}

View File

@ -4,11 +4,14 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { SupportConnectionDTO, SupportToolDTO } from "../common/support";
import type { SupportConnectionDTO } from "../common/support";
import { supportApi, supportKeys } from "./support-api";
import { SupportForm } from "./support-form";
import { SupportHistory } from "./support-history";
import { SupportSummary } from "./support-summary";
import { SupportTools } from "./support-tools";
export function AssetSupport({
companyId,
assetId,
@ -148,51 +151,3 @@ export function AssetSupport({
</section>
);
}
export function SupportSummary({
values,
tools,
}: {
values: SupportConnectionDTO["values"];
tools: readonly SupportToolDTO[];
}) {
const { t } = useTranslation("assets");
return (
<div className="space-y-1 text-sm">
<p className="font-medium">
{tools.find((tool) => tool.id === values.toolId)?.name ?? values.toolId} ·{" "}
{t(`support.${values.state}`)}
{values.preferred ? ` · ${t("support.preferred")}` : ""}
</p>
{values.destination && <p className="break-words">{values.destination}</p>}
<p>
{t("support.requiresVpn")}: {t(values.requiresVpn ? "yes" : "no")}
</p>
{values.notes && <p className="whitespace-pre-wrap text-muted-foreground">{values.notes}</p>}
</div>
);
}
export function SupportHistory({
connection,
tools,
}: {
connection: SupportConnectionDTO;
tools: readonly SupportToolDTO[];
}) {
const { i18n } = useTranslation("assets");
return (
<ol className="space-y-3">
{[...connection.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3" key={revision.id}>
<p className="text-sm">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<SupportSummary tools={tools} values={revision.values} />
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,75 @@
import { EntityRowActions, EntityStatus } from "@repo/shadcn-ui/components/entity-list";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
import { CatalogDetails } from "./catalog-details";
import { CatalogName } from "./catalog-name";
import { CatalogRowActions } from "./catalog-row-actions";
export function CatalogCell({
column,
item,
scope,
items,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
column: "name" | "origin" | "details" | "state" | "actions";
item: CatalogItemDTO;
scope: string;
items: readonly CatalogItemDTO[];
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("assets");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
switch (column) {
case "name":
return <CatalogName item={item} items={items} onView={onView} />;
case "origin":
return (
<>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</>
);
case "details":
return <CatalogDetails item={item} />;
case "state":
return (
<>
<EntityStatus active={item.effectiveActive}>
{t(item.effectiveActive ? "active" : "inactive")}
</EntityStatus>
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</>
);
case "actions":
return (
<>
<EntityRowActions label={t("actions")}>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</EntityRowActions>
</>
);
}
}

View File

@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
export function CatalogDetails({ item }: { item: CatalogItemDTO }) {
const { t } = useTranslation("assets");
const values = item.values;
if (values.kind === "field") {
return t(values.type);
}
if (values.kind === "relation") {
return values.inverseName;
}
return t("fieldCount", { count: values.fields.length });
}

View File

@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import { CatalogApiError } from "./catalog-api";
export function CatalogError({
error,
invalid = false,
}: {
error?: Error | null | undefined;
invalid?: boolean;
}) {
const { t } = useTranslation("assets");
if (!(error || invalid)) {
return null;
}
return (
<p className="text-sm text-destructive" role="alert">
{t(
`errors.${invalid ? "INVALID_REQUEST" : error instanceof CatalogApiError ? error.code : "INTERNAL_ERROR"}`
)}
</p>
);
}

View File

@ -5,67 +5,25 @@ import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef } from "react";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
import type { AssetDefinitionSpec, CatalogItemDTO, SaveDefinitionResponseDTO } from "../common";
import type { CatalogItemDTO } from "../common";
import { AssetDefinitionSchema, FieldTypeSchema } from "../common";
import { CatalogApiError, catalogApi, catalogKeys } from "./catalog-api";
import { CatalogError } from "./catalog-error";
import { CatalogHelp } from "./catalog-help";
import type { CatalogSaved } from "./use-catalog-save";
import { useCatalogSave } from "./use-catalog-save";
export type CatalogSaved = (id: string, data: SaveDefinitionResponseDTO) => void;
export function useCatalogSave(
scope: string,
csrfToken: string,
onSaved: CatalogSaved,
id?: string
) {
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
return useMutation({
mutationFn: (values: AssetDefinitionSpec) => catalogApi.save(scope, values, csrfToken, id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["asset-catalog"] });
client.setQueryData(catalogKeys.scope(scope), { items: response.items });
if (mounted.current) {
onSaved(response.savedId, response);
}
},
});
}
export function CatalogError({
error,
invalid = false,
}: {
error?: Error | null | undefined;
invalid?: boolean;
}) {
const { t } = useTranslation("assets");
if (!(error || invalid)) {
return null;
}
return (
<p className="text-sm text-destructive" role="alert">
{t(
`errors.${invalid ? "INVALID_REQUEST" : error instanceof CatalogApiError ? error.code : "INTERNAL_ERROR"}`
)}
</p>
);
}
const FieldFormSchema = AssetDefinitionSchema.options[0].omit({ choices: true }).extend({
choicesText: z.string().max(10000),
});
type FieldFormValues = z.infer<typeof FieldFormSchema>;
export function CatalogFieldForm({
scope,
csrfToken,

View File

@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
export function CatalogName({
item,
items,
onView,
}: {
item: CatalogItemDTO;
items: readonly CatalogItemDTO[];
onView: () => void;
}) {
const { t } = useTranslation("assets");
const values = item.values;
const inactiveFields =
values.kind === "template" &&
values.fields.some(
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
);
return (
<>
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={onView}
type="button"
>
{values.name}
</button>
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</>
);
}

View File

@ -1,21 +1,17 @@
import { Button } from "@repo/shadcn-ui/components/button";
import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityStatus,
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogCell } from "./catalog-cell";
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
import { CatalogError, CatalogFieldForm } from "./catalog-form";
import { CatalogError } from "./catalog-error";
import { CatalogFieldForm } from "./catalog-form";
import { CatalogRelationForm } from "./catalog-relation-form";
import { CatalogTemplateForm } from "./catalog-template-form";
@ -24,13 +20,13 @@ type Editor = {
item?: CatalogItemDTO;
copy?: CatalogItemDTO;
};
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
const { t, i18n } = useTranslation("assets");
const _searchId = useId();
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
const [search, setSearch] = useState("");
const [view, setView] = useState<CatalogItemDTO>();
const [viewId, setViewId] = useState<string>();
const [editor, setEditor] = useState<Editor>();
const query = useQuery({
queryKey: catalogKeys.scope(scope),
@ -45,6 +41,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
},
});
const items = query.data?.items ?? [];
const view = items.find((item) => item.id === viewId);
const visible = items.filter(
(item) =>
item.values.kind === tab &&
@ -107,7 +104,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
onView={() => setViewId(item.id)}
pending={mutation.isPending}
scope={scope}
/>
@ -125,7 +122,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
onView={() => setViewId(item.id)}
pending={mutation.isPending}
scope={scope}
/>
@ -142,7 +139,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
onView={() => setViewId(item.id)}
pending={mutation.isPending}
scope={scope}
/>
@ -160,7 +157,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
onView={() => setViewId(item.id)}
pending={mutation.isPending}
scope={scope}
/>
@ -178,7 +175,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
onView={() => setViewId(item.id)}
pending={mutation.isPending}
scope={scope}
/>
@ -197,7 +194,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
title={t(scope === "global" ? "title" : "companyTitle")}
unavailable={!query.data && query.isError}
/>
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
<CatalogDefinitionDialog item={view} items={items} onClose={() => setViewId(undefined)} />
{editor && (
<FormDialog
cancelLabel={t("cancel")}
@ -240,73 +237,6 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
</div>
);
}
function CatalogCell({
column,
item,
scope,
items,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
column: "name" | "origin" | "details" | "state" | "actions";
item: CatalogItemDTO;
scope: string;
items: readonly CatalogItemDTO[];
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("assets");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
const _values = item.values;
switch (column) {
case "name":
return <CatalogName item={item} items={items} onView={onView} />;
case "origin":
return (
<>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</>
);
case "details":
return <CatalogDetails item={item} />;
case "state":
return (
<>
<EntityStatus active={item.effectiveActive}>
{t(item.effectiveActive ? "active" : "inactive")}
</EntityStatus>
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</>
);
case "actions":
return (
<>
<EntityRowActions label={t("actions")}>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</EntityRowActions>
</>
);
}
}
function editorTitle(editor: Editor) {
if (editor.kind === "field") {
@ -317,83 +247,3 @@ function editorTitle(editor: Editor) {
}
return editor.item ? "editTemplate" : "newTemplate";
}
function CatalogRowActions({
item,
editable,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
editable: boolean;
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("assets");
return (
<div className="contents">
<EntityAction onClick={onView}>{t("view")}</EntityAction>
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>
{t(item.effectiveActive ? "deactivate" : "activate")}
</EntityAction>
{item.overridden && (
<EntityAction disabled={pending} onClick={() => onStatus(null)}>
{t("reset")}
</EntityAction>
)}
</div>
);
}
function CatalogName({
item,
items,
onView,
}: {
item: CatalogItemDTO;
items: readonly CatalogItemDTO[];
onView: () => void;
}) {
const { t } = useTranslation("assets");
const values = item.values;
const inactiveFields =
values.kind === "template" &&
values.fields.some(
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
);
return (
<>
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={onView}
type="button"
>
{values.name}
</button>
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</>
);
}
function CatalogDetails({ item }: { item: CatalogItemDTO }) {
const { t } = useTranslation("assets");
const values = item.values;
if (values.kind === "field") {
return t(values.type);
}
if (values.kind === "relation") {
return values.inverseName;
}
return t("fieldCount", { count: values.fields.length });
}

View File

@ -11,8 +11,10 @@ import type { z } from "zod/v4";
import { AssetDefinitionSchema, type CatalogItemDTO } from "../common";
import { CatalogError, type CatalogSaved, useCatalogSave } from "./catalog-form";
import { CatalogError } from "./catalog-error";
import { CatalogHelp } from "./catalog-help";
import type { CatalogSaved } from "./use-catalog-save";
import { useCatalogSave } from "./use-catalog-save";
const schema = AssetDefinitionSchema.options[2];
export function CatalogRelationForm({

View File

@ -0,0 +1,39 @@
import { EntityAction } from "@repo/shadcn-ui/components/entity-list";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
export function CatalogRowActions({
item,
editable,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
editable: boolean;
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("assets");
return (
<div className="contents">
<EntityAction onClick={onView}>{t("view")}</EntityAction>
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>
{t(item.effectiveActive ? "deactivate" : "activate")}
</EntityAction>
{item.overridden && (
<EntityAction disabled={pending} onClick={() => onStatus(null)}>
{t("reset")}
</EntityAction>
)}
</div>
);
}

View File

@ -14,9 +14,11 @@ import type { z } from "zod/v4";
import type { CatalogItemDTO } from "../common";
import { AssetDefinitionSchema } from "../common";
import type { CatalogSaved } from "./catalog-form";
import { CatalogError, CatalogFieldForm, useCatalogSave } from "./catalog-form";
import { CatalogError } from "./catalog-error";
import { CatalogFieldForm } from "./catalog-form";
import { CatalogHelp } from "./catalog-help";
import type { CatalogSaved } from "./use-catalog-save";
import { useCatalogSave } from "./use-catalog-save";
const TemplateFormSchema = AssetDefinitionSchema.options[1];
type TemplateFormValues = z.infer<typeof TemplateFormSchema>;

View File

@ -0,0 +1,12 @@
import { useTranslation } from "react-i18next";
import { InventoryApiError } from "./inventory-api";
export function InventoryFeedback({ error }: { error: Error | null }) {
const { t } = useTranslation("assets");
return error ? (
<p className="text-sm text-destructive" role="alert">
{t(`inventoryErrors.${error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR"}`)}
</p>
) : null;
}

View File

@ -11,16 +11,17 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../common/inventory";
import { AssetDetail } from "./asset-detail";
import type { InventoryLookups } from "./asset-form";
import { AssetForm, InventoryFeedback } from "./asset-form";
import { AssetForm } from "./asset-form";
import { AssetRelationships } from "./asset-relationships";
import { AssetSelectionPanel } from "./asset-selection-panel";
import { AssetSupport } from "./asset-support";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogError } from "./catalog-form";
import { CatalogError } from "./catalog-error";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { InventoryFeedback } from "./inventory-feedback";
export function AssetInventoryPage({
companyId,
csrfToken,
@ -271,57 +272,3 @@ export function AssetInventoryPage({
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
return id === selectedId && detailOpen ? "inventory-asset-selected" : "";
}
function AssetSelectionPanel({
companyId,
csrfToken,
asset,
open,
lookups,
canEdit,
onClose,
onView,
onEdit,
}: {
companyId: string;
csrfToken: string;
asset: AssetDTO | undefined;
open: boolean;
lookups: InventoryLookups;
canEdit: boolean;
onClose: () => void;
onView: (id: string) => void;
onEdit: (id: string) => void;
}) {
const { t } = useTranslation("assets");
if (!(asset && open)) {
return null;
}
return (
<aside aria-label={t("assetPanel")} className="asset-side-panel">
<header className="flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-lg font-semibold">{asset.values.name}</h2>
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="sm" variant="ghost">
×
</Button>
</header>
<AssetDetail asset={asset} compact lookups={lookups}>
<AssetRelationships assetId={asset.id} companyId={companyId} csrfToken={csrfToken} />
<AssetSupport
assetId={asset.id}
companyId={companyId}
csrfToken={csrfToken}
retired={asset.values.status === "retired"}
/>
</AssetDetail>
<div className="flex flex-wrap gap-2">
<Button onClick={() => onView(asset.id)} variant="outline">
{t("viewAsset")}
</Button>
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} variant="outline">
{t("edit")}
</Button>
</div>
</aside>
);
}

View File

@ -0,0 +1,135 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { RelationshipInputDTO } from "../common";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogRelationForm } from "./catalog-relation-form";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { InventoryFeedback } from "./inventory-feedback";
export function RelationshipForm({
companyId,
csrfToken,
assetId,
onSaved,
}: {
companyId: string;
csrfToken: string;
assetId: string;
onSaved: () => void;
}) {
const { t } = useTranslation("assets");
const id = useId();
const client = useQueryClient();
const [typeId, setTypeId] = useState("");
const [targetId, setTargetId] = useState("");
const [notes, setNotes] = useState("");
const [quick, setQuick] = useState(false);
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const mutation = useMutation({
mutationFn: (values: RelationshipInputDTO) => inventoryApi.link(companyId, values, csrfToken),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
onSaved();
},
});
return (
<>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate({ sourceId: assetId, targetId, typeId, notes });
}}
>
<div className="space-y-2">
<Label htmlFor={`${id}-type`}>{t("relationshipType")}</Label>
<SelectWithAction
action={{ label: t("newRelation"), onClick: () => setQuick(true) }}
disabled={mutation.isPending || catalog.isPending}
id={`${id}-type`}
label={t("relationshipType")}
onValueChange={setTypeId}
options={(catalog.data?.items ?? [])
.filter((item) => item.values.kind === "relation" && item.effectiveActive)
.map((item) => ({ value: item.id, label: item.values.name }))}
placeholder={t("chooseRelation")}
required
value={typeId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-target`}>{t("targetAsset")}</Label>
<SelectWithAction
disabled={mutation.isPending || inventory.isPending}
id={`${id}-target`}
label={t("targetAsset")}
onValueChange={setTargetId}
options={(inventory.data?.assets ?? [])
.filter((asset) => asset.id !== assetId)
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("chooseAsset")}
required
value={targetId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-notes`}>{t("notes")}</Label>
<Textarea
id={`${id}-notes`}
maxLength={10000}
onChange={(event) => setNotes(event.target.value)}
value={notes}
/>
</div>
<InventoryFeedback error={mutation.error ?? catalog.error ?? inventory.error} />
<ContextHelp
description={t("relationshipHelp")}
examples={t("relationshipExample")}
examplesLabel={t("examplesLabel")}
guidance={t("relationshipGuidance")}
title={t("helpTitle")}
/>
<FormActions>
<Button disabled={!(typeId && targetId) || mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setQuick}
open
title={t("newRelation")}
>
<p className="text-sm text-muted-foreground">{t("quickRelationNotice")}</p>
<CatalogRelationForm
csrfToken={csrfToken}
onSaved={(savedId) => {
setTypeId(savedId);
setQuick(false);
}}
scope={companyId}
/>
</FormDialog>
)}
</>
);
}

View File

@ -0,0 +1,17 @@
import { useTranslation } from "react-i18next";
import { SupportApiError } from "./support-api";
export function SupportFeedback({ error, invalid = false }: { error: unknown; invalid?: boolean }) {
const { t } = useTranslation("assets");
const code = invalid
? "INVALID_REQUEST"
: error instanceof SupportApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`support.errors.${code}`, { defaultValue: t("support.errors.INTERNAL_ERROR") })}
</p>
) : null;
}

View File

@ -17,7 +17,8 @@ import {
} from "../common/support";
import { supportApi, supportKeys } from "./support-api";
import { SupportFeedback, SupportHelp } from "./support-help";
import { SupportFeedback } from "./support-feedback";
import { SupportHelp } from "./support-help";
import { SupportToolForm } from "./support-tool-form";
export function SupportForm({
companyId,

View File

@ -1,7 +1,6 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
import { SupportApiError } from "./support-api";
export function SupportHelp({ tool = false }: { tool?: boolean }) {
const { t } = useTranslation("assets");
return (
@ -15,16 +14,3 @@ export function SupportHelp({ tool = false }: { tool?: boolean }) {
/>
);
}
export function SupportFeedback({ error, invalid = false }: { error: unknown; invalid?: boolean }) {
const { t } = useTranslation("assets");
const code = invalid
? "INVALID_REQUEST"
: error instanceof SupportApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`support.errors.${code}`, { defaultValue: t("support.errors.INTERNAL_ERROR") })}
</p>
) : null;
}

View File

@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next";
import type { SupportConnectionDTO, SupportToolDTO } from "../common/support";
import { SupportSummary } from "./support-summary";
export function SupportHistory({
connection,
tools,
}: {
connection: SupportConnectionDTO;
tools: readonly SupportToolDTO[];
}) {
const { i18n } = useTranslation("assets");
return (
<ol className="space-y-3">
{[...connection.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3" key={revision.id}>
<p className="text-sm">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<SupportSummary tools={tools} values={revision.values} />
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import type { SupportConnectionDTO, SupportToolDTO } from "../common/support";
export function SupportSummary({
values,
tools,
}: {
values: SupportConnectionDTO["values"];
tools: readonly SupportToolDTO[];
}) {
const { t } = useTranslation("assets");
return (
<div className="space-y-1 text-sm">
<p className="font-medium">
{tools.find((tool) => tool.id === values.toolId)?.name ?? values.toolId} ·{" "}
{t(`support.${values.state}`)}
{values.preferred ? ` · ${t("support.preferred")}` : ""}
</p>
{values.destination && <p className="break-words">{values.destination}</p>}
<p>
{t("support.requiresVpn")}: {t(values.requiresVpn ? "yes" : "no")}
</p>
{values.notes && <p className="whitespace-pre-wrap text-muted-foreground">{values.notes}</p>}
</div>
);
}

View File

@ -15,7 +15,8 @@ import {
} from "../common/support";
import { supportApi, supportKeys } from "./support-api";
import { SupportFeedback, SupportHelp } from "./support-help";
import { SupportFeedback } from "./support-feedback";
import { SupportHelp } from "./support-help";
export function SupportToolForm({
csrfToken,
tool,

View File

@ -8,9 +8,9 @@ import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { SupportSummary } from "./asset-support";
import { assetsEnglish, assetsSpanish } from "./i18n";
import { SupportForm } from "./support-form";
import { SupportSummary } from "./support-summary";
import { SupportToolForm } from "./support-tool-form";
const tool = {

View File

@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import type { AssetDefinitionSpec, SaveDefinitionResponseDTO } from "../common";
import { catalogApi, catalogKeys } from "./catalog-api";
export type CatalogSaved = (id: string, data: SaveDefinitionResponseDTO) => void;
export function useCatalogSave(
scope: string,
csrfToken: string,
onSaved: CatalogSaved,
id?: string
) {
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
return useMutation({
mutationFn: (values: AssetDefinitionSpec) => catalogApi.save(scope, values, csrfToken, id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["asset-catalog"] });
client.setQueryData(catalogKeys.scope(scope), { items: response.items });
if (mounted.current) {
onSaved(response.savedId, response);
}
},
});
}

View File

@ -12,9 +12,11 @@ import { useTranslation } from "react-i18next";
import { type WorkstationDTO, type WorkstationInput, WorkstationInputSchema } from "../common";
import { AssetForm, InventoryFeedback, type InventoryLookups } from "./asset-form";
import type { InventoryLookups } from "./asset-form";
import { AssetForm } from "./asset-form";
import { catalogApi, catalogKeys } from "./catalog-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { InventoryFeedback } from "./inventory-feedback";
export function WorkstationForm({
companyId,
csrfToken,

View File

@ -11,10 +11,9 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { WorkstationDTO } from "../common";
import { InventoryFeedback, type InventoryLookups } from "./asset-form";
import type { InventoryLookups } from "./asset-form";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { InventoryFeedback } from "./inventory-feedback";
import { WorkstationForm } from "./workstation-form";
export function WorkstationsPage({
companyId,
@ -49,26 +48,6 @@ export function WorkstationsPage({
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
)
);
const _summary = (item: WorkstationDTO) => (
<>
<h3 className="font-medium">
{item.values.name} · {t(item.values.active ? "active" : "inactive")}
</h3>
<p className="text-sm text-muted-foreground">
{siteName(item.values.siteId)} · {personName(item.values.personId)} ·{" "}
{t("workstationCount", {
count: item.values.assetIds.length + item.values.sharedAssetIds.length,
})}
</p>
<p className="mt-2 text-sm">
{t("exclusiveWorkstationAssets")}: {members(item.values.assetIds)}
</p>
<p className="mt-1 text-sm">
{t("sharedWorkstationAssets")}:{" "}
{members(item.values.sharedAssetIds, "noSharedWorkstationAssets")}
</p>
</>
);
return (
<div className="space-y-5">
<EntityListHeader

View File

@ -7,9 +7,6 @@ import { AccessError } from "./access-error";
import type { AccessItem } from "./access-item.entity";
import type { AccessContext } from "./access-workspace.aggregate";
function _open(item: AccessItem) {
return "endDate" in item.values.data && item.values.data.endDate === null;
}
function active(item: AccessItem, today: string) {
const values = item.values.data;
return !(

View File

@ -7,10 +7,11 @@ import { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { Option } from "./access-field-option";
import type { AccessField } from "./access-fields";
import type { AccessFormValues } from "./access-form-values";
import { PermissionsControl } from "./permissions-control";
type Option = { value: string; label: string };
export function AccessFieldControl({
field,
id,
@ -99,47 +100,10 @@ function stringValue(value: string | boolean | string[] | undefined) {
return typeof value === "string" ? value : "";
}
function PermissionsControl({
fieldKey,
id,
value,
options,
form,
}: {
fieldKey: string;
id: string;
value: string | boolean | string[] | undefined;
options: readonly Option[];
form: UseFormReturn<AccessFormValues>;
}) {
const { t } = useTranslation("access");
const values = Array.isArray(value) ? value : [];
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t(fieldKey)}</legend>
{options.map((option) => (
<div className="flex items-center gap-2" key={option.value}>
<Checkbox
checked={values.includes(option.value)}
id={`${id}-${option.value}`}
onCheckedChange={(checked) =>
form.setValue(
fieldKey,
checked ? [...values, option.value] : values.filter((item) => item !== option.value)
)
}
/>
<Label htmlFor={`${id}-${option.value}`}>{option.label}</Label>
</div>
))}
{!options.length && <p className="text-sm text-muted-foreground">{t("noPermissions")}</p>}
</fieldset>
);
}
function quickAddLabel(key: string) {
return key === "systemId" ? "quickAddSystem" : "quickAddEnvironment";
}
function selectAction(
onClick: (() => void) | undefined,
disabled: boolean,

View File

@ -0,0 +1 @@
export type Option = { value: string; label: string };

View File

@ -0,0 +1,223 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ComponentType } from "react";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { AccessFieldsDTO } from "../../common";
import { AccessFieldsSchema } from "../../common";
import { AccessApiError, accessApi, accessKeys } from "./access-api";
import { AccessFieldControl } from "./access-field-control";
import type { AccessField } from "./access-fields";
import { accessFields } from "./access-fields";
import type { AccessFormProps } from "./access-form-props";
import type { AccessFormValues } from "./access-form-values";
import { compatibleOption, fieldDefault, formBody } from "./access-form-values";
import { accessLabel } from "./access-labels";
import { AccessQuickEditor } from "./access-quick-editor";
import { EntityHelp } from "./entity-help";
import type { QuickCreatePlan } from "./quick-create-plan";
import { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan";
export function AccessFormFields({
scope,
kind,
item,
data,
people,
csrfToken,
onSaved,
allowQuickCreate = true,
initialValues = {},
lockedFields = [],
nestedAccessForm,
}: AccessFormProps & {
nestedAccessForm: ComponentType<AccessFormProps>;
}) {
const { t } = useTranslation("access");
const prefix = useId();
const client = useQueryClient();
const [validation, setValidation] = useState<string>();
const fields = accessFields[kind];
const [quickCreate, setQuickCreate] = useState<{
field: AccessField;
plan: QuickCreatePlan;
}>();
const form = useForm<AccessFormValues>({
defaultValues: Object.fromEntries(
fields.map((field) => [field.key, initialValues[field.key] ?? fieldDefault(field, item)])
),
});
const watched = form.watch();
const text = (key: string) => (typeof watched[key] === "string" ? watched[key] : "");
const account = data.items.find((target) => target.id === text("accountId"))?.values;
const technical =
kind === "account_person" && account?.kind === "account" && account.usage === "technical";
const mutation = useMutation({
mutationFn: (values: unknown) => {
const parsed = AccessFieldsSchema.safeParse(values);
if (!parsed.success) {
throw new AccessApiError("INVALID_REQUEST");
}
return accessApi.request(scope, parsed.data, item?.id, csrfToken);
},
onSuccess: async (result, submitted) => {
const savedId =
item?.id ??
result.items.find(
(candidate) =>
candidate.companyId === (scope === "global" ? null : scope) &&
!data.items.some((existing) => existing.id === candidate.id) &&
matchesSubmittedValues(candidate.values, AccessFieldsSchema.parse(submitted))
)?.id;
await client.invalidateQueries({ queryKey: ["access"] });
client.setQueryData(accessKeys.scope(scope), result);
onSaved(savedId, AccessFieldsSchema.parse(submitted));
},
});
const submit = form.handleSubmit((raw) => {
setValidation(undefined);
const parsed = AccessFieldsSchema.safeParse(formBody(kind, fields, raw, technical));
if (!parsed.success) {
setValidation(t("invalid"));
return;
}
mutation.mutate(parsed.data);
});
const select = (field: AccessField, value: string, createdValues?: AccessFieldsDTO) => {
form.setValue(field.key, value);
if (field.key === "resourceId" || field.key === "resourceTypeId") {
form.setValue("permissionId", "");
form.setValue("permissionIds", []);
}
if (field.key === "systemId") {
form.setValue("environmentId", "");
}
if (field.key === "accountId" && ["membership", "grant"].includes(kind)) {
form.setValue("groupId", "");
}
if (field.key === "groupId" && kind === "grant") {
form.setValue("accountId", "");
}
if (field.key === "templateId") {
applyTemplate(value, createdValues);
}
};
const applyTemplate = (id: string, createdValues?: AccessFieldsDTO) => {
const template = createdValues ?? data.items.find((target) => target.id === id)?.values;
if (template?.kind !== "template") {
return;
}
form.setValue("name", template.name);
form.setValue("description", template.description ?? "");
if (template.systemId && !lockedFields.includes("systemId")) {
form.setValue("systemId", template.systemId);
form.setValue("environmentId", "");
}
};
const options = (field: AccessField) => {
if (field.options) {
return field.options.map((value) => ({ value, label: t(value) }));
}
if (field.key === "personId") {
return people
.filter((person) => person.active || person.id === text(field.key))
.map((person) => ({ value: person.id, label: person.name }));
}
return data.items
.filter((target) =>
compatibleOption(target, field, text(field.key), watched, data.items, kind)
)
.map((target) => ({ value: target.id, label: accessLabel(target, data.items, people) }));
};
const quickAction = (field: AccessField) => {
if (!allowQuickCreate || (item && field.fixed) || lockedFields.includes(field.key)) {
return undefined;
}
const plan = quickCreatePlan(field, scope, kind, data, watched);
return plan ? () => setQuickCreate({ field, plan }) : undefined;
};
const created = (id?: string, values?: AccessFieldsDTO) => {
if (quickCreate && id) {
select(quickCreate.field, id, values);
setQuickCreate(undefined);
}
};
const help: Readonly<Record<string, string>> = {
account: "accountHelp",
coverage: "coverageHelp",
template: "templateHelp",
};
const editableFields = fields.filter((field) => field.key !== "configuration" || !technical);
return (
<>
<form className="space-y-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
{editableFields.map((field) => (
<div
className={["note", "permissions"].includes(field.type) ? "sm:col-span-2" : ""}
key={field.key}
>
<AccessFieldControl
createLabel={
field.key === "systemId" || field.key === "environmentId"
? undefined
: t("quickAddEntry", { name: t(field.key) })
}
disabled={
Boolean(item && field.fixed) ||
lockedFields.includes(field.key) ||
mutation.isPending
}
field={field}
form={form}
id={`${prefix}-${field.key}`}
onCreate={quickAction(field)}
onSelected={(value) => select(field, value)}
options={options(field)}
value={watched[field.key]}
/>
</div>
))}
</div>
{validation && (
<p className="text-destructive" role="alert">
{validation}
</p>
)}
{mutation.isError && (
<p className="text-destructive" role="alert">
{t(
`errors.${mutation.error instanceof AccessApiError ? mutation.error.code : "INTERNAL_ERROR"}`
)}
</p>
)}
{help[kind] && <p className="text-sm text-muted-foreground">{t(help[kind])}</p>}
{["account_person", "membership", "grant"].includes(kind) && (
<p className="text-sm text-muted-foreground">{t("configurationHelp")}</p>
)}
<EntityHelp kind={kind} scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quickCreate && (
<AccessQuickEditor
csrfToken={csrfToken}
data={data}
nestedAccessForm={nestedAccessForm}
onClose={() => setQuickCreate(undefined)}
onCreated={created}
people={people}
plan={quickCreate.plan}
scope={scope}
/>
)}
</>
);
}

View File

@ -0,0 +1,22 @@
import type {
AccessFieldsDTO,
AccessItemDTO,
AccessKind,
AccessResponseDTO,
PersonDTO,
} from "../../common";
import type { AccessFormValues } from "./access-form-values";
export type AccessFormProps = {
scope: string;
kind: AccessKind;
item?: AccessItemDTO | undefined;
data: AccessResponseDTO;
people: readonly PersonDTO[];
csrfToken: string;
onSaved: (id?: string, values?: AccessFieldsDTO) => void;
allowQuickCreate?: boolean;
initialValues?: AccessFormValues;
lockedFields?: readonly string[];
};

View File

@ -1,339 +1,9 @@
import { ResourceEditor } from "@metis/resources/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type {
AccessFieldsDTO,
AccessItemDTO,
AccessKind,
AccessResponseDTO,
PersonDTO,
} from "../../common";
import { AccessFieldsSchema } from "../../common";
import { PersonForm } from "../components/person-form";
import { AccessFormFields } from "./access-form-fields";
import type { AccessFormProps } from "./access-form-props";
import { AccessApiError, accessApi, accessKeys } from "./access-api";
import { AccessFieldControl } from "./access-field-control";
import type { AccessField } from "./access-fields";
import { accessFields, createLabels } from "./access-fields";
import type { AccessFormValues } from "./access-form-values";
import { compatibleOption, fieldDefault, formBody } from "./access-form-values";
import { accessLabel } from "./access-labels";
import { EntityHelp } from "./entity-help";
import { QuickCreateDialog } from "./quick-create-dialog";
import type { QuickCreatePlan } from "./quick-create-plan";
import { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan";
function AccessFormInternal({
scope,
kind,
item,
data,
people,
csrfToken,
onSaved,
allowQuickCreate = true,
initialValues = {},
lockedFields = [],
}: {
scope: string;
kind: AccessKind;
item?: AccessItemDTO | undefined;
data: AccessResponseDTO;
people: readonly PersonDTO[];
csrfToken: string;
onSaved: (id?: string, values?: AccessFieldsDTO) => void;
allowQuickCreate?: boolean;
initialValues?: AccessFormValues;
lockedFields?: readonly string[];
}) {
const { t } = useTranslation("access");
const prefix = useId();
const client = useQueryClient();
const [validation, setValidation] = useState<string>();
const fields = accessFields[kind];
const [quickCreate, setQuickCreate] = useState<{
field: AccessField;
plan: QuickCreatePlan;
}>();
const form = useForm<AccessFormValues>({
defaultValues: Object.fromEntries(
fields.map((field) => [field.key, initialValues[field.key] ?? fieldDefault(field, item)])
),
});
const watched = form.watch();
const text = (key: string) => (typeof watched[key] === "string" ? watched[key] : "");
const account = data.items.find((target) => target.id === text("accountId"))?.values;
const technical =
kind === "account_person" && account?.kind === "account" && account.usage === "technical";
const mutation = useMutation({
mutationFn: (values: unknown) => {
const parsed = AccessFieldsSchema.safeParse(values);
if (!parsed.success) {
throw new AccessApiError("INVALID_REQUEST");
}
return accessApi.request(scope, parsed.data, item?.id, csrfToken);
},
onSuccess: async (result, submitted) => {
const savedId =
item?.id ??
result.items.find(
(candidate) =>
candidate.companyId === (scope === "global" ? null : scope) &&
!data.items.some((existing) => existing.id === candidate.id) &&
matchesSubmittedValues(candidate.values, AccessFieldsSchema.parse(submitted))
)?.id;
await client.invalidateQueries({ queryKey: ["access"] });
client.setQueryData(accessKeys.scope(scope), result);
onSaved(savedId, AccessFieldsSchema.parse(submitted));
},
});
const submit = form.handleSubmit(async (raw) => {
setValidation(undefined);
const parsed = AccessFieldsSchema.safeParse(formBody(kind, fields, raw, technical));
if (!parsed.success) {
setValidation(t("invalid"));
return;
}
await mutation.mutateAsync(parsed.data).catch(() => undefined);
});
const select = (field: AccessField, value: string, createdValues?: AccessFieldsDTO) => {
form.setValue(field.key, value);
if (field.key === "resourceId" || field.key === "resourceTypeId") {
form.setValue("permissionId", "");
form.setValue("permissionIds", []);
}
if (field.key === "systemId") {
form.setValue("environmentId", "");
}
if (field.key === "accountId" && ["membership", "grant"].includes(kind)) {
form.setValue("groupId", "");
}
if (field.key === "groupId" && kind === "grant") {
form.setValue("accountId", "");
}
if (field.key === "templateId") {
applyTemplate(value, createdValues);
}
};
const applyTemplate = (id: string, createdValues?: AccessFieldsDTO) => {
const template = createdValues ?? data.items.find((target) => target.id === id)?.values;
if (template?.kind !== "template") {
return;
}
form.setValue("name", template.name);
form.setValue("description", template.description ?? "");
if (template.systemId && !lockedFields.includes("systemId")) {
form.setValue("systemId", template.systemId);
form.setValue("environmentId", "");
}
};
const options = (field: AccessField) => {
if (field.options) {
return field.options.map((value) => ({ value, label: t(value) }));
}
if (field.key === "personId") {
return people
.filter((person) => person.active || person.id === text(field.key))
.map((person) => ({ value: person.id, label: person.name }));
}
return data.items
.filter((target) =>
compatibleOption(target, field, text(field.key), watched, data.items, kind)
)
.map((target) => ({ value: target.id, label: accessLabel(target, data.items, people) }));
};
const quickAction = (field: AccessField) => {
if (!allowQuickCreate || (item && field.fixed) || lockedFields.includes(field.key)) {
return undefined;
}
const plan = quickCreatePlan(field, scope, kind, data, watched);
return plan ? () => setQuickCreate({ field, plan }) : undefined;
};
const created = (id?: string, values?: AccessFieldsDTO) => {
if (quickCreate && id) {
select(quickCreate.field, id, values);
setQuickCreate(undefined);
}
};
const help: Readonly<Record<string, string>> = {
account: "accountHelp",
coverage: "coverageHelp",
template: "templateHelp",
};
const editableFields = fields.filter((field) => field.key !== "configuration" || !technical);
return (
<>
<form className="space-y-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
{editableFields.map((field) => (
<div
className={["note", "permissions"].includes(field.type) ? "sm:col-span-2" : ""}
key={field.key}
>
<AccessFieldControl
createLabel={
field.key === "systemId" || field.key === "environmentId"
? undefined
: t("quickAddEntry", { name: t(field.key) })
}
disabled={
Boolean(item && field.fixed) ||
lockedFields.includes(field.key) ||
mutation.isPending
}
field={field}
form={form}
id={`${prefix}-${field.key}`}
onCreate={quickAction(field)}
onSelected={(value) => select(field, value)}
options={options(field)}
value={watched[field.key]}
/>
</div>
))}
</div>
{validation && (
<p className="text-destructive" role="alert">
{validation}
</p>
)}
{mutation.isError && (
<p className="text-destructive" role="alert">
{t(
`errors.${mutation.error instanceof AccessApiError ? mutation.error.code : "INTERNAL_ERROR"}`
)}
</p>
)}
{help[kind] && <p className="text-sm text-muted-foreground">{t(help[kind])}</p>}
{["account_person", "membership", "grant"].includes(kind) && (
<p className="text-sm text-muted-foreground">{t("configurationHelp")}</p>
)}
<EntityHelp kind={kind} scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quickCreate && (
<AccessQuickEditor
csrfToken={csrfToken}
data={data}
onClose={() => setQuickCreate(undefined)}
onCreated={created}
people={people}
plan={quickCreate.plan}
scope={scope}
/>
)}
</>
);
}
function AccessQuickEditor({
plan,
scope,
data,
people,
csrfToken,
onCreated,
onClose,
}: {
plan: QuickCreatePlan;
scope: string;
data: AccessResponseDTO;
people: readonly PersonDTO[];
csrfToken: string;
onCreated: (id?: string, values?: AccessFieldsDTO) => void;
onClose: () => void;
}) {
const { t } = useTranslation("access");
const active = useRef(true);
useEffect(() => {
active.current = true;
return () => {
active.current = false;
};
}, []);
const close = () => {
active.current = false;
onClose();
};
const created = (id?: string, values?: AccessFieldsDTO) => {
if (active.current) {
onCreated(id, values);
}
};
const globalQuery = useQuery({
queryKey: accessKeys.scope("global"),
queryFn: () => accessApi.request("global"),
enabled: plan.type === "access" && plan.scope === "global" && scope !== "global",
});
if (plan.type === "simple") {
return (
<QuickCreateDialog
csrfToken={csrfToken}
items={data.items}
onClose={close}
onCreated={created}
scope={scope}
target={plan.target}
/>
);
}
const title = plan.type === "person" ? "quickAddPerson" : createLabels[plan.kind];
const childData = plan.type === "access" && plan.scope !== scope ? globalQuery.data : data;
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
close();
}
}}
open
title={t(title)}
wide
>
{plan.type === "person" ? (
<>
<p className="text-sm text-muted-foreground">{t("quickCreateNotice")}</p>
<PersonForm companyId={scope} csrfToken={csrfToken} onSaved={created} requireActive />
</>
) : (
<>
<p className="text-sm text-muted-foreground">
{t(plan.scope === "global" ? "quickGlobalNotice" : "quickCreateNotice")}
</p>
{childData ? (
<AccessForm
allowQuickCreate={false}
csrfToken={csrfToken}
data={childData}
initialValues={plan.defaults}
kind={plan.kind}
lockedFields={plan.locked}
onSaved={created}
people={people}
scope={plan.scope}
/>
) : (
<p role="status">{t("loading")}</p>
)}
{globalQuery.isError && <p role="alert">{t("errors.INTERNAL_ERROR")}</p>}
</>
)}
</FormDialog>
);
}
export function AccessForm(props: Parameters<typeof AccessFormInternal>[0]) {
export function AccessForm(props: AccessFormProps) {
if (props.kind === "resource") {
const type = props.initialValues?.resourceTypeId;
return (
@ -352,5 +22,5 @@ export function AccessForm(props: Parameters<typeof AccessFormInternal>[0]) {
/>
);
}
return <AccessFormInternal {...props} />;
return <AccessFormFields {...props} nestedAccessForm={AccessForm} />;
}

View File

@ -0,0 +1,114 @@
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useQuery } from "@tanstack/react-query";
import type { ComponentType } from "react";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { AccessFieldsDTO, AccessResponseDTO, PersonDTO } from "../../common";
import { PersonForm } from "../components/person-form";
import { accessApi, accessKeys } from "./access-api";
import { createLabels } from "./access-fields";
import type { AccessFormProps } from "./access-form-props";
import { QuickCreateDialog } from "./quick-create-dialog";
import type { QuickCreatePlan } from "./quick-create-plan";
export function AccessQuickEditor({
plan,
scope,
data,
people,
csrfToken,
onCreated,
onClose,
nestedAccessForm: NestedAccessForm,
}: {
plan: QuickCreatePlan;
scope: string;
data: AccessResponseDTO;
people: readonly PersonDTO[];
csrfToken: string;
onCreated: (id?: string, values?: AccessFieldsDTO) => void;
onClose: () => void;
nestedAccessForm: ComponentType<AccessFormProps>;
}) {
const { t } = useTranslation("access");
const active = useRef(true);
useEffect(() => {
active.current = true;
return () => {
active.current = false;
};
}, []);
const close = () => {
active.current = false;
onClose();
};
const created = (id?: string, values?: AccessFieldsDTO) => {
if (active.current) {
onCreated(id, values);
}
};
const globalQuery = useQuery({
queryKey: accessKeys.scope("global"),
queryFn: () => accessApi.request("global"),
enabled: plan.type === "access" && plan.scope === "global" && scope !== "global",
});
if (plan.type === "simple") {
return (
<QuickCreateDialog
csrfToken={csrfToken}
items={data.items}
onClose={close}
onCreated={created}
scope={scope}
target={plan.target}
/>
);
}
const title = plan.type === "person" ? "quickAddPerson" : createLabels[plan.kind];
const childData = plan.type === "access" && plan.scope !== scope ? globalQuery.data : data;
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
close();
}
}}
open
title={t(title)}
wide
>
{plan.type === "person" ? (
<>
<p className="text-sm text-muted-foreground">{t("quickCreateNotice")}</p>
<PersonForm companyId={scope} csrfToken={csrfToken} onSaved={created} requireActive />
</>
) : (
<>
<p className="text-sm text-muted-foreground">
{t(plan.scope === "global" ? "quickGlobalNotice" : "quickCreateNotice")}
</p>
{childData ? (
<NestedAccessForm
allowQuickCreate={false}
csrfToken={csrfToken}
data={childData}
initialValues={plan.defaults}
kind={plan.kind}
lockedFields={plan.locked}
onSaved={created}
people={people}
scope={plan.scope}
/>
) : (
<p role="status">{t("loading")}</p>
)}
{globalQuery.isError && <p role="alert">{t("errors.INTERNAL_ERROR")}</p>}
</>
)}
</FormDialog>
);
}

View File

@ -1,47 +1,11 @@
import {
EntityAction,
EntityRowActions,
EntityStatus,
} from "@repo/shadcn-ui/components/entity-list";
import { EntityAction, EntityRowActions } from "@repo/shadcn-ui/components/entity-list";
import { useTranslation } from "react-i18next";
import type { AccessFieldsDTO, AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../common";
import type { AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../common";
import { accessLabel } from "./access-labels";
import { EntryState } from "./entry-state";
function EntryState({ value }: { value: AccessFieldsDTO }) {
const { t } = useTranslation("access");
let state = "—";
if ("active" in value) {
state = t(value.active ? "active" : "inactive");
}
if ("endDate" in value) {
state = value.endDate ?? t("open");
}
return (
<>
<EntityStatus
active={"active" in value ? value.active : "endDate" in value && !value.endDate}
>
{state}
</EntityStatus>
{"configuration" in value && value.configuration && (
<p className="text-sm">{t(value.configuration)}</p>
)}
{value.kind === "account" && (
<p className="text-sm">
{t(value.usage)}
{value.expiresOn && (
<span>
{" "}
· {t("expiresOn")}: {value.expiresOn}
</span>
)}
</p>
)}
</>
);
}
export function AccessCell({
column,
item,

View File

@ -2,7 +2,7 @@ import { Button } from "@repo/shadcn-ui/components/button";
import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useQuery } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { AccessKind, PersonDTO } from "../../common";
@ -34,7 +34,6 @@ export function AccessWorkspacePage({
const [history, setHistory] = useState<string>();
const [helpOpen, setHelpOpen] = useState(false);
const [search, setSearch] = useState("");
const _searchId = useId();
const data = query.data;
const selected = data?.items.find((item) => item.id === editor);
const historical = data?.items.find((item) => item.id === history);

View File

@ -0,0 +1,38 @@
import { EntityStatus } from "@repo/shadcn-ui/components/entity-list";
import { useTranslation } from "react-i18next";
import type { AccessFieldsDTO } from "../../common";
export function EntryState({ value }: { value: AccessFieldsDTO }) {
const { t } = useTranslation("access");
let state = "—";
if ("active" in value) {
state = t(value.active ? "active" : "inactive");
}
if ("endDate" in value) {
state = value.endDate ?? t("open");
}
return (
<>
<EntityStatus
active={"active" in value ? value.active : "endDate" in value && !value.endDate}
>
{state}
</EntityStatus>
{"configuration" in value && value.configuration && (
<p className="text-sm">{t(value.configuration)}</p>
)}
{value.kind === "account" && (
<p className="text-sm">
{t(value.usage)}
{value.expiresOn && (
<span>
{" "}
· {t("expiresOn")}: {value.expiresOn}
</span>
)}
</p>
)}
</>
);
}

View File

@ -0,0 +1,45 @@
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { Label } from "@repo/shadcn-ui/components/label";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { Option } from "./access-field-option";
import type { AccessFormValues } from "./access-form-values";
export function PermissionsControl({
fieldKey,
id,
value,
options,
form,
}: {
fieldKey: string;
id: string;
value: string | boolean | string[] | undefined;
options: readonly Option[];
form: UseFormReturn<AccessFormValues>;
}) {
const { t } = useTranslation("access");
const values = Array.isArray(value) ? value : [];
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t(fieldKey)}</legend>
{options.map((option) => (
<div className="flex items-center gap-2" key={option.value}>
<Checkbox
checked={values.includes(option.value)}
id={`${id}-${option.value}`}
onCheckedChange={(checked) =>
form.setValue(
fieldKey,
checked ? [...values, option.value] : values.filter((item) => item !== option.value)
)
}
/>
<Label htmlFor={`${id}-${option.value}`}>{option.label}</Label>
</div>
))}
{!options.length && <p className="text-sm text-muted-foreground">{t("noPermissions")}</p>}
</fieldset>
);
}

View File

@ -1,15 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
ResourceEditor,
catalogApi,
catalogKeys,
inventoryApi,
inventoryKeys,
} from "@metis/resources/client";
import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/resources/client";
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
@ -27,10 +21,11 @@ import {
ApplicationConfigurationInputSchema,
} from "../../common/application-configurations";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { WorkProfileForm } from "../profiles/work-profile-form";
import { applicationApi, applicationKeys } from "./application-api";
import { ApplicationFeedback } from "./application-feedback";
import { ApplicationQuickEditor } from "./application-quick-editor";
export function ApplicationConfigurationForm({
company,
csrfToken,
@ -268,6 +263,7 @@ function applicationDefaults(
}
);
}
function applicationStates(
configuration: ApplicationConfigurationDTO | undefined
): readonly ApplicationConfigurationInput["state"][] {
@ -275,11 +271,13 @@ function applicationStates(
? APPLICATION_STATE_TRANSITIONS[configuration.values.state]
: ["pending", "configured"];
}
function applicationProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
return profiles
.filter((item) => item.values.active || item.id === selected)
.map((item) => ({ value: item.id, label: item.values.name }));
}
function changeDelivery(value: string, form: UseFormReturn<ApplicationConfigurationInput>) {
const parsed = ApplicationConfigurationInputSchema.shape.delivery.safeParse(value);
if (parsed.success) {
@ -289,50 +287,3 @@ function changeDelivery(value: string, form: UseFormReturn<ApplicationConfigurat
}
}
}
function ApplicationQuickEditor({
kind,
company,
csrfToken,
onClose,
onSaved,
}: {
kind: "profile" | "application";
company: CompanyDTO;
csrfToken: string;
onClose: () => void;
onSaved: (field: "profileId" | "resourceId", id: string) => void;
}) {
const { t } = useTranslation("companies");
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(kind === "profile" ? "profiles.new" : "applications.newResource")}
wide
>
{kind === "profile" ? (
<WorkProfileForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
onSaved={(id) => onSaved("profileId", id)}
/>
) : (
<ResourceEditor
allowQuickCreate={false}
companyId={company.id}
csrfToken={csrfToken}
initialKind="application"
onSaved={(id) => onSaved("resourceId", id)}
/>
)}
</FormDialog>
);
}

View File

@ -16,12 +16,14 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { ApplicationConfigurationDTO, CompanyDTO } from "../../common";
import type { CompanyDTO } from "../../common";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { applicationApi, applicationKeys } from "./application-api";
import { ApplicationConfigurationForm } from "./application-configuration-form";
import { ApplicationFeedback } from "./application-feedback";
import { ApplicationHistory } from "./application-history";
export function ApplicationConfigurationsPage({
company,
csrfToken,
@ -230,30 +232,3 @@ export function ApplicationConfigurationsPage({
</div>
);
}
function ApplicationHistory({ configuration }: { configuration: ApplicationConfigurationDTO }) {
const { t, i18n } = useTranslation("companies");
return (
<ol className="space-y-3">
{[...configuration.history].reverse().map((revision) => (
<li className="rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>
{t(`applications.${revision.values.delivery}`)} · {t(`mail.${revision.values.state}`)}
</p>
{revision.values.version && (
<p>{t("applications.versionValue", { version: revision.values.version })}</p>
)}
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import type { ApplicationConfigurationDTO } from "../../common";
export function ApplicationHistory({
configuration,
}: {
configuration: ApplicationConfigurationDTO;
}) {
const { t, i18n } = useTranslation("companies");
return (
<ol className="space-y-3">
{[...configuration.history].reverse().map((revision) => (
<li className="rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>
{t(`applications.${revision.values.delivery}`)} · {t(`mail.${revision.values.state}`)}
</p>
{revision.values.version && (
<p>{t("applications.versionValue", { version: revision.values.version })}</p>
)}
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,53 @@
import { ResourceEditor } from "@metis/resources/client";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { WorkProfileForm } from "../profiles/work-profile-form";
export function ApplicationQuickEditor({
kind,
company,
csrfToken,
onClose,
onSaved,
}: {
kind: "profile" | "application";
company: CompanyDTO;
csrfToken: string;
onClose: () => void;
onSaved: (field: "profileId" | "resourceId", id: string) => void;
}) {
const { t } = useTranslation("companies");
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(kind === "profile" ? "profiles.new" : "applications.newResource")}
wide
>
{kind === "profile" ? (
<WorkProfileForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
onSaved={(id) => onSaved("profileId", id)}
/>
) : (
<ResourceEditor
allowQuickCreate={false}
companyId={company.id}
csrfToken={csrfToken}
initialKind="application"
onSaved={(id) => onSaved("resourceId", id)}
/>
)}
</FormDialog>
);
}

View File

@ -1,15 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
ResourceEditor,
catalogApi,
catalogKeys,
inventoryApi,
inventoryKeys,
} from "@metis/resources/client";
import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/resources/client";
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
@ -27,10 +21,11 @@ import {
CertificateConfigurationInputSchema,
} from "../../common/certificate-configurations";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { WorkProfileForm } from "../profiles/work-profile-form";
import { certificateApi, certificateKeys } from "./certificate-api";
import { CertificateFeedback } from "./certificate-feedback";
import { CertificateQuickEditor } from "./certificate-quick-editor";
export function CertificateConfigurationForm({
company,
csrfToken,
@ -250,6 +245,7 @@ function certificateDefaults(
}
);
}
function certificateStates(
configuration: CertificateConfigurationDTO | undefined
): readonly CertificateConfigurationInput["state"][] {
@ -257,54 +253,9 @@ function certificateStates(
? CERTIFICATE_STATE_TRANSITIONS[configuration.values.state]
: ["pending", "configured"];
}
function certificateProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
return profiles
.filter((item) => item.values.active || item.id === selected)
.map((item) => ({ value: item.id, label: item.values.name }));
}
function CertificateQuickEditor({
kind,
company,
csrfToken,
onClose,
onSaved,
}: {
kind: "profile" | "certificate";
company: CompanyDTO;
csrfToken: string;
onClose: () => void;
onSaved: (field: "profileId" | "resourceId", id: string) => void;
}) {
const { t } = useTranslation("companies");
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(kind === "profile" ? "profiles.new" : "certificates.newResource")}
wide
>
{kind === "profile" ? (
<WorkProfileForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
onSaved={(id) => onSaved("profileId", id)}
/>
) : (
<ResourceEditor
allowQuickCreate={false}
companyId={company.id}
csrfToken={csrfToken}
initialKind="certificate"
onSaved={(id) => onSaved("resourceId", id)}
/>
)}
</FormDialog>
);
}

View File

@ -16,12 +16,14 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CertificateConfigurationDTO, CompanyDTO } from "../../common";
import type { CompanyDTO } from "../../common";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { certificateApi, certificateKeys } from "./certificate-api";
import { CertificateConfigurationForm } from "./certificate-configuration-form";
import { CertificateFeedback } from "./certificate-feedback";
import { CertificateHistory } from "./certificate-history";
export function CertificateConfigurationsPage({
company,
csrfToken,
@ -237,28 +239,3 @@ export function CertificateConfigurationsPage({
</div>
);
}
function CertificateHistory({ configuration }: { configuration: CertificateConfigurationDTO }) {
const { t, i18n } = useTranslation("companies");
return (
<ol className="space-y-3">
{[...configuration.history].reverse().map((revision) => (
<li className="rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>{t(`mail.${revision.values.state}`)}</p>
{revision.values.location && (
<p>{t("certificates.locationValue", { location: revision.values.location })}</p>
)}
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import type { CertificateConfigurationDTO } from "../../common";
export function CertificateHistory({
configuration,
}: {
configuration: CertificateConfigurationDTO;
}) {
const { t, i18n } = useTranslation("companies");
return (
<ol className="space-y-3">
{[...configuration.history].reverse().map((revision) => (
<li className="rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>{t(`mail.${revision.values.state}`)}</p>
{revision.values.location && (
<p>{t("certificates.locationValue", { location: revision.values.location })}</p>
)}
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,53 @@
import { ResourceEditor } from "@metis/resources/client";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { WorkProfileForm } from "../profiles/work-profile-form";
export function CertificateQuickEditor({
kind,
company,
csrfToken,
onClose,
onSaved,
}: {
kind: "profile" | "certificate";
company: CompanyDTO;
csrfToken: string;
onClose: () => void;
onSaved: (field: "profileId" | "resourceId", id: string) => void;
}) {
const { t } = useTranslation("companies");
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(kind === "profile" ? "profiles.new" : "certificates.newResource")}
wide
>
{kind === "profile" ? (
<WorkProfileForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
onSaved={(id) => onSaved("profileId", id)}
/>
) : (
<ResourceEditor
allowQuickCreate={false}
companyId={company.id}
csrfToken={csrfToken}
initialKind="certificate"
onSaved={(id) => onSaved("resourceId", id)}
/>
)}
</FormDialog>
);
}

View File

@ -16,7 +16,7 @@ import { EntityHelp } from "../access/entity-help";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
import { DepartmentForm } from "./department-section";
import { DepartmentForm } from "./department-form";
const FormSchema = z.strictObject({
departmentId: z.uuid(),

View File

@ -10,7 +10,7 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
import { useId, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanySummaryDTO } from "../../common";
@ -46,7 +46,6 @@ export function CompaniesPage({
const [activeOnly, setActiveOnly] = useState(initialActiveOnly);
const [createOpen, setCreateOpen] = useState(initialCreate);
const [search, setSearch] = useState("");
const _id = useId();
const table = useReactTable({
data: query.data?.companies ?? EMPTY_COMPANIES,
columns: COMPANY_COLUMNS,

View File

@ -1,5 +1,3 @@
import { AssetCatalogPage, BrandCatalogPage } from "@metis/assets/client";
import { ResourceCatalogPage } from "@metis/resources/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
@ -20,19 +18,12 @@ import type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AccessWorkspacePage } from "../access/access-workspace";
import { companyApi, companyKeys } from "../api/company-api";
import { InterventionTemplatesPage } from "../intervention-templates/templates-page";
import { InterventionsPage } from "../interventions/interventions-page";
import { TicketsPage } from "../tickets/tickets-page";
import { CompaniesPage } from "./companies-page";
import { CompanyDetail } from "./company-detail";
import { CompanyFeedback } from "./company-feedback";
import type { DashboardNavigation } from "./dashboard-api";
import { DashboardPage } from "./dashboard-page";
type WorkspaceLocation = { area: "home" } | DashboardNavigation | { area: "catalogs" };
import { WorkspaceContent } from "./workspace-content";
import type { WorkspaceLocation } from "./workspace-location";
export function CompaniesWorkspace({
csrfToken,
@ -321,91 +312,3 @@ export function CompaniesWorkspace({
</div>
);
}
function GlobalCatalogContent({
catalog,
csrfToken,
}: {
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
csrfToken: string;
}) {
switch (catalog) {
case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} />;
case "access":
return <AccessWorkspacePage csrfToken={csrfToken} scope="global" />;
case "assets":
return <AssetCatalogPage csrfToken={csrfToken} scope="global" />;
case "brands":
return <BrandCatalogPage csrfToken={csrfToken} />;
case "resources":
return <ResourceCatalogPage csrfToken={csrfToken} scope="global" />;
}
}
function WorkspaceContent({
area,
navigation,
catalog,
csrfToken,
onNavigate,
onSelectCompany,
}: {
area: WorkspaceLocation["area"];
navigation: WorkspaceLocation;
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
csrfToken: string;
onNavigate: (destination: DashboardNavigation) => void;
onSelectCompany: (id: string) => void;
}) {
switch (area) {
case "interventions":
return (
<InterventionsPage
csrfToken={csrfToken}
{...(navigation.area === "interventions"
? {
companyId: navigation.companyId,
initialId: navigation.id,
initialCreate: navigation.create,
initialPendingOnly: navigation.pendingOnly,
}
: {})}
/>
);
case "tickets":
return (
<TicketsPage
csrfToken={csrfToken}
{...(navigation.area === "tickets"
? {
companyId: navigation.companyId,
initialId: navigation.id,
initialCreate: navigation.create,
initialPendingOnly: navigation.pendingOnly,
}
: {})}
/>
);
case "directory":
return (
<CompaniesPage
csrfToken={csrfToken}
onSelectCompany={onSelectCompany}
{...(navigation.area === "directory"
? {
initialActiveOnly: navigation.activeOnly,
initialCompanyId: navigation.companyId,
initialCreate: navigation.create,
}
: {})}
/>
);
case "catalogs":
return <GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />;
case "company":
return null;
case "home":
return <DashboardPage csrfToken={csrfToken} onNavigate={onNavigate} />;
}
}

View File

@ -1,12 +1,4 @@
import { AssetCatalogPage } from "@metis/assets/client";
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
import { Button } from "@repo/shadcn-ui/components/button";
import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import {
BoxIcon,
@ -23,136 +15,12 @@ import type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { AccessWorkspacePage } from "../access/access-workspace";
import { companyApi, companyKeys } from "../api/company-api";
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
import { FolderMappingsPage } from "../folders/folder-mappings-page";
import { InterventionTemplatesPage } from "../intervention-templates/templates-page";
import { InterventionsPage } from "../interventions/interventions-page";
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
import { MailboxRelations } from "../mail/mailbox-relations";
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
import { ProcessesPage } from "../processes/processes-page";
import { WorkProfilesPage } from "../profiles/work-profiles-page";
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
import { TicketsPage } from "../tickets/tickets-page";
import { CompanyFeedback } from "./company-feedback";
import { CompanyForm } from "./company-form";
import { CompanyInventory } from "./company-inventory";
import { DepartmentSection } from "./department-section";
import { PeopleSection } from "./people-section";
import { SiteForm } from "./site-form";
function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t, i18n } = useTranslation("companies");
const [createOpen, setCreateOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | undefined>();
const selected = company.sites.find((site) => site.id === selectedId);
return (
<div className="space-y-6">
<EntityListHeader
actions={<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>}
locale={i18n.language}
title={t("sites")}
/>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (site) => site.name,
hideable: false,
render: (site) => <span className="font-medium">{site.name}</span>,
},
{
id: "address",
label: t("addressOptional"),
value: (site) => site.address,
render: (site) => (
<span className="whitespace-pre-wrap">{site.address ?? t("noAddress")}</span>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (site) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setSelectedId(site.id)}>{t("edit")}</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noSites")}
items={company.sites}
locale={i18n.language}
title={t("sites")}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setCreateOpen}
open={createOpen}
title={t("newSite")}
>
<SiteForm
companyId={company.id}
csrfToken={csrfToken}
onSaved={() => setCreateOpen(false)}
showTitle={false}
/>
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setSelectedId(undefined);
}
}}
open={selected !== undefined}
title={t("editSite")}
>
{selected && (
<SiteForm
companyId={company.id}
csrfToken={csrfToken}
key={selected.id}
onSaved={() => setSelectedId(undefined)}
showTitle={false}
site={selected}
/>
)}
</FormDialog>
</div>
);
}
type CompanyTab =
| "interventions"
| "intervention-templates"
| "tickets"
| "processes"
| "remote"
| "telework"
| "people"
| "departments"
| "sites"
| "access"
| "resource-catalog"
| "resources"
| "assets"
| "inventory"
| "workstations"
| "certificates"
| "applications"
| "printers"
| "folders"
| "mail"
| "profiles";
import { CompanySection } from "./company-section";
import type { CompanyTab } from "./company-tab";
export function CompanyDetail({
id,
@ -300,85 +168,3 @@ export function CompanyDetail({
</div>
);
}
function CompanySection({
company,
csrfToken,
tab,
initialEntityId,
initialCreate,
}: {
company: CompanyDTO;
csrfToken: string;
tab: CompanyTab;
initialEntityId?: string | undefined;
initialCreate?: boolean | undefined;
}) {
switch (tab) {
case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
case "interventions":
return <InterventionsPage companyId={company.id} csrfToken={csrfToken} />;
case "tickets":
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
case "processes":
return <ProcessesPage company={company} csrfToken={csrfToken} />;
case "remote":
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />;
case "telework":
return <TeleworkAuthorizationsPage company={company} csrfToken={csrfToken} />;
case "people":
return <PeopleSection company={company} csrfToken={csrfToken} initialId={initialEntityId} />;
case "departments":
return <DepartmentSection company={company} csrfToken={csrfToken} />;
case "sites":
return <SiteSection company={company} csrfToken={csrfToken} />;
case "access":
return (
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
);
case "profiles":
return (
<WorkProfilesPage company={company} csrfToken={csrfToken} initialId={initialEntityId} />
);
case "certificates":
return <CertificateConfigurationsPage company={company} csrfToken={csrfToken} />;
case "applications":
return <ApplicationConfigurationsPage company={company} csrfToken={csrfToken} />;
case "printers":
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />;
case "folders":
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
case "mail":
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
case "workstations":
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
case "inventory":
return (
<CompanyInventory
company={company}
csrfToken={csrfToken}
initialCreate={initialCreate}
initialId={initialEntityId}
/>
);
case "resources":
return (
<ResourcesPage
companyId={company.id}
csrfToken={csrfToken}
initialCreate={initialCreate}
initialId={initialEntityId}
relatedContent={(resource) =>
resource.values.integration?.kind === "mailbox" ? (
<MailboxRelations company={company} csrfToken={csrfToken} mailboxId={resource.id} />
) : null
}
/>
);
case "resource-catalog":
return <ResourceCatalogPage csrfToken={csrfToken} scope={company.id} />;
case "assets":
return <AssetCatalogPage csrfToken={csrfToken} scope={company.id} />;
}
}

View File

@ -0,0 +1,106 @@
import { AssetCatalogPage } from "@metis/assets/client";
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
import type { CompanyDTO } from "../../common";
import { AccessWorkspacePage } from "../access/access-workspace";
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
import { FolderMappingsPage } from "../folders/folder-mappings-page";
import { InterventionTemplatesPage } from "../intervention-templates/templates-page";
import { InterventionsPage } from "../interventions/interventions-page";
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
import { MailboxRelations } from "../mail/mailbox-relations";
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
import { ProcessesPage } from "../processes/processes-page";
import { WorkProfilesPage } from "../profiles/work-profiles-page";
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
import { TicketsPage } from "../tickets/tickets-page";
import { CompanyInventory } from "./company-inventory";
import type { CompanyTab } from "./company-tab";
import { DepartmentSection } from "./department-section";
import { PeopleSection } from "./people-section";
import { SiteSection } from "./site-section";
export function CompanySection({
company,
csrfToken,
tab,
initialEntityId,
initialCreate,
}: {
company: CompanyDTO;
csrfToken: string;
tab: CompanyTab;
initialEntityId?: string | undefined;
initialCreate?: boolean | undefined;
}) {
switch (tab) {
case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
case "interventions":
return <InterventionsPage companyId={company.id} csrfToken={csrfToken} />;
case "tickets":
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
case "processes":
return <ProcessesPage company={company} csrfToken={csrfToken} />;
case "remote":
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />;
case "telework":
return <TeleworkAuthorizationsPage company={company} csrfToken={csrfToken} />;
case "people":
return <PeopleSection company={company} csrfToken={csrfToken} initialId={initialEntityId} />;
case "departments":
return <DepartmentSection company={company} csrfToken={csrfToken} />;
case "sites":
return <SiteSection company={company} csrfToken={csrfToken} />;
case "access":
return (
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
);
case "profiles":
return (
<WorkProfilesPage company={company} csrfToken={csrfToken} initialId={initialEntityId} />
);
case "certificates":
return <CertificateConfigurationsPage company={company} csrfToken={csrfToken} />;
case "applications":
return <ApplicationConfigurationsPage company={company} csrfToken={csrfToken} />;
case "printers":
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />;
case "folders":
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
case "mail":
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
case "workstations":
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
case "inventory":
return (
<CompanyInventory
company={company}
csrfToken={csrfToken}
initialCreate={initialCreate}
initialId={initialEntityId}
/>
);
case "resources":
return (
<ResourcesPage
companyId={company.id}
csrfToken={csrfToken}
initialCreate={initialCreate}
initialId={initialEntityId}
relatedContent={(resource) =>
resource.values.integration?.kind === "mailbox" ? (
<MailboxRelations company={company} csrfToken={csrfToken} mailboxId={resource.id} />
) : null
}
/>
);
case "resource-catalog":
return <ResourceCatalogPage csrfToken={csrfToken} scope={company.id} />;
case "assets":
return <AssetCatalogPage csrfToken={csrfToken} scope={company.id} />;
}
}

View File

@ -0,0 +1,22 @@
export type CompanyTab =
| "interventions"
| "intervention-templates"
| "tickets"
| "processes"
| "remote"
| "telework"
| "people"
| "departments"
| "sites"
| "access"
| "resource-catalog"
| "resources"
| "assets"
| "inventory"
| "workstations"
| "certificates"
| "applications"
| "printers"
| "folders"
| "mail"
| "profiles";

View File

@ -33,7 +33,8 @@ async function request(
if (!response.ok) {
throw new Error("DASHBOARD_UNAVAILABLE");
}
return response.json() as Promise<unknown>;
const body: unknown = await response.json();
return body;
}
export const dashboardApi = {
summary: async (signal: AbortSignal, companyId?: string | undefined) =>

View File

@ -16,7 +16,6 @@ import {
WrenchIcon,
} from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -31,6 +30,7 @@ import {
} from "./dashboard-api";
import { BlockState } from "./dashboard-block";
import { DashboardDetailsDialog } from "./dashboard-details-dialog";
import { DashboardPanel } from "./dashboard-panel";
import { DashboardQuickCreate } from "./dashboard-quick-create";
const ISSUE_ICONS = {
@ -40,6 +40,7 @@ const ISSUE_ICONS = {
missing_site: BoxIcon,
inactive_person: UserRoundXIcon,
};
const KIND_ICONS = {
ticket: TicketIcon,
intervention: WrenchIcon,
@ -479,38 +480,3 @@ export function DashboardPage({
</div>
);
}
function DashboardPanel({
title,
icon,
action,
children,
}: {
title: string;
icon?: ReactNode;
action?: () => void;
children: ReactNode;
}) {
const { t } = useTranslation("companies");
return (
<section aria-label={title} className="dashboard-panel min-w-0">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-3">
{icon && (
<span aria-hidden="true" className="[&_svg]:size-5">
{icon}
</span>
)}
{title}
</h2>
{action && (
<Button className="text-primary shrink-0" onClick={action} size="sm" variant="ghost">
{t("dashboard.viewAll")}
<ChevronRightIcon aria-hidden="true" className="size-4" />
</Button>
)}
</div>
{children}
</section>
);
}

View File

@ -0,0 +1,39 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { ChevronRightIcon } from "@repo/shadcn-ui/lib/icons";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
export function DashboardPanel({
title,
icon,
action,
children,
}: {
title: string;
icon?: ReactNode;
action?: () => void;
children: ReactNode;
}) {
const { t } = useTranslation("companies");
return (
<section aria-label={title} className="dashboard-panel min-w-0">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-3">
{icon && (
<span aria-hidden="true" className="[&_svg]:size-5">
{icon}
</span>
)}
{title}
</h2>
{action && (
<Button className="text-primary shrink-0" onClick={action} size="sm" variant="ghost">
{t("dashboard.viewAll")}
<ChevronRightIcon aria-hidden="true" className="size-4" />
</Button>
)}
</div>
{children}
</section>
);
}

View File

@ -0,0 +1,92 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { DepartmentDTO, DepartmentRequestDTO } from "../../common";
import { DepartmentRequestSchema } from "../../common";
import { EntityHelp } from "../access/entity-help";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
export function DepartmentForm({
companyId,
department,
csrfToken,
onSaved,
requireActive = false,
}: {
companyId: string;
department?: DepartmentDTO | undefined;
csrfToken: string;
onSaved: (id: string) => void;
requireActive?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<DepartmentRequestDTO>({
resolver: zodResolver(DepartmentRequestSchema),
defaultValues: { name: department?.name ?? "", active: department?.active ?? true },
});
const mutation = useMutation({
mutationFn: (values: DepartmentRequestDTO) =>
companyApi.department(companyId, department?.id, values, csrfToken),
onSuccess: (response) => {
client.setQueryData(companyKeys.detail(companyId), response);
const saved = department
? response.company.departments.find((item) => item.id === department.id)
: response.company.departments.at(-1);
if (saved && mounted.current) {
onSaved(saved.id);
}
},
});
return (
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="space-y-2">
<Label htmlFor={id}>{t("name")}</Label>
<Input id={id} maxLength={100} required {...form.register("name")} />
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("active")}
disabled={requireActive}
id={`${id}-active`}
onCheckedChange={(checked) =>
form.setValue("active", checked === true, { shouldDirty: true })
}
/>
<Label htmlFor={`${id}-active`}>{t("activeDepartment")}</Label>
</div>
{department && (
<p className="text-sm text-muted-foreground">{t("departmentDeactivationHelp")}</p>
)}
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<EntityHelp kind="department" />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}

View File

@ -1,6 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import {
EntityAction,
EntityListHeader,
@ -8,95 +6,14 @@ import {
EntityStatus,
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, DepartmentDTO, DepartmentRequestDTO } from "../../common";
import { DepartmentRequestSchema } from "../../common";
import { EntityHelp } from "../access/entity-help";
import { companyApi, companyKeys } from "../api/company-api";
import type { CompanyDTO } from "../../common";
import { CompanyFeedback } from "./company-feedback";
import { DepartmentForm } from "./department-form";
export function DepartmentForm({
companyId,
department,
csrfToken,
onSaved,
requireActive = false,
}: {
companyId: string;
department?: DepartmentDTO | undefined;
csrfToken: string;
onSaved: (id: string) => void;
requireActive?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<DepartmentRequestDTO>({
resolver: zodResolver(DepartmentRequestSchema),
defaultValues: { name: department?.name ?? "", active: department?.active ?? true },
});
const mutation = useMutation({
mutationFn: (values: DepartmentRequestDTO) =>
companyApi.department(companyId, department?.id, values, csrfToken),
onSuccess: (response) => {
client.setQueryData(companyKeys.detail(companyId), response);
const saved = department
? response.company.departments.find((item) => item.id === department.id)
: response.company.departments.at(-1);
if (saved && mounted.current) {
onSaved(saved.id);
}
},
});
return (
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="space-y-2">
<Label htmlFor={id}>{t("name")}</Label>
<Input id={id} maxLength={100} required {...form.register("name")} />
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("active")}
disabled={requireActive}
id={`${id}-active`}
onCheckedChange={(checked) =>
form.setValue("active", checked === true, { shouldDirty: true })
}
/>
<Label htmlFor={`${id}-active`}>{t("activeDepartment")}</Label>
</div>
{department && (
<p className="text-sm text-muted-foreground">{t("departmentDeactivationHelp")}</p>
)}
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<EntityHelp kind="department" />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}
export function DepartmentSection({
company,
csrfToken,

View File

@ -0,0 +1,26 @@
import { AssetCatalogPage, BrandCatalogPage } from "@metis/assets/client";
import { ResourceCatalogPage } from "@metis/resources/client";
import { AccessWorkspacePage } from "../access/access-workspace";
import { InterventionTemplatesPage } from "../intervention-templates/templates-page";
export function GlobalCatalogContent({
catalog,
csrfToken,
}: {
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
csrfToken: string;
}) {
switch (catalog) {
case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} />;
case "access":
return <AccessWorkspacePage csrfToken={csrfToken} scope="global" />;
case "assets":
return <AssetCatalogPage csrfToken={csrfToken} scope="global" />;
case "brands":
return <BrandCatalogPage csrfToken={csrfToken} />;
case "resources":
return <ResourceCatalogPage csrfToken={csrfToken} scope="global" />;
}
}

View File

@ -1,5 +1,4 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import {
EntityAction,
@ -13,200 +12,11 @@ import { Label } from "@repo/shadcn-ui/components/label";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, PersonDTO } from "../../common";
import type { CompanyDTO } from "../../common";
import { AssignmentForm } from "./assignment-form";
import { PersonDetails } from "./person-details";
import { PersonForm } from "./person-form";
function PersonDetails({
company,
person,
csrfToken,
onBack,
onEdit,
}: {
company: CompanyDTO;
person: PersonDTO;
csrfToken: string;
onBack: () => void;
onEdit: () => void;
}) {
const { t, i18n } = useTranslation("companies");
const [editor, setEditor] = useState<string | undefined>();
const [historyId, setHistoryId] = useState<string | undefined>();
const assignments = company.assignments.filter((item) => item.personId === person.id);
const selected = assignments.find((item) => item.id === editor);
const history = assignments.find((item) => item.id === historyId);
const departmentName = (id: string) =>
company.departments.find((item) => item.id === id)?.name ?? "—";
return (
<div className="space-y-6">
<Button onClick={onBack} variant="outline">
{t("backToPeople")}
</Button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">{person.name}</h2>
<p className="mt-1 text-muted-foreground">
{t(person.active ? "activePerson" : "inactivePerson")}
{person.isContact ? ` · ${t("isContact")}` : ""}
</p>
</div>
<Button onClick={onEdit} variant="outline">
{t("editPerson")}
</Button>
</div>
<Card>
<CardContent className="grid gap-4 pt-6 sm:grid-cols-3">
<div>
<p className="text-sm text-muted-foreground">{t("generalPosition")}</p>
<p>{person.position ?? "—"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("email")}</p>
<p className="break-all">{person.email ?? "—"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("phone")}</p>
<p>{person.phone ?? "—"}</p>
</div>
</CardContent>
</Card>
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
{!person.active && assignments.some((item) => item.endDate === null) && (
<p className="rounded-md border p-3" role="status">
{t("inactiveMembershipWarning")}
</p>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<EntityListHeader
actions={
<Button disabled={!person.active} onClick={() => setEditor("new")}>
{t("addAssignment")}
</Button>
}
locale={i18n.language}
title={t("departmentAssignments")}
/>
<EntityTable
columns={[
{
id: "department",
label: t("department"),
value: (item) => departmentName(item.departmentId),
hideable: false,
render: (item) => (
<>
{departmentName(item.departmentId)}
{company.departments.find((entry) => entry.id === item.departmentId)?.active ===
false && <p className="text-xs">{t("inactiveDepartment")}</p>}
</>
),
},
{
id: "position",
label: t("position"),
value: (item) => item.position,
render: (item) => <>{item.position ?? "—"}</>,
},
{
id: "start",
label: t("startDate"),
value: (item) => item.startDate,
render: (item) => <>{item.startDate}</>,
},
{
id: "end",
label: t("endDate"),
value: (item) => item.endDate,
render: (item) => <>{item.endDate ?? t("currentAssignment")}</>,
},
{
id: "primary",
label: t("primary"),
value: (item) => item.isPrimary,
render: (item) => <>{item.isPrimary ? t("yes") : "—"}</>,
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
<EntityAction onClick={() => setHistoryId(item.id)}>
{t("history")}
</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noAssignments")}
items={assignments}
locale={i18n.language}
title={t("departmentAssignments")}
/>
{!company.departments.some((item) => item.active) && (
<p className="text-sm text-muted-foreground">{t("createDepartmentFirst")}</p>
)}
</CardContent>
</Card>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open={editor !== undefined}
title={t(selected ? "editAssignment" : "addAssignment")}
>
<AssignmentForm
assignment={selected}
company={company}
csrfToken={csrfToken}
key={editor}
onSaved={() => setEditor(undefined)}
personId={person.id}
/>
</FormDialog>
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setHistoryId(undefined);
}
}}
open={history !== undefined}
title={t("assignmentHistory")}
>
{history && (
<div className="space-y-4">
<p>{departmentName(history.departmentId)}</p>
{[...history.history].reverse().map((revision) => (
<div className="space-y-1 rounded-md border p-3" key={revision.id}>
<p className="text-sm font-medium">
{new Date(revision.recordedAt).toLocaleString(i18n.language)}
</p>
<p>
{t("position")}: {revision.position ?? "—"}
</p>
<p>
{revision.startDate} → {revision.endDate ?? t("currentAssignment")}
</p>
<p>
{t("primaryDepartment")}: {t(revision.isPrimary ? "yes" : "no")}
</p>
</div>
))}
</div>
)}
</FormDialog>
</div>
);
}
export function PeopleSection({
company,
csrfToken,

View File

@ -0,0 +1,205 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, PersonDTO } from "../../common";
import { AssignmentForm } from "./assignment-form";
export function PersonDetails({
company,
person,
csrfToken,
onBack,
onEdit,
}: {
company: CompanyDTO;
person: PersonDTO;
csrfToken: string;
onBack: () => void;
onEdit: () => void;
}) {
const { t, i18n } = useTranslation("companies");
const [editor, setEditor] = useState<string | undefined>();
const [historyId, setHistoryId] = useState<string | undefined>();
const assignments = company.assignments.filter((item) => item.personId === person.id);
const selected = assignments.find((item) => item.id === editor);
const history = assignments.find((item) => item.id === historyId);
const departmentName = (id: string) =>
company.departments.find((item) => item.id === id)?.name ?? "—";
return (
<div className="space-y-6">
<Button onClick={onBack} variant="outline">
{t("backToPeople")}
</Button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">{person.name}</h2>
<p className="mt-1 text-muted-foreground">
{t(person.active ? "activePerson" : "inactivePerson")}
{person.isContact ? ` · ${t("isContact")}` : ""}
</p>
</div>
<Button onClick={onEdit} variant="outline">
{t("editPerson")}
</Button>
</div>
<Card>
<CardContent className="grid gap-4 pt-6 sm:grid-cols-3">
<div>
<p className="text-sm text-muted-foreground">{t("generalPosition")}</p>
<p>{person.position ?? "—"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("email")}</p>
<p className="break-all">{person.email ?? "—"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("phone")}</p>
<p>{person.phone ?? "—"}</p>
</div>
</CardContent>
</Card>
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
{!person.active && assignments.some((item) => item.endDate === null) && (
<p className="rounded-md border p-3" role="status">
{t("inactiveMembershipWarning")}
</p>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<EntityListHeader
actions={
<Button disabled={!person.active} onClick={() => setEditor("new")}>
{t("addAssignment")}
</Button>
}
locale={i18n.language}
title={t("departmentAssignments")}
/>
<EntityTable
columns={[
{
id: "department",
label: t("department"),
value: (item) => departmentName(item.departmentId),
hideable: false,
render: (item) => (
<>
{departmentName(item.departmentId)}
{company.departments.find((entry) => entry.id === item.departmentId)?.active ===
false && <p className="text-xs">{t("inactiveDepartment")}</p>}
</>
),
},
{
id: "position",
label: t("position"),
value: (item) => item.position,
render: (item) => <>{item.position ?? "—"}</>,
},
{
id: "start",
label: t("startDate"),
value: (item) => item.startDate,
render: (item) => <>{item.startDate}</>,
},
{
id: "end",
label: t("endDate"),
value: (item) => item.endDate,
render: (item) => <>{item.endDate ?? t("currentAssignment")}</>,
},
{
id: "primary",
label: t("primary"),
value: (item) => item.isPrimary,
render: (item) => <>{item.isPrimary ? t("yes") : "—"}</>,
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
<EntityAction onClick={() => setHistoryId(item.id)}>
{t("history")}
</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noAssignments")}
items={assignments}
locale={i18n.language}
title={t("departmentAssignments")}
/>
{!company.departments.some((item) => item.active) && (
<p className="text-sm text-muted-foreground">{t("createDepartmentFirst")}</p>
)}
</CardContent>
</Card>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open={editor !== undefined}
title={t(selected ? "editAssignment" : "addAssignment")}
>
<AssignmentForm
assignment={selected}
company={company}
csrfToken={csrfToken}
key={editor}
onSaved={() => setEditor(undefined)}
personId={person.id}
/>
</FormDialog>
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setHistoryId(undefined);
}
}}
open={history !== undefined}
title={t("assignmentHistory")}
>
{history && (
<div className="space-y-4">
<p>{departmentName(history.departmentId)}</p>
{[...history.history].reverse().map((revision) => (
<div className="space-y-1 rounded-md border p-3" key={revision.id}>
<p className="text-sm font-medium">
{new Date(revision.recordedAt).toLocaleString(i18n.language)}
</p>
<p>
{t("position")}: {revision.position ?? "—"}
</p>
<p>
{revision.startDate} → {revision.endDate ?? t("currentAssignment")}
</p>
<p>
{t("primaryDepartment")}: {t(revision.isPrimary ? "yes" : "no")}
</p>
</div>
))}
</div>
)}
</FormDialog>
</div>
);
}

View File

@ -0,0 +1,99 @@
import { Button } from "@repo/shadcn-ui/components/button";
import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { SiteForm } from "./site-form";
export function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t, i18n } = useTranslation("companies");
const [createOpen, setCreateOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | undefined>();
const selected = company.sites.find((site) => site.id === selectedId);
return (
<div className="space-y-6">
<EntityListHeader
actions={<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>}
locale={i18n.language}
title={t("sites")}
/>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (site) => site.name,
hideable: false,
render: (site) => <span className="font-medium">{site.name}</span>,
},
{
id: "address",
label: t("addressOptional"),
value: (site) => site.address,
render: (site) => (
<span className="whitespace-pre-wrap">{site.address ?? t("noAddress")}</span>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (site) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setSelectedId(site.id)}>{t("edit")}</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noSites")}
items={company.sites}
locale={i18n.language}
title={t("sites")}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setCreateOpen}
open={createOpen}
title={t("newSite")}
>
<SiteForm
companyId={company.id}
csrfToken={csrfToken}
onSaved={() => setCreateOpen(false)}
showTitle={false}
/>
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setSelectedId(undefined);
}
}}
open={selected !== undefined}
title={t("editSite")}
>
{selected && (
<SiteForm
companyId={company.id}
csrfToken={csrfToken}
key={selected.id}
onSaved={() => setSelectedId(undefined)}
showTitle={false}
site={selected}
/>
)}
</FormDialog>
</div>
);
}

View File

@ -0,0 +1,75 @@
import { InterventionsPage } from "../interventions/interventions-page";
import { TicketsPage } from "../tickets/tickets-page";
import { CompaniesPage } from "./companies-page";
import type { DashboardNavigation } from "./dashboard-api";
import { DashboardPage } from "./dashboard-page";
import { GlobalCatalogContent } from "./global-catalog-content";
import type { WorkspaceLocation } from "./workspace-location";
export function WorkspaceContent({
area,
navigation,
catalog,
csrfToken,
onNavigate,
onSelectCompany,
}: {
area: WorkspaceLocation["area"];
navigation: WorkspaceLocation;
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
csrfToken: string;
onNavigate: (destination: DashboardNavigation) => void;
onSelectCompany: (id: string) => void;
}) {
switch (area) {
case "interventions":
return (
<InterventionsPage
csrfToken={csrfToken}
{...(navigation.area === "interventions"
? {
companyId: navigation.companyId,
initialId: navigation.id,
initialCreate: navigation.create,
initialPendingOnly: navigation.pendingOnly,
}
: {})}
/>
);
case "tickets":
return (
<TicketsPage
csrfToken={csrfToken}
{...(navigation.area === "tickets"
? {
companyId: navigation.companyId,
initialId: navigation.id,
initialCreate: navigation.create,
initialPendingOnly: navigation.pendingOnly,
}
: {})}
/>
);
case "directory":
return (
<CompaniesPage
csrfToken={csrfToken}
onSelectCompany={onSelectCompany}
{...(navigation.area === "directory"
? {
initialActiveOnly: navigation.activeOnly,
initialCompanyId: navigation.companyId,
initialCreate: navigation.create,
}
: {})}
/>
);
case "catalogs":
return <GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />;
case "company":
return null;
case "home":
return <DashboardPage csrfToken={csrfToken} onNavigate={onNavigate} />;
}
}

View File

@ -0,0 +1,3 @@
import type { DashboardNavigation } from "./dashboard-api";
export type WorkspaceLocation = { area: "home" } | DashboardNavigation | { area: "catalogs" };

View File

@ -19,11 +19,11 @@ import {
FolderMappingInputSchema,
} from "../../common/folder-mappings";
import { accessApi, accessKeys } from "../access/access-api";
import { accessLabel } from "../access/access-labels";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { folderApi, folderKeys } from "./folder-api";
import { FolderFeedback } from "./folder-feedback";
import { FolderMappingTargets } from "./folder-mapping-targets";
import { FolderQuickEditor } from "./folder-quick-editor";
export function FolderMappingForm({
@ -113,52 +113,20 @@ export function FolderMappingForm({
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
<SelectWithAction
action={
mapping || initialProfileId
? undefined
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
}
disabled={!!mapping || !!initialProfileId || !ready}
id={`${prefix}-profile`}
label={t("mail.profile")}
onValueChange={(id) => {
form.setValue("profileId", id);
changeNeed("");
}}
options={(profiles.data?.profiles ?? [])
.filter((item) => item.values.active || item.id === values.profileId)
.map((item) => ({ value: item.id, label: item.values.name }))}
placeholder={t("mail.choose")}
required
value={values.profileId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-need`}>{t("folders.need")}</Label>
<SelectWithAction
action={
mapping
? undefined
: { label: t("folders.newNeed"), onClick: () => setQuick("need") }
}
disabled={!!mapping || !profile || !ready}
id={`${prefix}-need`}
label={t("folders.need")}
onValueChange={changeNeed}
options={needs.map((item) => ({
value: item.id,
label: accessLabel(item, items, company.people),
}))}
placeholder={t("mail.choose")}
required
value={values.needId}
/>
</div>
</div>
<FolderMappingTargets
company={company}
fixed={Boolean(mapping)}
form={form}
initialProfileId={initialProfileId}
items={items}
needs={needs}
onNeedChange={changeNeed}
onQuickCreate={setQuick}
prefix={prefix}
profile={profile}
profiles={profiles.data?.profiles ?? []}
ready={ready}
/>
<div className="space-y-2">
<Label htmlFor={`${prefix}-path`}>{t("folders.path")}</Label>
<Input id={`${prefix}-path`} readOnly value={values.path} />

View File

@ -0,0 +1,88 @@
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { AccessItemDTO, CompanyDTO } from "../../common";
import type { FolderMappingInput } from "../../common/folder-mappings";
import type { WorkProfileDTO } from "../../common/work-profiles";
import { accessLabel } from "../access/access-labels";
export function FolderMappingTargets({
prefix,
form,
profiles,
needs,
items,
company,
profile,
fixed,
initialProfileId,
ready,
onQuickCreate,
onNeedChange,
}: {
prefix: string;
form: UseFormReturn<FolderMappingInput>;
profiles: readonly WorkProfileDTO[];
needs: readonly AccessItemDTO[];
items: readonly AccessItemDTO[];
company: CompanyDTO;
profile: WorkProfileDTO | undefined;
fixed: boolean;
initialProfileId: string;
ready: boolean;
onQuickCreate: (kind: "profile" | "need") => void;
onNeedChange: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const values = form.watch();
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
<SelectWithAction
action={
fixed || initialProfileId
? undefined
: { label: t("profiles.new"), onClick: () => onQuickCreate("profile") }
}
disabled={!!fixed || !!initialProfileId || !ready}
id={`${prefix}-profile`}
label={t("mail.profile")}
onValueChange={(id) => {
form.setValue("profileId", id);
onNeedChange("");
}}
options={profiles
.filter((item) => item.values.active || item.id === values.profileId)
.map((item) => ({ value: item.id, label: item.values.name }))}
placeholder={t("mail.choose")}
required
value={values.profileId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-need`}>{t("folders.need")}</Label>
<SelectWithAction
action={
fixed
? undefined
: { label: t("folders.newNeed"), onClick: () => onQuickCreate("need") }
}
disabled={!!fixed || !profile || !ready}
id={`${prefix}-need`}
label={t("folders.need")}
onValueChange={onNeedChange}
options={needs.map((item) => ({
value: item.id,
label: accessLabel(item, items, company.people),
}))}
placeholder={t("mail.choose")}
required
value={values.needId}
/>
</div>
</div>
);
}

View File

@ -1,9 +1,7 @@
import { useTranslation } from "react-i18next";
import type {
InterventionTemplateDTO,
InterventionTemplateInput,
} from "../../common/intervention-templates";
import type { InterventionTemplateInput } from "../../common/intervention-templates";
export function TemplateSteps({ values }: { values: InterventionTemplateInput }) {
const { t } = useTranslation("companies");
return (
@ -27,24 +25,3 @@ export function TemplateSteps({ values }: { values: InterventionTemplateInput })
</>
);
}
export function TemplateHistory({ template }: { template: InterventionTemplateDTO }) {
const { t, i18n } = useTranslation("companies");
return (
<details>
<summary className="cursor-pointer text-sm">{t("history")}</summary>
<ol className="space-y-3 mt-3">
{[...template.history].reverse().map((revision) => (
<li className="rounded border p-3 space-y-2" key={revision.id}>
<p className="text-sm">
{new Date(revision.recordedAt).toLocaleString(i18n.language)} · {revision.actorName}
</p>
<p className="font-medium">
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<TemplateSteps values={revision.values} />
</li>
))}
</ol>
</details>
);
}

View File

@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { InterventionTemplateApiError } from "./template-api";
export function InterventionTemplateFeedback({
error,
invalid = false,
}: {
error: unknown;
invalid?: boolean;
}) {
const { t } = useTranslation("companies");
const code = invalid
? "INVALID_REQUEST"
: error instanceof InterventionTemplateApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`interventionTemplates.errors.${code}`, {
defaultValue: t("interventionTemplates.errors.INTERNAL_ERROR"),
})}
</p>
) : null;
}

View File

@ -18,7 +18,8 @@ import {
} from "../../common/intervention-templates";
import { InterventionTemplateApiError, interventionTemplateApi } from "./template-api";
import { InterventionTemplateFeedback, InterventionTemplateHelp } from "./template-help";
import { InterventionTemplateFeedback } from "./template-feedback";
import { InterventionTemplateHelp } from "./template-help";
import { TemplateStepFields } from "./template-step-fields";
export function newTemplateStep() {

View File

@ -1,7 +1,6 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
import { InterventionTemplateApiError } from "./template-api";
export function InterventionTemplateHelp() {
const { t } = useTranslation("companies");
return (
@ -15,24 +14,3 @@ export function InterventionTemplateHelp() {
/>
);
}
export function InterventionTemplateFeedback({
error,
invalid = false,
}: {
error: unknown;
invalid?: boolean;
}) {
const { t } = useTranslation("companies");
const code = invalid
? "INVALID_REQUEST"
: error instanceof InterventionTemplateApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`interventionTemplates.errors.${code}`, {
defaultValue: t("interventionTemplates.errors.INTERNAL_ERROR"),
})}
</p>
) : null;
}

View File

@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import type { InterventionTemplateDTO } from "../../common/intervention-templates";
import { TemplateSteps } from "./template-detail";
export function TemplateHistory({ template }: { template: InterventionTemplateDTO }) {
const { t, i18n } = useTranslation("companies");
return (
<details>
<summary className="cursor-pointer text-sm">{t("history")}</summary>
<ol className="space-y-3 mt-3">
{[...template.history].reverse().map((revision) => (
<li className="rounded border p-3 space-y-2" key={revision.id}>
<p className="text-sm">
{new Date(revision.recordedAt).toLocaleString(i18n.language)} · {revision.actorName}
</p>
<p className="font-medium">
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<TemplateSteps values={revision.values} />
</li>
))}
</ol>
</details>
);
}

View File

@ -14,9 +14,10 @@ import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { interventionTemplateApi, interventionTemplateKeys } from "./template-api";
import { TemplateHistory, TemplateSteps } from "./template-detail";
import { TemplateSteps } from "./template-detail";
import { InterventionTemplateFeedback } from "./template-feedback";
import { InterventionTemplateForm, copyTemplateValues } from "./template-form";
import { InterventionTemplateFeedback } from "./template-help";
import { TemplateHistory } from "./template-history";
export function InterventionTemplatesPage({
scope = "global",
csrfToken,

View File

@ -0,0 +1,41 @@
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useTranslation } from "react-i18next";
import type { InterventionListDTO } from "../../common/interventions";
export function InterventionCompanyField({
prefix,
companyId,
companies,
fixed,
onChange,
onNew,
}: {
prefix: string;
companyId: string;
companies: InterventionListDTO["companies"];
fixed: boolean;
onChange: (id: string) => void;
onNew: () => void;
}) {
const { t } = useTranslation("companies");
return (
<div className="space-y-2">
<Label htmlFor={prefix}>{t("tickets.company")}</Label>
{fixed ? (
<p>{companies.find((item) => item.id === companyId)?.name ?? companyId}</p>
) : (
<SelectWithAction
action={{ label: t("newCompany"), onClick: onNew }}
id={prefix}
label={t("tickets.company")}
onValueChange={onChange}
options={companies.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("tickets.choose")}
value={companyId}
/>
)}
</div>
);
}

View File

@ -1,15 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
InterventionDTO,
InterventionInput,
InterventionListDTO,
} from "../../common/interventions";
import type { TicketLookupsDTO } from "../../common/tickets";
import { ticketTargetOptions } from "../tickets/ticket-targets";
import type { InterventionDTO, InterventionListDTO } from "../../common/interventions";
import { interventionApi, interventionKeys } from "./intervention-api";
import { InterventionSnapshot } from "./intervention-snapshot";
export function stepCompletion(intervention: InterventionDTO, stepId: string) {
let completed: InterventionDTO["history"][number] | undefined;
@ -26,92 +21,7 @@ export function stepCompletion(intervention: InterventionDTO, stepId: string) {
}
return completed;
}
function InterventionSnapshot({
values,
data,
lookups,
}: {
values: InterventionInput;
data: InterventionListDTO;
lookups: TicketLookupsDTO | undefined;
}) {
const { t, i18n } = useTranslation("companies");
const date = (value: string | null) =>
value ? new Date(value).toLocaleString(i18n.language) : "—";
return (
<div className="space-y-3">
<p className="font-medium">
{values.subject} · {t(`interventions.states.${values.state}`)}
</p>
<dl className="grid sm:grid-cols-2 gap-2 text-sm">
<div>
<dt>{t("interventions.assignee")}</dt>
<dd>
{data.technicians.find((item) => item.id === values.assigneeId)?.name ??
t("interventions.unassigned")}
</dd>
</div>
<div>
<dt>{t("interventions.modality")}</dt>
<dd>{t(`interventions.modalities.${values.modality}`)}</dd>
</div>
<div>
<dt>{t("interventions.plannedDate")}</dt>
<dd>{values.plannedDate ?? "—"}</dd>
</div>
<div>
<dt>{t("interventions.minutes")}</dt>
<dd>{values.minutes ?? t("interventions.unknownTime")}</dd>
</div>
<div>
<dt>{t("interventions.startedAt")}</dt>
<dd>{date(values.startedAt)}</dd>
</div>
<div>
<dt>{t("interventions.endedAt")}</dt>
<dd>{date(values.endedAt)}</dd>
</div>
</dl>
{(["diagnosis", "workPerformed", "resolution"] as const).map(
(field) =>
values[field] && (
<div key={field}>
<h4 className="font-medium text-sm">{t(`interventions.${field}`)}</h4>
<p className="whitespace-pre-wrap break-words text-sm">{values[field]}</p>
</div>
)
)}
{values.affected.length > 0 && (
<ul className="text-sm">
{values.affected.map((target) => (
<li key={target.kind + target.targetId}>
{t(`tickets.${target.kind}`)} ·{" "}
{lookups
? (ticketTargetOptions(lookups, target.kind).find(
(item) => item.id === target.targetId
)?.name ?? target.targetId)
: target.targetId}
</li>
))}
</ul>
)}
{values.steps.length > 0 && (
<ol className="list-decimal pl-5 space-y-2 text-sm">
{values.steps.map((step) => (
<li key={step.id}>
<p>
{step.title} · {t(`interventions.stepStates.${step.state}`)}
{step.required ? ` · ${t("interventionTemplates.required")}` : ""}
</p>
{step.instructions && <p className="whitespace-pre-wrap">{step.instructions}</p>}
{step.notes && <p className="whitespace-pre-wrap">{step.notes}</p>}
</li>
))}
</ol>
)}
</div>
);
}
export function InterventionDetail({
intervention,
data,

View File

@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { InterventionApiError } from "./intervention-api";
export function InterventionFeedback({
error,
invalid = false,
}: {
error: unknown;
invalid?: boolean;
}) {
const { t } = useTranslation("companies");
const code = invalid
? "INVALID_REQUEST"
: error instanceof InterventionApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`interventions.errors.${code}`, {
defaultValue: t("interventions.errors.INTERNAL_ERROR"),
})}
</p>
) : null;
}

View File

@ -24,8 +24,10 @@ import { TicketQuickForms } from "../tickets/ticket-quick-forms";
import { TicketTargets } from "../tickets/ticket-targets";
import { InterventionApiError, interventionApi, interventionKeys } from "./intervention-api";
import { InterventionCompanyField } from "./intervention-company-field";
import { InterventionFeedback } from "./intervention-feedback";
import { InterventionWorkFields } from "./intervention-fields";
import { InterventionFeedback, InterventionHelp } from "./intervention-help";
import { InterventionHelp } from "./intervention-help";
import { InterventionProcedureFields } from "./intervention-procedure-fields";
import { InterventionSteps } from "./intervention-steps";
@ -47,6 +49,7 @@ export function emptyIntervention(ticket?: TicketDTO): InterventionInput {
steps: [],
};
}
export function InterventionForm({
scope,
intervention,
@ -360,38 +363,3 @@ function isCompanyFixed(
) {
return Boolean(scope || intervention || ticket);
}
function InterventionCompanyField({
prefix,
companyId,
companies,
fixed,
onChange,
onNew,
}: {
prefix: string;
companyId: string;
companies: InterventionListDTO["companies"];
fixed: boolean;
onChange: (id: string) => void;
onNew: () => void;
}) {
const { t } = useTranslation("companies");
return (
<div className="space-y-2">
<Label htmlFor={prefix}>{t("tickets.company")}</Label>
{fixed ? (
<p>{companies.find((item) => item.id === companyId)?.name ?? companyId}</p>
) : (
<SelectWithAction
action={{ label: t("newCompany"), onClick: onNew }}
id={prefix}
label={t("tickets.company")}
onValueChange={onChange}
options={companies.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("tickets.choose")}
value={companyId}
/>
)}
</div>
);
}

View File

@ -1,7 +1,6 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
import { InterventionApiError } from "./intervention-api";
export function InterventionHelp() {
const { t } = useTranslation("companies");
return (
@ -15,24 +14,3 @@ export function InterventionHelp() {
/>
);
}
export function InterventionFeedback({
error,
invalid = false,
}: {
error: unknown;
invalid?: boolean;
}) {
const { t } = useTranslation("companies");
const code = invalid
? "INVALID_REQUEST"
: error instanceof InterventionApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`interventions.errors.${code}`, {
defaultValue: t("interventions.errors.INTERNAL_ERROR"),
})}
</p>
) : null;
}

View File

@ -0,0 +1,92 @@
import { useTranslation } from "react-i18next";
import type { InterventionInput, InterventionListDTO } from "../../common/interventions";
import type { TicketLookupsDTO } from "../../common/tickets";
import { ticketTargetOptions } from "../tickets/ticket-targets";
export function InterventionSnapshot({
values,
data,
lookups,
}: {
values: InterventionInput;
data: InterventionListDTO;
lookups: TicketLookupsDTO | undefined;
}) {
const { t, i18n } = useTranslation("companies");
const date = (value: string | null) =>
value ? new Date(value).toLocaleString(i18n.language) : "—";
return (
<div className="space-y-3">
<p className="font-medium">
{values.subject} · {t(`interventions.states.${values.state}`)}
</p>
<dl className="grid sm:grid-cols-2 gap-2 text-sm">
<div>
<dt>{t("interventions.assignee")}</dt>
<dd>
{data.technicians.find((item) => item.id === values.assigneeId)?.name ??
t("interventions.unassigned")}
</dd>
</div>
<div>
<dt>{t("interventions.modality")}</dt>
<dd>{t(`interventions.modalities.${values.modality}`)}</dd>
</div>
<div>
<dt>{t("interventions.plannedDate")}</dt>
<dd>{values.plannedDate ?? "—"}</dd>
</div>
<div>
<dt>{t("interventions.minutes")}</dt>
<dd>{values.minutes ?? t("interventions.unknownTime")}</dd>
</div>
<div>
<dt>{t("interventions.startedAt")}</dt>
<dd>{date(values.startedAt)}</dd>
</div>
<div>
<dt>{t("interventions.endedAt")}</dt>
<dd>{date(values.endedAt)}</dd>
</div>
</dl>
{(["diagnosis", "workPerformed", "resolution"] as const).map(
(field) =>
values[field] && (
<div key={field}>
<h4 className="font-medium text-sm">{t(`interventions.${field}`)}</h4>
<p className="whitespace-pre-wrap break-words text-sm">{values[field]}</p>
</div>
)
)}
{values.affected.length > 0 && (
<ul className="text-sm">
{values.affected.map((target) => (
<li key={target.kind + target.targetId}>
{t(`tickets.${target.kind}`)} ·{" "}
{lookups
? (ticketTargetOptions(lookups, target.kind).find(
(item) => item.id === target.targetId
)?.name ?? target.targetId)
: target.targetId}
</li>
))}
</ul>
)}
{values.steps.length > 0 && (
<ol className="list-decimal pl-5 space-y-2 text-sm">
{values.steps.map((step) => (
<li key={step.id}>
<p>
{step.title} · {t(`interventions.stepStates.${step.state}`)}
{step.required ? ` · ${t("interventionTemplates.required")}` : ""}
</p>
{step.instructions && <p className="whitespace-pre-wrap">{step.instructions}</p>}
{step.notes && <p className="whitespace-pre-wrap">{step.notes}</p>}
</li>
))}
</ol>
)}
</div>
);
}

View File

@ -16,8 +16,8 @@ import { type InterventionDTO, InterventionStateSchema } from "../../common/inte
import { interventionApi, interventionKeys } from "./intervention-api";
import { InterventionDetail } from "./intervention-detail";
import { InterventionFeedback } from "./intervention-feedback";
import { InterventionForm } from "./intervention-form";
import { InterventionFeedback } from "./intervention-help";
export type InterventionFilters = {
search: string;
company: string;
@ -79,11 +79,10 @@ export function InterventionsPage({
assignee: "",
});
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
const [initialViewId, setInitialViewId] = useState(initialId);
const [viewId, setViewId] = useState(initialId);
const [open, setOpen] = useState(initialCreate);
const [edit, setEdit] = useState<InterventionDTO | undefined>();
const [view, setView] = useState<InterventionDTO | undefined>();
const currentView = selectedDetail(view, query.data?.interventions, initialViewId);
const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId);
const items = filterInterventions(query.data?.interventions ?? [], filters, pendingOnly);
const date = (value: string | undefined) => formatDate(value, i18n.language);
return (
@ -132,7 +131,7 @@ export function InterventionsPage({
render: (intervention) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(intervention)}
onClick={() => setViewId(intervention.id)}
type="button"
>
{intervention.reference}
@ -147,7 +146,7 @@ export function InterventionsPage({
render: (intervention) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(intervention)}
onClick={() => setViewId(intervention.id)}
type="button"
>
{intervention.values.subject}
@ -233,7 +232,7 @@ export function InterventionsPage({
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(intervention)}>
<EntityAction onClick={() => setViewId(intervention.id)}>
{t("interventions.details")}
</EntityAction>
<EntityAction
@ -343,8 +342,7 @@ export function InterventionsPage({
cancelLabel={t("cancel")}
onOpenChange={(value) => {
if (!value) {
setView(undefined);
setInitialViewId(undefined);
setViewId(undefined);
}
}}
open={Boolean(currentView)}
@ -376,11 +374,3 @@ function formatDate(value: string | undefined, language: string) {
)
: "";
}
function selectedDetail(
view: InterventionDTO | undefined,
items: InterventionDTO[] | undefined,
id: string | undefined
) {
return view ?? items?.find((item) => item.id === id);
}

View File

@ -0,0 +1,50 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { TicketDTO } from "../../common/tickets";
import { interventionApi, interventionKeys } from "./intervention-api";
import { InterventionFeedback } from "./intervention-feedback";
import { InterventionForm } from "./intervention-form";
export function TicketInterventionForm({
ticket,
csrfToken,
onSaved,
}: {
ticket: TicketDTO;
csrfToken: string;
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const query = useQuery({
queryKey: interventionKeys.list(ticket.companyId),
queryFn: () => interventionApi.list(ticket.companyId),
});
return (
<>
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
<InterventionFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
type="button"
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && !query.isError && (
<InterventionForm
csrfToken={csrfToken}
data={query.data}
initialTicket={ticket}
onSaved={onSaved}
/>
)}
</>
);
}

View File

@ -1,52 +1,9 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { TicketDTO } from "../../common/tickets";
import { interventionApi, interventionKeys } from "./intervention-api";
import { InterventionForm } from "./intervention-form";
import { InterventionFeedback } from "./intervention-help";
export function TicketInterventionForm({
ticket,
csrfToken,
onSaved,
}: {
ticket: TicketDTO;
csrfToken: string;
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const query = useQuery({
queryKey: interventionKeys.list(ticket.companyId),
queryFn: () => interventionApi.list(ticket.companyId),
});
return (
<>
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
<InterventionFeedback error={query.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
type="button"
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && !query.isError && (
<InterventionForm
csrfToken={csrfToken}
data={query.data}
initialTicket={ticket}
onSaved={onSaved}
/>
)}
</>
);
}
import { InterventionFeedback } from "./intervention-feedback";
export function TicketInterventions({
companyId,
ticketId,

View File

@ -2,9 +2,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
@ -21,13 +20,13 @@ import {
import type { MailboxDTO, MailboxUseDTO } from "../../common/mailboxes";
import type { WorkProfileDTO } from "../../common/work-profiles";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { WorkProfileForm } from "../profiles/work-profile-form";
import { mailApi, mailKeys } from "./mail-api";
import { MailConfigurationTargets } from "./mail-configuration-targets";
import { MailFeedback } from "./mail-feedback";
import { MailQuickEditor } from "./mail-quick-editor";
import { mailboxApi, mailboxKeys } from "./mailbox-api";
import { MailboxForm } from "./mailbox-form";
import { MailboxUseForm } from "./mailbox-use-form";
import { MailboxUsagePrompt } from "./mailbox-usage-prompt";
export function MailConfigurationForm({
company,
@ -110,97 +109,31 @@ export function MailConfigurationForm({
{assetLabel(legacy.assetId)}
</p>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
<SelectWithAction
action={
fixed || initialProfileId
? undefined
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
}
disabled={fixed || !!initialProfileId || !ready}
id={`${prefix}-profile`}
label={t("mail.profile")}
onValueChange={(id) => form.setValue("profileId", id)}
options={profileOptions(
profiles.data?.profiles ?? [],
values.profileId,
legacy
).map((item) => ({
value: item.id,
label:
profileLabel(item.id) +
" · " +
personLabel(item.values.personId) +
" · " +
assetLabel(item.values.assetId),
}))}
placeholder={t("mail.choose")}
required
value={values.profileId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-mailbox`}>{t("mailbox.titleSingle")}</Label>
<SelectWithAction
action={
fixed
? undefined
: { label: t("mailbox.new"), onClick: () => setQuick("mailbox") }
}
disabled={fixed || !ready}
id={`${prefix}-mailbox`}
label={t("mailbox.titleSingle")}
onValueChange={(id) => form.setValue("mailboxId", id)}
options={mailboxOptions(
mailboxes.data?.mailboxes ?? [],
values.mailboxId,
legacy
).map((item) => ({ value: item.id, label: item.values.address }))}
placeholder={t("mail.choose")}
required
value={values.mailboxId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-method`}>{t("mail.method")}</Label>
<SelectWithAction
id={`${prefix}-method`}
label={t("mail.method")}
onValueChange={(value) => {
const parsed = MailConfigurationInputSchema.shape.method.safeParse(value);
if (parsed.success) {
form.setValue("method", parsed.data);
}
}}
options={MailConfigurationInputSchema.shape.method.options.map((method) => ({
value: method,
label: t(`mail.${method}`),
}))}
placeholder={t("mail.choose")}
required
value={values.method}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={t("mail.state")}
onValueChange={(value) => {
const parsed = MailConfigurationInputSchema.shape.state.safeParse(value);
if (parsed.success) {
form.setValue("state", parsed.data);
}
}}
options={states.map((state) => ({ value: state, label: t(`mail.${state}`) }))}
placeholder={t("mail.choose")}
required
value={values.state}
/>
</div>
</div>
<MailConfigurationTargets
fixed={fixed}
form={form}
initialProfileId={initialProfileId}
mailboxes={mailboxOptions(
mailboxes.data?.mailboxes ?? [],
values.mailboxId,
legacy
).map((item) => ({ value: item.id, label: item.values.address }))}
onQuickCreate={setQuick}
prefix={prefix}
profiles={profileOptions(profiles.data?.profiles ?? [], values.profileId, legacy).map(
(item) => ({
value: item.id,
label:
profileLabel(item.id) +
" · " +
personLabel(item.values.personId) +
" · " +
assetLabel(item.values.assetId),
})
)}
ready={ready}
states={states}
/>
<MailboxUsagePrompt
authorized={authorized ?? false}
mailbox={selectedMailbox}
@ -273,74 +206,10 @@ function configurationDefaults(
};
}
function MailQuickEditor({
kind,
company,
csrfToken,
profile,
mailbox,
initialAddress,
onClose,
onCreated,
}: {
kind: "profile" | "mailbox" | "use";
company: CompanyDTO;
csrfToken: string;
profile: WorkProfileDTO | undefined;
mailbox: MailboxDTO | undefined;
initialAddress: string;
onClose: () => void;
onCreated: (field: "profileId" | "mailboxId", id: string) => void;
}) {
const { t } = useTranslation("companies");
const titles = { mailbox: "mailbox.new", profile: "profiles.new", use: "mailbox.linkPerson" };
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(titles[kind])}
wide
>
{kind === "mailbox" && (
<MailboxForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
initialAddress={initialAddress}
onSaved={(id) => onCreated("mailboxId", id)}
/>
)}
{kind === "profile" && (
<WorkProfileForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
onSaved={(id) => onCreated("profileId", id)}
/>
)}
{kind === "use" && profile && mailbox && (
<MailboxUseForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
initialMailboxId={mailbox.id}
initialPersonId={profile.values.personId}
onSaved={onClose}
/>
)}
</FormDialog>
);
}
function legacyOf(existing: MailConfigurationDTO["values"] | undefined) {
return existing && "personId" in existing ? existing : undefined;
}
function profileOptions(
items: readonly WorkProfileDTO[],
selected: string,
@ -353,6 +222,7 @@ function profileOptions(
(item.values.personId === legacy.personId && item.values.assetId === legacy.assetId))
);
}
function mailboxOptions(
items: readonly MailboxDTO[],
selected: string,
@ -370,30 +240,6 @@ function configurationStates(configuration: MailConfigurationDTO | undefined) {
? MAIL_STATE_TRANSITIONS[configuration.values.state]
: ["pending", "configured"];
}
function MailboxUsagePrompt({
profile,
mailbox,
authorized,
onRegister,
}: {
profile: WorkProfileDTO | undefined;
mailbox: MailboxDTO | undefined;
authorized: boolean;
onRegister: () => void;
}) {
const { t } = useTranslation("companies");
if (!(profile && mailbox) || authorized) {
return null;
}
return (
<div className="rounded-md border p-3 text-sm space-y-2">
<p>{t("mailbox.useMissing")}</p>
<Button onClick={onRegister} type="button" variant="outline">
{t(mailbox.values.kind === "corporate" ? "mailbox.authorize" : "mailbox.recordUse")}
</Button>
</div>
);
}
function hasMailboxUse(
uses: readonly MailboxUseDTO[],

View File

@ -0,0 +1,109 @@
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type MailConfigurationInput,
MailConfigurationInputSchema,
} from "../../common/mail-configurations";
export function MailConfigurationTargets({
prefix,
form,
fixed,
initialProfileId,
ready,
states,
profiles,
mailboxes,
onQuickCreate,
}: {
prefix: string;
form: UseFormReturn<MailConfigurationInput>;
fixed: boolean;
initialProfileId: string;
ready: boolean;
states: readonly string[];
profiles: readonly { value: string; label: string }[];
mailboxes: readonly { value: string; label: string }[];
onQuickCreate: (kind: "profile" | "mailbox") => void;
}) {
const { t } = useTranslation("companies");
const values = form.watch();
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
<SelectWithAction
action={
fixed || initialProfileId
? undefined
: { label: t("profiles.new"), onClick: () => onQuickCreate("profile") }
}
disabled={fixed || !!initialProfileId || !ready}
id={`${prefix}-profile`}
label={t("mail.profile")}
onValueChange={(id) => form.setValue("profileId", id)}
options={profiles}
placeholder={t("mail.choose")}
required
value={values.profileId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-mailbox`}>{t("mailbox.titleSingle")}</Label>
<SelectWithAction
action={
fixed ? undefined : { label: t("mailbox.new"), onClick: () => onQuickCreate("mailbox") }
}
disabled={fixed || !ready}
id={`${prefix}-mailbox`}
label={t("mailbox.titleSingle")}
onValueChange={(id) => form.setValue("mailboxId", id)}
options={mailboxes}
placeholder={t("mail.choose")}
required
value={values.mailboxId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-method`}>{t("mail.method")}</Label>
<SelectWithAction
id={`${prefix}-method`}
label={t("mail.method")}
onValueChange={(value) => {
const parsed = MailConfigurationInputSchema.shape.method.safeParse(value);
if (parsed.success) {
form.setValue("method", parsed.data);
}
}}
options={MailConfigurationInputSchema.shape.method.options.map((method) => ({
value: method,
label: t(`mail.${method}`),
}))}
placeholder={t("mail.choose")}
required
value={values.method}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={t("mail.state")}
onValueChange={(value) => {
const parsed = MailConfigurationInputSchema.shape.state.safeParse(value);
if (parsed.success) {
form.setValue("state", parsed.data);
}
}}
options={states.map((state) => ({ value: state, label: t(`mail.${state}`) }))}
placeholder={t("mail.choose")}
required
value={values.state}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import type { MailboxDTO } from "../../common/mailboxes";
import type { WorkProfileDTO } from "../../common/work-profiles";
import { WorkProfileForm } from "../profiles/work-profile-form";
import { MailboxForm } from "./mailbox-form";
import { MailboxUseForm } from "./mailbox-use-form";
export function MailQuickEditor({
kind,
company,
csrfToken,
profile,
mailbox,
initialAddress,
onClose,
onCreated,
}: {
kind: "profile" | "mailbox" | "use";
company: CompanyDTO;
csrfToken: string;
profile: WorkProfileDTO | undefined;
mailbox: MailboxDTO | undefined;
initialAddress: string;
onClose: () => void;
onCreated: (field: "profileId" | "mailboxId", id: string) => void;
}) {
const { t } = useTranslation("companies");
const titles = { mailbox: "mailbox.new", profile: "profiles.new", use: "mailbox.linkPerson" };
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(titles[kind])}
wide
>
{kind === "mailbox" && (
<MailboxForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
initialAddress={initialAddress}
onSaved={(id) => onCreated("mailboxId", id)}
/>
)}
{kind === "profile" && (
<WorkProfileForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
onSaved={(id) => onCreated("profileId", id)}
/>
)}
{kind === "use" && profile && mailbox && (
<MailboxUseForm
allowQuickCreate={false}
company={company}
csrfToken={csrfToken}
initialMailboxId={mailbox.id}
initialPersonId={profile.values.personId}
onSaved={onClose}
/>
)}
</FormDialog>
);
}

View File

@ -0,0 +1,30 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import type { MailboxDTO } from "../../common/mailboxes";
import type { WorkProfileDTO } from "../../common/work-profiles";
export function MailboxUsagePrompt({
profile,
mailbox,
authorized,
onRegister,
}: {
profile: WorkProfileDTO | undefined;
mailbox: MailboxDTO | undefined;
authorized: boolean;
onRegister: () => void;
}) {
const { t } = useTranslation("companies");
if (!(profile && mailbox) || authorized) {
return null;
}
return (
<div className="rounded-md border p-3 text-sm space-y-2">
<p>{t("mailbox.useMissing")}</p>
<Button onClick={onRegister} type="button" variant="outline">
{t(mailbox.values.kind === "corporate" ? "mailbox.authorize" : "mailbox.recordUse")}
</Button>
</div>
);
}

View File

@ -12,12 +12,14 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, PrinterConfigurationDTO } from "../../common";
import type { CompanyDTO } from "../../common";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { printerApi, printerKeys } from "./printer-api";
import { PrinterConfigurationForm } from "./printer-configuration-form";
import { PrinterFeedback } from "./printer-feedback";
import { PrinterHistory } from "./printer-history";
export function PrinterConfigurationsPage({
company,
csrfToken,
@ -215,28 +217,3 @@ export function PrinterConfigurationsPage({
</div>
);
}
function PrinterHistory({ configuration }: { configuration: PrinterConfigurationDTO }) {
const { t, i18n } = useTranslation("companies");
return (
<ol className="space-y-3">
{[...configuration.history].reverse().map((revision) => (
<li className="rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>
{t(`printers.${revision.values.connection}`)} · {t(`mail.${revision.values.state}`)}
</p>
<p>{t(revision.values.isDefault ? "printers.default" : "printers.notDefault")}</p>
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import type { PrinterConfigurationDTO } from "../../common";
export function PrinterHistory({ configuration }: { configuration: PrinterConfigurationDTO }) {
const { t, i18n } = useTranslation("companies");
return (
<ol className="space-y-3">
{[...configuration.history].reverse().map((revision) => (
<li className="rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p>
{t(`printers.${revision.values.connection}`)} · {t(`mail.${revision.values.state}`)}
</p>
<p>{t(revision.values.isDefault ? "printers.default" : "printers.notDefault")}</p>
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
)}
</li>
))}
</ol>
);
}

View File

@ -0,0 +1,239 @@
import { AssetSupport } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, WorkProfileDTO } from "../../common";
import type { RemoteConnectionDTO } from "../../common/remote-connections";
import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations";
import { accessLabel } from "../access/access-labels";
import { RemoteSummary } from "../remote/remote-summary";
import { TeleworkAuthorizationSummary } from "../telework/telework-authorization-summary";
import { EnvironmentSection } from "./environment-section";
import { HardwareCharacteristics } from "./hardware-characteristics";
import {
type EnvironmentSources,
type ProfileConfigurationKind,
buildEnvironment,
} from "./work-environment.model";
export function EnvironmentContent({
connections,
authorizations,
company,
profile,
profiles,
sources,
csrfToken,
onManage,
onProfile,
}: {
company: CompanyDTO;
profile: WorkProfileDTO;
connections: readonly RemoteConnectionDTO[];
authorizations: readonly TeleworkAuthorizationDTO[];
profiles: readonly WorkProfileDTO[];
sources: EnvironmentSources;
csrfToken: string;
onManage: (kind: ProfileConfigurationKind | "telework" | "remote") => void;
onProfile: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const [attention, setAttention] = useState(false);
const model = buildEnvironment(profile, sources);
const remoteConnections = connections.filter(
(item) =>
item.companyId === company.id &&
item.values.personId === profile.values.personId &&
!["removed", "never_configured"].includes(item.values.state)
);
const person = company.people.find((item) => item.id === profile.values.personId);
const account = sources.access.find((item) => item.id === profile.values.accountId);
const others = profiles.filter(
(item) =>
item.companyId === company.id &&
item.values.active &&
item.values.personId === profile.values.personId &&
item.id !== profile.id
);
return (
<div className="space-y-5">
<header className="space-y-2">
<h2 className="text-2xl font-semibold">
{t("environment.title")} · {person?.name ?? profile.values.name}
</h2>
<p className="text-muted-foreground">
{profile.values.name} · {model.host?.values.name ?? profile.values.assetId}
</p>
<p className="text-sm">{t("environment.description")}</p>
</header>
{!profile.values.active && (
<p className="rounded-md border border-amber-500 p-3 text-sm" role="status">
{t("environment.archived")}
</p>
)}
{profile.needsReview && (
<p className="text-sm text-amber-700">{t("environment.reviewProfile")}</p>
)}
{person && !person.active && (
<p className="text-sm text-amber-700">{t("environment.personInactive")}</p>
)}
{others.length > 0 && (
<nav aria-label={t("environment.samePerson")} className="flex flex-wrap items-center gap-2">
<span className="text-sm">{t("environment.samePerson")}</span>
{others.map((item) => (
<Button key={item.id} onClick={() => onProfile(item.id)} size="sm" variant="outline">
{item.values.name}
</Button>
))}
</nav>
)}
<Card>
<CardContent className="space-y-4 pt-5">
<h3 className="font-semibold">{t("environment.equipment")}</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">{t("environment.device")}</dt>
<dd className="font-medium">{model.host?.values.name ?? profile.values.assetId}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("profiles.account")}</dt>
<dd>
{account
? accessLabel(account, sources.access, company.people)
: t("profiles.noAccount")}
</dd>
</div>
{person?.position && (
<div>
<dt className="text-muted-foreground">{t("environment.position")}</dt>
<dd>{person.position}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground">{t("environment.workstation")}</dt>
<dd>
{model.stations.map((station) => station.values.name).join(" · ") ||
t("environment.noStation")}
</dd>
</div>
</dl>
{model.host && <HardwareCharacteristics asset={model.host} />}
{model.stationAssets.length > 0 && (
<ul className="grid gap-2 sm:grid-cols-2">
{model.stationAssets.map((asset) => {
const shared = model.stations.some((station) =>
station.values.sharedAssetIds.includes(asset.id)
);
return (
<li className="rounded-md border p-3 text-sm" key={asset.id}>
<span className="font-medium">{asset.values.name}</span>
<p className="text-muted-foreground">
{asset.specification.templateName} ·{" "}
{t(shared ? "environment.shared" : "environment.exclusive")}
</p>
{asset.values.status === "retired" && (
<p className="text-amber-700">{t("environment.retiredAsset")}</p>
)}
</li>
);
})}
</ul>
)}
{profile.values.notes && (
<p className="whitespace-pre-wrap text-sm">{profile.values.notes}</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3 pt-5">
<header className="flex items-center justify-between gap-3">
<h3 className="font-semibold">{t("telework.title")}</h3>
<Button onClick={() => onManage("telework")} size="sm" variant="outline">
{t("telework.manage")}
</Button>
</header>
<TeleworkAuthorizationSummary
authorization={authorizations.find(
(item) =>
item.companyId === company.id && item.values.personId === profile.values.personId
)}
/>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3 pt-5">
<header className="flex items-center justify-between gap-3">
<h3 className="font-semibold">{t("remote.title")}</h3>
<Button onClick={() => onManage("remote")} size="sm" variant="outline">
{t("remote.manage")}
</Button>
</header>
{!remoteConnections.length && (
<p className="text-sm text-muted-foreground">{t("remote.empty")}</p>
)}
<ul className="divide-y">
{remoteConnections.map((item) => (
<li className="py-3" key={item.id}>
<RemoteSummary
assets={sources.assets}
company={company}
connection={item}
resources={sources.resources}
/>
</li>
))}
</ul>
</CardContent>
</Card>
<AssetSupport
assetId={profile.values.assetId}
companyId={company.id}
csrfToken={csrfToken}
retired={model.host?.values.status === "retired"}
/>
<section aria-label={t("environment.configurations")} className="space-y-4">
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{(["configured", "pending", "removal", "review"] as const).map((key) => (
<div className="rounded-md border p-3" key={key}>
<dt className="text-sm text-muted-foreground">{t(`environment.${key}`)}</dt>
<dd className="text-2xl font-semibold">{model.counts[key]}</dd>
</div>
))}
</dl>
<label className="flex items-center gap-2 text-sm">
<input
checked={attention}
onChange={(event) => setAttention(event.target.checked)}
type="checkbox"
/>
{t("environment.onlyAttention")}
</label>
{model.unlinkedMail > 0 && (
<p className="text-sm text-amber-700">
{t("environment.unlinkedMail", { count: model.unlinkedMail })}
</p>
)}
<div className="grid items-start gap-4 xl:grid-cols-2">
{(["mail", "folders", "printers", "applications", "certificates"] as const).map(
(kind) => (
<EnvironmentSection
attention={attention}
key={kind}
kind={kind}
onManage={() => onManage(kind)}
rows={model.rows.filter(
(item) =>
item.kind === kind && (!attention || item.state !== "configured" || item.review)
)}
/>
)
)}
</div>
</section>
<p className="text-sm text-muted-foreground">{t("environment.unknown")}</p>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { useTranslation } from "react-i18next";
import type { EnvironmentRow, ProfileConfigurationKind } from "./work-environment.model";
export function EnvironmentSection({
attention,
kind,
rows,
onManage,
}: {
attention: boolean;
kind: ProfileConfigurationKind;
rows: readonly EnvironmentRow[];
onManage: () => void;
}) {
const { t } = useTranslation("companies");
return (
<Card>
<CardContent className="pt-5">
<header className="mb-3 flex items-center justify-between gap-3">
<h3 className="font-semibold">{t(`${kind}.title`)}</h3>
<Button
aria-label={t("environment.manageSection", { section: t(`${kind}.title`) })}
onClick={onManage}
size="sm"
variant="outline"
>
{t("environment.manage")}
</Button>
</header>
{!rows.length && (
<p className="py-2 text-sm text-muted-foreground">
{t(attention ? "environment.noAttention" : "environment.empty")}
</p>
)}
<ul className="divide-y">
{rows.map((row) => (
<li className="space-y-2 py-3 text-sm" key={row.id}>
<div className="flex flex-wrap justify-between gap-2">
<h4 className="font-medium break-words">{row.name}</h4>
<span
className={row.state === "configured" ? "text-emerald-700" : "text-amber-700"}
>
{t(`mail.${row.state}`)}
</span>
</div>
<dl>
{row.details.map((detail) => (
<div className="break-words" key={detail.label}>
<dt className="inline text-muted-foreground">{t(detail.label)}: </dt>
<dd className="inline">{detail.translated ? t(detail.value) : detail.value}</dd>
</div>
))}
</dl>
{row.review && <p className="text-amber-700">{t("environment.needsReview")}</p>}
{row.notes && (
<p className="whitespace-pre-wrap text-muted-foreground">{row.notes}</p>
)}
</li>
))}
</ul>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,26 @@
import type { AssetDTO } from "@metis/assets/common";
import { useTranslation } from "react-i18next";
export function HardwareCharacteristics({ asset }: { asset: AssetDTO }) {
const { t } = useTranslation("companies");
return (
<dl className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
{asset.specification.fields.flatMap((field) => {
const value = asset.values.characteristics[field.fieldId];
if (value === undefined || value === null || value === "") {
return [];
}
const text = typeof value === "boolean" ? t(value ? "yes" : "no") : String(value);
return [
<div key={field.fieldId}>
<dt className="text-muted-foreground">
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</dt>
<dd className="break-words">{text}</dd>
</div>,
];
})}
</dl>
);
}

View File

@ -0,0 +1,66 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
import { FolderMappingsPage } from "../folders/folder-mappings-page";
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
import type { ProfileSelection } from "./profile-selection";
import { WorkEnvironmentPage } from "./work-environment-page";
export function ProfileConfigurations({
personId,
company,
csrfToken,
selection,
onBack,
onSelection,
}: {
company: CompanyDTO;
csrfToken: string;
personId: string | undefined;
selection: ProfileSelection;
onSelection: (selection: ProfileSelection) => void;
onBack: () => void;
}) {
const { t } = useTranslation("companies");
const props = { company, csrfToken, initialProfileId: selection.profileId };
return (
<div className="space-y-4">
<Button onClick={onBack} variant="outline">
{t(selection.fromEnvironment ? "environment.back" : "profiles.title")}
</Button>
{selection.kind === "environment" && (
<WorkEnvironmentPage
company={company}
csrfToken={csrfToken}
onManage={(kind) =>
onSelection({ kind, profileId: selection.profileId, fromEnvironment: true })
}
onProfile={(profileId) => onSelection({ kind: "environment", profileId })}
profileId={selection.profileId}
/>
)}
{selection.kind === "remote" && (
<RemoteConnectionsPage company={company} csrfToken={csrfToken} initialPersonId={personId} />
)}
{selection.kind === "telework" && (
<TeleworkAuthorizationsPage
company={company}
csrfToken={csrfToken}
initialPersonId={personId}
/>
)}
{selection.kind === "folders" && <FolderMappingsPage {...props} />}
{selection.kind === "mail" && <MailConfigurationsPage {...props} />}
{selection.kind === "certificates" && <CertificateConfigurationsPage {...props} />}
{selection.kind === "applications" && <ApplicationConfigurationsPage {...props} />}
{selection.kind === "printers" && <PrinterConfigurationsPage {...props} />}
</div>
);
}

View File

@ -0,0 +1,7 @@
import type { ProfileConfigurationKind } from "./work-environment.model";
export type ProfileSelection = {
kind: ProfileConfigurationKind | "environment" | "telework" | "remote";
profileId: string;
fromEnvironment?: boolean;
};

View File

@ -0,0 +1,120 @@
import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client";
import {
inventoryApi as resourceApi,
inventoryKeys as resourceKeys,
} from "@metis/resources/client";
import { useQuery } from "@tanstack/react-query";
import { accessApi, accessKeys } from "../access/access-api";
import { applicationApi, applicationKeys } from "../applications/application-api";
import { certificateApi, certificateKeys } from "../certificates/certificate-api";
import { folderApi, folderKeys } from "../folders/folder-api";
import { mailApi, mailKeys } from "../mail/mail-api";
import { printerApi, printerKeys } from "../printers/printer-api";
import { remoteApi, remoteKeys } from "../remote/remote-api";
import { teleworkApi, teleworkKeys } from "../telework/telework-api";
import type { EnvironmentSources } from "./work-environment.model";
import { profileApi, profileKeys } from "./work-profile-api";
export function useEnvironmentQueries(companyId: string) {
const profiles = useQuery({
queryKey: profileKeys.company(companyId),
queryFn: () => profileApi.get(companyId),
});
const assets = useQuery({
queryKey: assetKeys.company(companyId),
queryFn: () => assetApi.get(companyId),
});
const resources = useQuery({
queryKey: resourceKeys.company(companyId),
queryFn: () => resourceApi.get(companyId),
});
const access = useQuery({
queryKey: accessKeys.scope(companyId),
queryFn: () => accessApi.request(companyId),
});
const mail = useQuery({
queryKey: mailKeys.company(companyId),
queryFn: () => mailApi.get(companyId),
});
const folders = useQuery({
queryKey: folderKeys.company(companyId),
queryFn: () => folderApi.get(companyId),
});
const printers = useQuery({
queryKey: printerKeys.company(companyId),
queryFn: () => printerApi.get(companyId),
});
const applications = useQuery({
queryKey: applicationKeys.company(companyId),
queryFn: () => applicationApi.get(companyId),
});
const certificates = useQuery({
queryKey: certificateKeys.company(companyId),
queryFn: () => certificateApi.get(companyId),
});
const telework = useQuery({
queryKey: teleworkKeys.company(companyId),
queryFn: () => teleworkApi.get(companyId),
});
const remote = useQuery({
queryKey: remoteKeys.company(companyId),
queryFn: () => remoteApi.get(companyId),
});
const queries = [
remote,
telework,
profiles,
assets,
resources,
access,
mail,
folders,
printers,
applications,
certificates,
];
const pending = queries.some((query) => query.isPending);
const error = queries.some((query) => query.isError);
const retry = () => Promise.all(queries.map((query) => query.refetch()));
if (
!(
remote.data &&
telework.data &&
profiles.data &&
assets.data &&
resources.data &&
access.data &&
mail.data &&
folders.data &&
printers.data &&
applications.data &&
certificates.data
)
) {
return { pending, error, retry, data: undefined };
}
const sources: EnvironmentSources = {
assets: assets.data.assets,
workstations: assets.data.workstations,
resources: resources.data.resources,
access: access.data.items,
mail: mail.data.configurations,
folders: folders.data.mappings,
printers: printers.data.configurations,
applications: applications.data.configurations,
certificates: certificates.data.configurations,
};
return {
pending,
error,
retry,
data: {
profiles: profiles.data.profiles,
sources,
authorizations: telework.data.authorizations,
connections: remote.data.connections,
},
};
}

View File

@ -1,143 +1,12 @@
import {
AssetSupport,
inventoryApi as assetApi,
inventoryKeys as assetKeys,
} from "@metis/assets/client";
import type { AssetDTO } from "@metis/assets/common";
import {
inventoryApi as resourceApi,
inventoryKeys as resourceKeys,
} from "@metis/resources/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, WorkProfileDTO } from "../../common";
import type { RemoteConnectionDTO } from "../../common/remote-connections";
import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations";
import { accessApi, accessKeys } from "../access/access-api";
import { accessLabel } from "../access/access-labels";
import { applicationApi, applicationKeys } from "../applications/application-api";
import { certificateApi, certificateKeys } from "../certificates/certificate-api";
import { folderApi, folderKeys } from "../folders/folder-api";
import { mailApi, mailKeys } from "../mail/mail-api";
import { printerApi, printerKeys } from "../printers/printer-api";
import { remoteApi, remoteKeys } from "../remote/remote-api";
import { RemoteSummary } from "../remote/remote-summary";
import { teleworkApi, teleworkKeys } from "../telework/telework-api";
import { TeleworkAuthorizationSummary } from "../telework/telework-authorization-summary";
import type { CompanyDTO } from "../../common";
import {
type EnvironmentRow,
type EnvironmentSources,
type ProfileConfigurationKind,
buildEnvironment,
} from "./work-environment.model";
import { profileApi, profileKeys } from "./work-profile-api";
import { EnvironmentContent } from "./environment-content";
import { useEnvironmentQueries } from "./use-environment-queries";
import type { ProfileConfigurationKind } from "./work-environment.model";
function useEnvironmentQueries(companyId: string) {
const profiles = useQuery({
queryKey: profileKeys.company(companyId),
queryFn: () => profileApi.get(companyId),
});
const assets = useQuery({
queryKey: assetKeys.company(companyId),
queryFn: () => assetApi.get(companyId),
});
const resources = useQuery({
queryKey: resourceKeys.company(companyId),
queryFn: () => resourceApi.get(companyId),
});
const access = useQuery({
queryKey: accessKeys.scope(companyId),
queryFn: () => accessApi.request(companyId),
});
const mail = useQuery({
queryKey: mailKeys.company(companyId),
queryFn: () => mailApi.get(companyId),
});
const folders = useQuery({
queryKey: folderKeys.company(companyId),
queryFn: () => folderApi.get(companyId),
});
const printers = useQuery({
queryKey: printerKeys.company(companyId),
queryFn: () => printerApi.get(companyId),
});
const applications = useQuery({
queryKey: applicationKeys.company(companyId),
queryFn: () => applicationApi.get(companyId),
});
const certificates = useQuery({
queryKey: certificateKeys.company(companyId),
queryFn: () => certificateApi.get(companyId),
});
const telework = useQuery({
queryKey: teleworkKeys.company(companyId),
queryFn: () => teleworkApi.get(companyId),
});
const remote = useQuery({
queryKey: remoteKeys.company(companyId),
queryFn: () => remoteApi.get(companyId),
});
const queries = [
remote,
telework,
profiles,
assets,
resources,
access,
mail,
folders,
printers,
applications,
certificates,
];
const pending = queries.some((query) => query.isPending);
const error = queries.some((query) => query.isError);
const retry = () => Promise.all(queries.map((query) => query.refetch()));
if (
!(
remote.data &&
telework.data &&
profiles.data &&
assets.data &&
resources.data &&
access.data &&
mail.data &&
folders.data &&
printers.data &&
applications.data &&
certificates.data
)
) {
return { pending, error, retry, data: undefined };
}
const sources: EnvironmentSources = {
assets: assets.data.assets,
workstations: assets.data.workstations,
resources: resources.data.resources,
access: access.data.items,
mail: mail.data.configurations,
folders: folders.data.mappings,
printers: printers.data.configurations,
applications: applications.data.configurations,
certificates: certificates.data.configurations,
};
return {
pending,
error,
retry,
data: {
profiles: profiles.data.profiles,
sources,
authorizations: telework.data.authorizations,
connections: remote.data.connections,
},
};
}
export function WorkEnvironmentPage({
company,
profileId,
@ -192,305 +61,3 @@ export function WorkEnvironmentPage({
/>
);
}
function EnvironmentContent({
connections,
authorizations,
company,
profile,
profiles,
sources,
csrfToken,
onManage,
onProfile,
}: {
company: CompanyDTO;
profile: WorkProfileDTO;
connections: readonly RemoteConnectionDTO[];
authorizations: readonly TeleworkAuthorizationDTO[];
profiles: readonly WorkProfileDTO[];
sources: EnvironmentSources;
csrfToken: string;
onManage: (kind: ProfileConfigurationKind | "telework" | "remote") => void;
onProfile: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const [attention, setAttention] = useState(false);
const model = buildEnvironment(profile, sources);
const remoteConnections = connections.filter(
(item) =>
item.companyId === company.id &&
item.values.personId === profile.values.personId &&
!["removed", "never_configured"].includes(item.values.state)
);
const person = company.people.find((item) => item.id === profile.values.personId);
const account = sources.access.find((item) => item.id === profile.values.accountId);
const others = profiles.filter(
(item) =>
item.companyId === company.id &&
item.values.active &&
item.values.personId === profile.values.personId &&
item.id !== profile.id
);
return (
<div className="space-y-5">
<header className="space-y-2">
<h2 className="text-2xl font-semibold">
{t("environment.title")} · {person?.name ?? profile.values.name}
</h2>
<p className="text-muted-foreground">
{profile.values.name} · {model.host?.values.name ?? profile.values.assetId}
</p>
<p className="text-sm">{t("environment.description")}</p>
</header>
{!profile.values.active && (
<p className="rounded-md border border-amber-500 p-3 text-sm" role="status">
{t("environment.archived")}
</p>
)}
{profile.needsReview && (
<p className="text-sm text-amber-700">{t("environment.reviewProfile")}</p>
)}
{person && !person.active && (
<p className="text-sm text-amber-700">{t("environment.personInactive")}</p>
)}
{others.length > 0 && (
<nav aria-label={t("environment.samePerson")} className="flex flex-wrap items-center gap-2">
<span className="text-sm">{t("environment.samePerson")}</span>
{others.map((item) => (
<Button key={item.id} onClick={() => onProfile(item.id)} size="sm" variant="outline">
{item.values.name}
</Button>
))}
</nav>
)}
<Card>
<CardContent className="space-y-4 pt-5">
<h3 className="font-semibold">{t("environment.equipment")}</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">{t("environment.device")}</dt>
<dd className="font-medium">{model.host?.values.name ?? profile.values.assetId}</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("profiles.account")}</dt>
<dd>
{account
? accessLabel(account, sources.access, company.people)
: t("profiles.noAccount")}
</dd>
</div>
{person?.position && (
<div>
<dt className="text-muted-foreground">{t("environment.position")}</dt>
<dd>{person.position}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground">{t("environment.workstation")}</dt>
<dd>
{model.stations.map((station) => station.values.name).join(" · ") ||
t("environment.noStation")}
</dd>
</div>
</dl>
{model.host && <HardwareCharacteristics asset={model.host} />}
{model.stationAssets.length > 0 && (
<ul className="grid gap-2 sm:grid-cols-2">
{model.stationAssets.map((asset) => {
const shared = model.stations.some((station) =>
station.values.sharedAssetIds.includes(asset.id)
);
return (
<li className="rounded-md border p-3 text-sm" key={asset.id}>
<span className="font-medium">{asset.values.name}</span>
<p className="text-muted-foreground">
{asset.specification.templateName} ·{" "}
{t(shared ? "environment.shared" : "environment.exclusive")}
</p>
{asset.values.status === "retired" && (
<p className="text-amber-700">{t("environment.retiredAsset")}</p>
)}
</li>
);
})}
</ul>
)}
{profile.values.notes && (
<p className="whitespace-pre-wrap text-sm">{profile.values.notes}</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3 pt-5">
<header className="flex items-center justify-between gap-3">
<h3 className="font-semibold">{t("telework.title")}</h3>
<Button onClick={() => onManage("telework")} size="sm" variant="outline">
{t("telework.manage")}
</Button>
</header>
<TeleworkAuthorizationSummary
authorization={authorizations.find(
(item) =>
item.companyId === company.id && item.values.personId === profile.values.personId
)}
/>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3 pt-5">
<header className="flex items-center justify-between gap-3">
<h3 className="font-semibold">{t("remote.title")}</h3>
<Button onClick={() => onManage("remote")} size="sm" variant="outline">
{t("remote.manage")}
</Button>
</header>
{!remoteConnections.length && (
<p className="text-sm text-muted-foreground">{t("remote.empty")}</p>
)}
<ul className="divide-y">
{remoteConnections.map((item) => (
<li className="py-3" key={item.id}>
<RemoteSummary
assets={sources.assets}
company={company}
connection={item}
resources={sources.resources}
/>
</li>
))}
</ul>
</CardContent>
</Card>
<AssetSupport
assetId={profile.values.assetId}
companyId={company.id}
csrfToken={csrfToken}
retired={model.host?.values.status === "retired"}
/>
<section aria-label={t("environment.configurations")} className="space-y-4">
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{(["configured", "pending", "removal", "review"] as const).map((key) => (
<div className="rounded-md border p-3" key={key}>
<dt className="text-sm text-muted-foreground">{t(`environment.${key}`)}</dt>
<dd className="text-2xl font-semibold">{model.counts[key]}</dd>
</div>
))}
</dl>
<label className="flex items-center gap-2 text-sm">
<input
checked={attention}
onChange={(event) => setAttention(event.target.checked)}
type="checkbox"
/>
{t("environment.onlyAttention")}
</label>
{model.unlinkedMail > 0 && (
<p className="text-sm text-amber-700">
{t("environment.unlinkedMail", { count: model.unlinkedMail })}
</p>
)}
<div className="grid items-start gap-4 xl:grid-cols-2">
{(["mail", "folders", "printers", "applications", "certificates"] as const).map(
(kind) => (
<EnvironmentSection
attention={attention}
key={kind}
kind={kind}
onManage={() => onManage(kind)}
rows={model.rows.filter(
(item) =>
item.kind === kind && (!attention || item.state !== "configured" || item.review)
)}
/>
)
)}
</div>
</section>
<p className="text-sm text-muted-foreground">{t("environment.unknown")}</p>
</div>
);
}
function HardwareCharacteristics({ asset }: { asset: AssetDTO }) {
const { t } = useTranslation("companies");
return (
<dl className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
{asset.specification.fields.flatMap((field) => {
const value = asset.values.characteristics[field.fieldId];
if (value === undefined || value === null || value === "") {
return [];
}
const text = typeof value === "boolean" ? t(value ? "yes" : "no") : String(value);
return [
<div key={field.fieldId}>
<dt className="text-muted-foreground">
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</dt>
<dd className="break-words">{text}</dd>
</div>,
];
})}
</dl>
);
}
function EnvironmentSection({
attention,
kind,
rows,
onManage,
}: {
attention: boolean;
kind: ProfileConfigurationKind;
rows: readonly EnvironmentRow[];
onManage: () => void;
}) {
const { t } = useTranslation("companies");
return (
<Card>
<CardContent className="pt-5">
<header className="mb-3 flex items-center justify-between gap-3">
<h3 className="font-semibold">{t(`${kind}.title`)}</h3>
<Button
aria-label={t("environment.manageSection", { section: t(`${kind}.title`) })}
onClick={onManage}
size="sm"
variant="outline"
>
{t("environment.manage")}
</Button>
</header>
{!rows.length && (
<p className="py-2 text-sm text-muted-foreground">
{t(attention ? "environment.noAttention" : "environment.empty")}
</p>
)}
<ul className="divide-y">
{rows.map((row) => (
<li className="space-y-2 py-3 text-sm" key={row.id}>
<div className="flex flex-wrap justify-between gap-2">
<h4 className="font-medium break-words">{row.name}</h4>
<span
className={row.state === "configured" ? "text-emerald-700" : "text-amber-700"}
>
{t(`mail.${row.state}`)}
</span>
</div>
<dl>
{row.details.map((detail) => (
<div className="break-words" key={detail.label}>
<dt className="inline text-muted-foreground">{t(detail.label)}: </dt>
<dd className="inline">{detail.translated ? t(detail.value) : detail.value}</dd>
</div>
))}
</dl>
{row.review && <p className="text-amber-700">{t("environment.needsReview")}</p>}
{row.notes && (
<p className="whitespace-pre-wrap text-muted-foreground">{row.notes}</p>
)}
</li>
))}
</ul>
</CardContent>
</Card>
);
}

View File

@ -15,26 +15,13 @@ import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { accessApi, accessKeys } from "../access/access-api";
import { accessLabel } from "../access/access-labels";
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
import { FolderMappingsPage } from "../folders/folder-mappings-page";
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
import { ProfileConfigurations } from "./profile-configurations";
import { ProfileFeedback } from "./profile-feedback";
import type { ProfileConfigurationKind } from "./work-environment.model";
import { WorkEnvironmentPage } from "./work-environment-page";
import type { ProfileSelection } from "./profile-selection";
import { profileApi, profileKeys } from "./work-profile-api";
import { WorkProfileForm } from "./work-profile-form";
type ProfileSelection = {
kind: ProfileConfigurationKind | "environment" | "telework" | "remote";
profileId: string;
fromEnvironment?: boolean;
};
export function WorkProfilesPage({
company,
csrfToken,
@ -338,55 +325,3 @@ export function WorkProfilesPage({
</div>
);
}
function ProfileConfigurations({
personId,
company,
csrfToken,
selection,
onBack,
onSelection,
}: {
company: CompanyDTO;
csrfToken: string;
personId: string | undefined;
selection: ProfileSelection;
onSelection: (selection: ProfileSelection) => void;
onBack: () => void;
}) {
const { t } = useTranslation("companies");
const props = { company, csrfToken, initialProfileId: selection.profileId };
return (
<div className="space-y-4">
<Button onClick={onBack} variant="outline">
{t(selection.fromEnvironment ? "environment.back" : "profiles.title")}
</Button>
{selection.kind === "environment" && (
<WorkEnvironmentPage
company={company}
csrfToken={csrfToken}
onManage={(kind) =>
onSelection({ kind, profileId: selection.profileId, fromEnvironment: true })
}
onProfile={(profileId) => onSelection({ kind: "environment", profileId })}
profileId={selection.profileId}
/>
)}
{selection.kind === "remote" && (
<RemoteConnectionsPage company={company} csrfToken={csrfToken} initialPersonId={personId} />
)}
{selection.kind === "telework" && (
<TeleworkAuthorizationsPage
company={company}
csrfToken={csrfToken}
initialPersonId={personId}
/>
)}
{selection.kind === "folders" && <FolderMappingsPage {...props} />}
{selection.kind === "mail" && <MailConfigurationsPage {...props} />}
{selection.kind === "certificates" && <CertificateConfigurationsPage {...props} />}
{selection.kind === "applications" && <ApplicationConfigurationsPage {...props} />}
{selection.kind === "printers" && <PrinterConfigurationsPage {...props} />}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More