Refactorización
This commit is contained in:
parent
78c05eae6d
commit
7f336df146
@ -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.
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
60
apps/web/src/language-switcher.tsx
Normal file
60
apps/web/src/language-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
63
modules/assets/src/web/asset-selection-panel.tsx
Normal file
63
modules/assets/src/web/asset-selection-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
75
modules/assets/src/web/catalog-cell.tsx
Normal file
75
modules/assets/src/web/catalog-cell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
15
modules/assets/src/web/catalog-details.tsx
Normal file
15
modules/assets/src/web/catalog-details.tsx
Normal 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 });
|
||||
}
|
||||
23
modules/assets/src/web/catalog-error.tsx
Normal file
23
modules/assets/src/web/catalog-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
36
modules/assets/src/web/catalog-name.tsx
Normal file
36
modules/assets/src/web/catalog-name.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
39
modules/assets/src/web/catalog-row-actions.tsx
Normal file
39
modules/assets/src/web/catalog-row-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
12
modules/assets/src/web/inventory-feedback.tsx
Normal file
12
modules/assets/src/web/inventory-feedback.tsx
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
135
modules/assets/src/web/relationship-form.tsx
Normal file
135
modules/assets/src/web/relationship-form.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
modules/assets/src/web/support-feedback.tsx
Normal file
17
modules/assets/src/web/support-feedback.tsx
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
31
modules/assets/src/web/support-history.tsx
Normal file
31
modules/assets/src/web/support-history.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
modules/assets/src/web/support-summary.tsx
Normal file
27
modules/assets/src/web/support-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
34
modules/assets/src/web/use-catalog-save.ts
Normal file
34
modules/assets/src/web/use-catalog-save.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 !(
|
||||
|
||||
@ -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,
|
||||
|
||||
1
modules/companies/src/web/access/access-field-option.ts
Normal file
1
modules/companies/src/web/access/access-field-option.ts
Normal file
@ -0,0 +1 @@
|
||||
export type Option = { value: string; label: string };
|
||||
223
modules/companies/src/web/access/access-form-fields.tsx
Normal file
223
modules/companies/src/web/access/access-form-fields.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
modules/companies/src/web/access/access-form-props.ts
Normal file
22
modules/companies/src/web/access/access-form-props.ts
Normal 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[];
|
||||
};
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
114
modules/companies/src/web/access/access-quick-editor.tsx
Normal file
114
modules/companies/src/web/access/access-quick-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
38
modules/companies/src/web/access/entry-state.tsx
Normal file
38
modules/companies/src/web/access/entry-state.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
modules/companies/src/web/access/permissions-control.tsx
Normal file
45
modules/companies/src/web/access/permissions-control.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
106
modules/companies/src/web/components/company-section.tsx
Normal file
106
modules/companies/src/web/components/company-section.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
||||
22
modules/companies/src/web/components/company-tab.ts
Normal file
22
modules/companies/src/web/components/company-tab.ts
Normal 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";
|
||||
@ -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) =>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
39
modules/companies/src/web/components/dashboard-panel.tsx
Normal file
39
modules/companies/src/web/components/dashboard-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
modules/companies/src/web/components/department-form.tsx
Normal file
92
modules/companies/src/web/components/department-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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" />;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
205
modules/companies/src/web/components/person-details.tsx
Normal file
205
modules/companies/src/web/components/person-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
modules/companies/src/web/components/site-section.tsx
Normal file
99
modules/companies/src/web/components/site-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
modules/companies/src/web/components/workspace-content.tsx
Normal file
75
modules/companies/src/web/components/workspace-content.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import type { DashboardNavigation } from "./dashboard-api";
|
||||
|
||||
export type WorkspaceLocation = { area: "home" } | DashboardNavigation | { area: "catalogs" };
|
||||
@ -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} />
|
||||
|
||||
88
modules/companies/src/web/folders/folder-mapping-targets.tsx
Normal file
88
modules/companies/src/web/folders/folder-mapping-targets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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[],
|
||||
|
||||
109
modules/companies/src/web/mail/mail-configuration-targets.tsx
Normal file
109
modules/companies/src/web/mail/mail-configuration-targets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
modules/companies/src/web/mail/mail-quick-editor.tsx
Normal file
75
modules/companies/src/web/mail/mail-quick-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
modules/companies/src/web/mail/mailbox-usage-prompt.tsx
Normal file
30
modules/companies/src/web/mail/mailbox-usage-prompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
modules/companies/src/web/printers/printer-history.tsx
Normal file
29
modules/companies/src/web/printers/printer-history.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
modules/companies/src/web/profiles/environment-content.tsx
Normal file
239
modules/companies/src/web/profiles/environment-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
modules/companies/src/web/profiles/environment-section.tsx
Normal file
67
modules/companies/src/web/profiles/environment-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
7
modules/companies/src/web/profiles/profile-selection.ts
Normal file
7
modules/companies/src/web/profiles/profile-selection.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { ProfileConfigurationKind } from "./work-environment.model";
|
||||
|
||||
export type ProfileSelection = {
|
||||
kind: ProfileConfigurationKind | "environment" | "telework" | "remote";
|
||||
profileId: string;
|
||||
fromEnvironment?: boolean;
|
||||
};
|
||||
120
modules/companies/src/web/profiles/use-environment-queries.ts
Normal file
120
modules/companies/src/web/profiles/use-environment-queries.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user