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.
|
Usar Node.js 24.x y la versión de PNPM fijada en package.json.
|
||||||
No imprimir, copiar ni versionar .env locales o secretos.
|
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
|
## Convenciones
|
||||||
|
|
||||||
- Código e identificadores en inglés; comentarios, TSDoc y documentación en castellano.
|
- 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);
|
response.status(404).json(error);
|
||||||
});
|
});
|
||||||
const errorHandler: ErrorRequestHandler = (_error: unknown, _request, response, next) => {
|
const errorHandler: ErrorRequestHandler = (failure: unknown, _request, response, next) => {
|
||||||
if (response.headersSent) {
|
if (response.headersSent) {
|
||||||
next(_error);
|
next(failure);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const status = bodyFailureStatus(failure);
|
||||||
const error: HttpErrorResponseDTO = {
|
const error: HttpErrorResponseDTO = {
|
||||||
code: "INTERNAL_ERROR",
|
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
|
||||||
requestId: response.getHeader("x-request-id")?.toString() ?? "",
|
requestId: response.getHeader("x-request-id")?.toString() ?? "",
|
||||||
};
|
};
|
||||||
response.status(500).json(error);
|
response.status(status).json(error);
|
||||||
};
|
};
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
return app;
|
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",
|
dialect: "mariadb",
|
||||||
host: environment.DB_HOST,
|
host: environment.DB_HOST,
|
||||||
port: environment.DB_PORT,
|
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 },
|
pool: { max: 5, min: 0, idle: 10000 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,9 +20,18 @@ async function start(): Promise<void> {
|
|||||||
console.error(JSON.stringify({ event: "server_start_failed" }));
|
console.error(JSON.stringify({ event: "server_start_failed" }));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
let stopping = false;
|
||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
|
if (stopping) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopping = true;
|
||||||
identity.stop();
|
identity.stop();
|
||||||
server.close(() => {
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error(JSON.stringify({ event: "server_close_failed" }));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
database.close().catch(() => {
|
database.close().catch(() => {
|
||||||
console.error(JSON.stringify({ event: "database_close_failed" }));
|
console.error(JSON.stringify({ event: "database_close_failed" }));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@ -32,7 +41,10 @@ async function start(): Promise<void> {
|
|||||||
process.once("SIGINT", shutdown);
|
process.once("SIGINT", shutdown);
|
||||||
process.once("SIGTERM", shutdown);
|
process.once("SIGTERM", shutdown);
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,66 +1,8 @@
|
|||||||
import { CompaniesWorkspace } from "@metis/companies/client";
|
import { CompaniesWorkspace } from "@metis/companies/client";
|
||||||
import { IdentityWorkspace, identityApi, identityKeys } from "@metis/identity/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 { useQuery } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
function LanguageSwitcher() {
|
import { LanguageSwitcher } from "./language-switcher";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const session = useQuery({
|
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 { AssetCharacteristicControl } from "./asset-characteristic-control";
|
||||||
import { BrandSelect } from "./brand-select";
|
import { BrandSelect } from "./brand-select";
|
||||||
import { CatalogTemplateForm } from "./catalog-template-form";
|
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 = {
|
export type InventoryLookups = {
|
||||||
sites: readonly { id: string; name: string }[];
|
sites: readonly { id: string; name: string }[];
|
||||||
people: readonly { id: string; name: string; active: boolean }[];
|
people: readonly { id: string; name: string; active: boolean }[];
|
||||||
onCreateSite: (onCreated: (id: string) => void) => void;
|
onCreateSite: (onCreated: (id: string) => void) => void;
|
||||||
onCreatePerson: (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({
|
export function AssetForm({
|
||||||
companyId,
|
companyId,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
|
|||||||
@ -1,19 +1,14 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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, 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
|
import { InventoryFeedback } from "./inventory-feedback";
|
||||||
|
import { RelationshipForm } from "./relationship-form";
|
||||||
|
|
||||||
export function AssetRelationships({
|
export function AssetRelationships({
|
||||||
companyId,
|
companyId,
|
||||||
@ -148,121 +143,3 @@ export function AssetRelationships({
|
|||||||
</section>
|
</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 { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { supportApi, supportKeys } from "./support-api";
|
||||||
import { SupportForm } from "./support-form";
|
import { SupportForm } from "./support-form";
|
||||||
|
import { SupportHistory } from "./support-history";
|
||||||
|
import { SupportSummary } from "./support-summary";
|
||||||
import { SupportTools } from "./support-tools";
|
import { SupportTools } from "./support-tools";
|
||||||
|
|
||||||
export function AssetSupport({
|
export function AssetSupport({
|
||||||
companyId,
|
companyId,
|
||||||
assetId,
|
assetId,
|
||||||
@ -148,51 +151,3 @@ export function AssetSupport({
|
|||||||
</section>
|
</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 { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useId } from "react";
|
||||||
import { useEffect, useId, useRef } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { AssetDefinitionSpec, CatalogItemDTO, SaveDefinitionResponseDTO } from "../common";
|
import type { CatalogItemDTO } from "../common";
|
||||||
import { AssetDefinitionSchema, FieldTypeSchema } 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 { 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({
|
const FieldFormSchema = AssetDefinitionSchema.options[0].omit({ choices: true }).extend({
|
||||||
choicesText: z.string().max(10000),
|
choicesText: z.string().max(10000),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FieldFormValues = z.infer<typeof FieldFormSchema>;
|
type FieldFormValues = z.infer<typeof FieldFormSchema>;
|
||||||
|
|
||||||
export function CatalogFieldForm({
|
export function CatalogFieldForm({
|
||||||
scope,
|
scope,
|
||||||
csrfToken,
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list";
|
||||||
EntityAction,
|
|
||||||
EntityListHeader,
|
|
||||||
EntityRowActions,
|
|
||||||
EntityStatus,
|
|
||||||
EntityTable,
|
|
||||||
} from "@repo/shadcn-ui/components/entity-list";
|
|
||||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CatalogItemDTO } from "../common";
|
import type { CatalogItemDTO } from "../common";
|
||||||
|
|
||||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||||
|
import { CatalogCell } from "./catalog-cell";
|
||||||
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
|
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 { CatalogRelationForm } from "./catalog-relation-form";
|
||||||
import { CatalogTemplateForm } from "./catalog-template-form";
|
import { CatalogTemplateForm } from "./catalog-template-form";
|
||||||
|
|
||||||
@ -24,13 +20,13 @@ type Editor = {
|
|||||||
item?: CatalogItemDTO;
|
item?: CatalogItemDTO;
|
||||||
copy?: CatalogItemDTO;
|
copy?: CatalogItemDTO;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
||||||
const { t, i18n } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const _searchId = useId();
|
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
|
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [view, setView] = useState<CatalogItemDTO>();
|
const [viewId, setViewId] = useState<string>();
|
||||||
const [editor, setEditor] = useState<Editor>();
|
const [editor, setEditor] = useState<Editor>();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: catalogKeys.scope(scope),
|
queryKey: catalogKeys.scope(scope),
|
||||||
@ -45,6 +41,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const items = query.data?.items ?? [];
|
const items = query.data?.items ?? [];
|
||||||
|
const view = items.find((item) => item.id === viewId);
|
||||||
const visible = items.filter(
|
const visible = items.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.values.kind === tab &&
|
item.values.kind === tab &&
|
||||||
@ -107,7 +104,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||||
onEdit={() => setEditor({ kind: tab, item })}
|
onEdit={() => setEditor({ kind: tab, item })}
|
||||||
onStatus={(active) => mutation.mutate({ item, active })}
|
onStatus={(active) => mutation.mutate({ item, active })}
|
||||||
onView={() => setView(item)}
|
onView={() => setViewId(item.id)}
|
||||||
pending={mutation.isPending}
|
pending={mutation.isPending}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
@ -125,7 +122,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||||
onEdit={() => setEditor({ kind: tab, item })}
|
onEdit={() => setEditor({ kind: tab, item })}
|
||||||
onStatus={(active) => mutation.mutate({ item, active })}
|
onStatus={(active) => mutation.mutate({ item, active })}
|
||||||
onView={() => setView(item)}
|
onView={() => setViewId(item.id)}
|
||||||
pending={mutation.isPending}
|
pending={mutation.isPending}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
@ -142,7 +139,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||||
onEdit={() => setEditor({ kind: tab, item })}
|
onEdit={() => setEditor({ kind: tab, item })}
|
||||||
onStatus={(active) => mutation.mutate({ item, active })}
|
onStatus={(active) => mutation.mutate({ item, active })}
|
||||||
onView={() => setView(item)}
|
onView={() => setViewId(item.id)}
|
||||||
pending={mutation.isPending}
|
pending={mutation.isPending}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
@ -160,7 +157,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||||
onEdit={() => setEditor({ kind: tab, item })}
|
onEdit={() => setEditor({ kind: tab, item })}
|
||||||
onStatus={(active) => mutation.mutate({ item, active })}
|
onStatus={(active) => mutation.mutate({ item, active })}
|
||||||
onView={() => setView(item)}
|
onView={() => setViewId(item.id)}
|
||||||
pending={mutation.isPending}
|
pending={mutation.isPending}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
@ -178,7 +175,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||||
onEdit={() => setEditor({ kind: tab, item })}
|
onEdit={() => setEditor({ kind: tab, item })}
|
||||||
onStatus={(active) => mutation.mutate({ item, active })}
|
onStatus={(active) => mutation.mutate({ item, active })}
|
||||||
onView={() => setView(item)}
|
onView={() => setViewId(item.id)}
|
||||||
pending={mutation.isPending}
|
pending={mutation.isPending}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
@ -197,7 +194,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||||
unavailable={!query.data && query.isError}
|
unavailable={!query.data && query.isError}
|
||||||
/>
|
/>
|
||||||
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
|
<CatalogDefinitionDialog item={view} items={items} onClose={() => setViewId(undefined)} />
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
@ -240,73 +237,6 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
</div>
|
</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) {
|
function editorTitle(editor: Editor) {
|
||||||
if (editor.kind === "field") {
|
if (editor.kind === "field") {
|
||||||
@ -317,83 +247,3 @@ function editorTitle(editor: Editor) {
|
|||||||
}
|
}
|
||||||
return editor.item ? "editTemplate" : "newTemplate";
|
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 { AssetDefinitionSchema, type CatalogItemDTO } from "../common";
|
||||||
|
|
||||||
import { CatalogError, type CatalogSaved, useCatalogSave } from "./catalog-form";
|
import { CatalogError } from "./catalog-error";
|
||||||
import { CatalogHelp } from "./catalog-help";
|
import { CatalogHelp } from "./catalog-help";
|
||||||
|
import type { CatalogSaved } from "./use-catalog-save";
|
||||||
|
import { useCatalogSave } from "./use-catalog-save";
|
||||||
|
|
||||||
const schema = AssetDefinitionSchema.options[2];
|
const schema = AssetDefinitionSchema.options[2];
|
||||||
export function CatalogRelationForm({
|
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 type { CatalogItemDTO } from "../common";
|
||||||
import { AssetDefinitionSchema } from "../common";
|
import { AssetDefinitionSchema } from "../common";
|
||||||
|
|
||||||
import type { CatalogSaved } from "./catalog-form";
|
import { CatalogError } from "./catalog-error";
|
||||||
import { CatalogError, CatalogFieldForm, useCatalogSave } from "./catalog-form";
|
import { CatalogFieldForm } from "./catalog-form";
|
||||||
import { CatalogHelp } from "./catalog-help";
|
import { CatalogHelp } from "./catalog-help";
|
||||||
|
import type { CatalogSaved } from "./use-catalog-save";
|
||||||
|
import { useCatalogSave } from "./use-catalog-save";
|
||||||
|
|
||||||
const TemplateFormSchema = AssetDefinitionSchema.options[1];
|
const TemplateFormSchema = AssetDefinitionSchema.options[1];
|
||||||
type TemplateFormValues = z.infer<typeof TemplateFormSchema>;
|
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 { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { AssetDTO } from "../common/inventory";
|
|
||||||
|
|
||||||
import { AssetDetail } from "./asset-detail";
|
import { AssetDetail } from "./asset-detail";
|
||||||
import type { InventoryLookups } from "./asset-form";
|
import type { InventoryLookups } from "./asset-form";
|
||||||
import { AssetForm, InventoryFeedback } from "./asset-form";
|
import { AssetForm } from "./asset-form";
|
||||||
import { AssetRelationships } from "./asset-relationships";
|
import { AssetRelationships } from "./asset-relationships";
|
||||||
|
import { AssetSelectionPanel } from "./asset-selection-panel";
|
||||||
import { AssetSupport } from "./asset-support";
|
import { AssetSupport } from "./asset-support";
|
||||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||||
import { CatalogError } from "./catalog-form";
|
import { CatalogError } from "./catalog-error";
|
||||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
|
import { InventoryFeedback } from "./inventory-feedback";
|
||||||
|
|
||||||
export function AssetInventoryPage({
|
export function AssetInventoryPage({
|
||||||
companyId,
|
companyId,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -271,57 +272,3 @@ export function AssetInventoryPage({
|
|||||||
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
|
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
|
||||||
return id === selectedId && detailOpen ? "inventory-asset-selected" : "";
|
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";
|
} from "../common/support";
|
||||||
|
|
||||||
import { supportApi, supportKeys } from "./support-api";
|
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";
|
import { SupportToolForm } from "./support-tool-form";
|
||||||
export function SupportForm({
|
export function SupportForm({
|
||||||
companyId,
|
companyId,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { SupportApiError } from "./support-api";
|
|
||||||
export function SupportHelp({ tool = false }: { tool?: boolean }) {
|
export function SupportHelp({ tool = false }: { tool?: boolean }) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
return (
|
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";
|
} from "../common/support";
|
||||||
|
|
||||||
import { supportApi, supportKeys } from "./support-api";
|
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({
|
export function SupportToolForm({
|
||||||
csrfToken,
|
csrfToken,
|
||||||
tool,
|
tool,
|
||||||
|
|||||||
@ -8,9 +8,9 @@ import type { ReactNode } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
|
||||||
import { SupportSummary } from "./asset-support";
|
|
||||||
import { assetsEnglish, assetsSpanish } from "./i18n";
|
import { assetsEnglish, assetsSpanish } from "./i18n";
|
||||||
import { SupportForm } from "./support-form";
|
import { SupportForm } from "./support-form";
|
||||||
|
import { SupportSummary } from "./support-summary";
|
||||||
import { SupportToolForm } from "./support-tool-form";
|
import { SupportToolForm } from "./support-tool-form";
|
||||||
|
|
||||||
const tool = {
|
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 { 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 { catalogApi, catalogKeys } from "./catalog-api";
|
||||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
|
import { InventoryFeedback } from "./inventory-feedback";
|
||||||
export function WorkstationForm({
|
export function WorkstationForm({
|
||||||
companyId,
|
companyId,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
|
|||||||
@ -11,10 +11,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { WorkstationDTO } from "../common";
|
import type { InventoryLookups } from "./asset-form";
|
||||||
|
|
||||||
import { InventoryFeedback, type InventoryLookups } from "./asset-form";
|
|
||||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
|
import { InventoryFeedback } from "./inventory-feedback";
|
||||||
import { WorkstationForm } from "./workstation-form";
|
import { WorkstationForm } from "./workstation-form";
|
||||||
export function WorkstationsPage({
|
export function WorkstationsPage({
|
||||||
companyId,
|
companyId,
|
||||||
@ -49,26 +48,6 @@ export function WorkstationsPage({
|
|||||||
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
(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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<EntityListHeader
|
<EntityListHeader
|
||||||
|
|||||||
@ -7,9 +7,6 @@ import { AccessError } from "./access-error";
|
|||||||
import type { AccessItem } from "./access-item.entity";
|
import type { AccessItem } from "./access-item.entity";
|
||||||
import type { AccessContext } from "./access-workspace.aggregate";
|
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) {
|
function active(item: AccessItem, today: string) {
|
||||||
const values = item.values.data;
|
const values = item.values.data;
|
||||||
return !(
|
return !(
|
||||||
|
|||||||
@ -7,10 +7,11 @@ import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
|||||||
import type { UseFormReturn } from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { Option } from "./access-field-option";
|
||||||
import type { AccessField } from "./access-fields";
|
import type { AccessField } from "./access-fields";
|
||||||
import type { AccessFormValues } from "./access-form-values";
|
import type { AccessFormValues } from "./access-form-values";
|
||||||
|
import { PermissionsControl } from "./permissions-control";
|
||||||
|
|
||||||
type Option = { value: string; label: string };
|
|
||||||
export function AccessFieldControl({
|
export function AccessFieldControl({
|
||||||
field,
|
field,
|
||||||
id,
|
id,
|
||||||
@ -99,47 +100,10 @@ function stringValue(value: string | boolean | string[] | undefined) {
|
|||||||
return typeof value === "string" ? value : "";
|
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) {
|
function quickAddLabel(key: string) {
|
||||||
return key === "systemId" ? "quickAddSystem" : "quickAddEnvironment";
|
return key === "systemId" ? "quickAddSystem" : "quickAddEnvironment";
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAction(
|
function selectAction(
|
||||||
onClick: (() => void) | undefined,
|
onClick: (() => void) | undefined,
|
||||||
disabled: boolean,
|
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 { 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 {
|
import { AccessFormFields } from "./access-form-fields";
|
||||||
AccessFieldsDTO,
|
import type { AccessFormProps } from "./access-form-props";
|
||||||
AccessItemDTO,
|
|
||||||
AccessKind,
|
|
||||||
AccessResponseDTO,
|
|
||||||
PersonDTO,
|
|
||||||
} from "../../common";
|
|
||||||
import { AccessFieldsSchema } from "../../common";
|
|
||||||
import { PersonForm } from "../components/person-form";
|
|
||||||
|
|
||||||
import { AccessApiError, accessApi, accessKeys } from "./access-api";
|
export function AccessForm(props: AccessFormProps) {
|
||||||
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]) {
|
|
||||||
if (props.kind === "resource") {
|
if (props.kind === "resource") {
|
||||||
const type = props.initialValues?.resourceTypeId;
|
const type = props.initialValues?.resourceTypeId;
|
||||||
return (
|
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 {
|
import { EntityAction, EntityRowActions } from "@repo/shadcn-ui/components/entity-list";
|
||||||
EntityAction,
|
|
||||||
EntityRowActions,
|
|
||||||
EntityStatus,
|
|
||||||
} from "@repo/shadcn-ui/components/entity-list";
|
|
||||||
import { useTranslation } from "react-i18next";
|
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 { 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({
|
export function AccessCell({
|
||||||
column,
|
column,
|
||||||
item,
|
item,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Button } from "@repo/shadcn-ui/components/button";
|
|||||||
import { EntityListHeader, 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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { AccessKind, PersonDTO } from "../../common";
|
import type { AccessKind, PersonDTO } from "../../common";
|
||||||
@ -34,7 +34,6 @@ export function AccessWorkspacePage({
|
|||||||
const [history, setHistory] = useState<string>();
|
const [history, setHistory] = useState<string>();
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const _searchId = useId();
|
|
||||||
const data = query.data;
|
const data = query.data;
|
||||||
const selected = data?.items.find((item) => item.id === editor);
|
const selected = data?.items.find((item) => item.id === editor);
|
||||||
const historical = data?.items.find((item) => item.id === history);
|
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/resources/client";
|
||||||
ResourceEditor,
|
|
||||||
catalogApi,
|
|
||||||
catalogKeys,
|
|
||||||
inventoryApi,
|
|
||||||
inventoryKeys,
|
|
||||||
} from "@metis/resources/client";
|
|
||||||
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common";
|
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
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 { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
@ -27,10 +21,11 @@ import {
|
|||||||
ApplicationConfigurationInputSchema,
|
ApplicationConfigurationInputSchema,
|
||||||
} from "../../common/application-configurations";
|
} from "../../common/application-configurations";
|
||||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||||
import { WorkProfileForm } from "../profiles/work-profile-form";
|
|
||||||
|
|
||||||
import { applicationApi, applicationKeys } from "./application-api";
|
import { applicationApi, applicationKeys } from "./application-api";
|
||||||
import { ApplicationFeedback } from "./application-feedback";
|
import { ApplicationFeedback } from "./application-feedback";
|
||||||
|
import { ApplicationQuickEditor } from "./application-quick-editor";
|
||||||
|
|
||||||
export function ApplicationConfigurationForm({
|
export function ApplicationConfigurationForm({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -268,6 +263,7 @@ function applicationDefaults(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applicationStates(
|
function applicationStates(
|
||||||
configuration: ApplicationConfigurationDTO | undefined
|
configuration: ApplicationConfigurationDTO | undefined
|
||||||
): readonly ApplicationConfigurationInput["state"][] {
|
): readonly ApplicationConfigurationInput["state"][] {
|
||||||
@ -275,11 +271,13 @@ function applicationStates(
|
|||||||
? APPLICATION_STATE_TRANSITIONS[configuration.values.state]
|
? APPLICATION_STATE_TRANSITIONS[configuration.values.state]
|
||||||
: ["pending", "configured"];
|
: ["pending", "configured"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function applicationProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
|
function applicationProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
|
||||||
return profiles
|
return profiles
|
||||||
.filter((item) => item.values.active || item.id === selected)
|
.filter((item) => item.values.active || item.id === selected)
|
||||||
.map((item) => ({ value: item.id, label: item.values.name }));
|
.map((item) => ({ value: item.id, label: item.values.name }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeDelivery(value: string, form: UseFormReturn<ApplicationConfigurationInput>) {
|
function changeDelivery(value: string, form: UseFormReturn<ApplicationConfigurationInput>) {
|
||||||
const parsed = ApplicationConfigurationInputSchema.shape.delivery.safeParse(value);
|
const parsed = ApplicationConfigurationInputSchema.shape.delivery.safeParse(value);
|
||||||
if (parsed.success) {
|
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 { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||||
|
|
||||||
import { applicationApi, applicationKeys } from "./application-api";
|
import { applicationApi, applicationKeys } from "./application-api";
|
||||||
import { ApplicationConfigurationForm } from "./application-configuration-form";
|
import { ApplicationConfigurationForm } from "./application-configuration-form";
|
||||||
import { ApplicationFeedback } from "./application-feedback";
|
import { ApplicationFeedback } from "./application-feedback";
|
||||||
|
import { ApplicationHistory } from "./application-history";
|
||||||
|
|
||||||
export function ApplicationConfigurationsPage({
|
export function ApplicationConfigurationsPage({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -230,30 +232,3 @@ export function ApplicationConfigurationsPage({
|
|||||||
</div>
|
</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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/resources/client";
|
||||||
ResourceEditor,
|
|
||||||
catalogApi,
|
|
||||||
catalogKeys,
|
|
||||||
inventoryApi,
|
|
||||||
inventoryKeys,
|
|
||||||
} from "@metis/resources/client";
|
|
||||||
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common";
|
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
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 { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
@ -27,10 +21,11 @@ import {
|
|||||||
CertificateConfigurationInputSchema,
|
CertificateConfigurationInputSchema,
|
||||||
} from "../../common/certificate-configurations";
|
} from "../../common/certificate-configurations";
|
||||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||||
import { WorkProfileForm } from "../profiles/work-profile-form";
|
|
||||||
|
|
||||||
import { certificateApi, certificateKeys } from "./certificate-api";
|
import { certificateApi, certificateKeys } from "./certificate-api";
|
||||||
import { CertificateFeedback } from "./certificate-feedback";
|
import { CertificateFeedback } from "./certificate-feedback";
|
||||||
|
import { CertificateQuickEditor } from "./certificate-quick-editor";
|
||||||
|
|
||||||
export function CertificateConfigurationForm({
|
export function CertificateConfigurationForm({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -250,6 +245,7 @@ function certificateDefaults(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function certificateStates(
|
function certificateStates(
|
||||||
configuration: CertificateConfigurationDTO | undefined
|
configuration: CertificateConfigurationDTO | undefined
|
||||||
): readonly CertificateConfigurationInput["state"][] {
|
): readonly CertificateConfigurationInput["state"][] {
|
||||||
@ -257,54 +253,9 @@ function certificateStates(
|
|||||||
? CERTIFICATE_STATE_TRANSITIONS[configuration.values.state]
|
? CERTIFICATE_STATE_TRANSITIONS[configuration.values.state]
|
||||||
: ["pending", "configured"];
|
: ["pending", "configured"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function certificateProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
|
function certificateProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
|
||||||
return profiles
|
return profiles
|
||||||
.filter((item) => item.values.active || item.id === selected)
|
.filter((item) => item.values.active || item.id === selected)
|
||||||
.map((item) => ({ value: item.id, label: item.values.name }));
|
.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 { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||||
|
|
||||||
import { certificateApi, certificateKeys } from "./certificate-api";
|
import { certificateApi, certificateKeys } from "./certificate-api";
|
||||||
import { CertificateConfigurationForm } from "./certificate-configuration-form";
|
import { CertificateConfigurationForm } from "./certificate-configuration-form";
|
||||||
import { CertificateFeedback } from "./certificate-feedback";
|
import { CertificateFeedback } from "./certificate-feedback";
|
||||||
|
import { CertificateHistory } from "./certificate-history";
|
||||||
|
|
||||||
export function CertificateConfigurationsPage({
|
export function CertificateConfigurationsPage({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -237,28 +239,3 @@ export function CertificateConfigurationsPage({
|
|||||||
</div>
|
</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 { companyApi, companyKeys } from "../api/company-api";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
import { DepartmentForm } from "./department-section";
|
import { DepartmentForm } from "./department-form";
|
||||||
|
|
||||||
const FormSchema = z.strictObject({
|
const FormSchema = z.strictObject({
|
||||||
departmentId: z.uuid(),
|
departmentId: z.uuid(),
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { getCoreRowModel, getFilteredRowModel, useReactTable } 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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CompanySummaryDTO } from "../../common";
|
import type { CompanySummaryDTO } from "../../common";
|
||||||
@ -46,7 +46,6 @@ export function CompaniesPage({
|
|||||||
const [activeOnly, setActiveOnly] = useState(initialActiveOnly);
|
const [activeOnly, setActiveOnly] = useState(initialActiveOnly);
|
||||||
const [createOpen, setCreateOpen] = useState(initialCreate);
|
const [createOpen, setCreateOpen] = useState(initialCreate);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const _id = useId();
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: query.data?.companies ?? EMPTY_COMPANIES,
|
data: query.data?.companies ?? EMPTY_COMPANIES,
|
||||||
columns: COMPANY_COLUMNS,
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
@ -20,19 +18,12 @@ import type { ReactNode } from "react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AccessWorkspacePage } from "../access/access-workspace";
|
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
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 { CompanyDetail } from "./company-detail";
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
import type { DashboardNavigation } from "./dashboard-api";
|
import { WorkspaceContent } from "./workspace-content";
|
||||||
import { DashboardPage } from "./dashboard-page";
|
import type { WorkspaceLocation } from "./workspace-location";
|
||||||
|
|
||||||
type WorkspaceLocation = { area: "home" } | DashboardNavigation | { area: "catalogs" };
|
|
||||||
|
|
||||||
export function CompaniesWorkspace({
|
export function CompaniesWorkspace({
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -321,91 +312,3 @@ export function CompaniesWorkspace({
|
|||||||
</div>
|
</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 { 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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import {
|
import {
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
@ -23,136 +15,12 @@ import type { ReactNode } from "react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CompanyDTO } from "../../common";
|
|
||||||
import { AccessWorkspacePage } from "../access/access-workspace";
|
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
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 { CompanyFeedback } from "./company-feedback";
|
||||||
import { CompanyForm } from "./company-form";
|
import { CompanyForm } from "./company-form";
|
||||||
import { CompanyInventory } from "./company-inventory";
|
import { CompanySection } from "./company-section";
|
||||||
import { DepartmentSection } from "./department-section";
|
import type { CompanyTab } from "./company-tab";
|
||||||
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";
|
|
||||||
|
|
||||||
export function CompanyDetail({
|
export function CompanyDetail({
|
||||||
id,
|
id,
|
||||||
@ -300,85 +168,3 @@ export function CompanyDetail({
|
|||||||
</div>
|
</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) {
|
if (!response.ok) {
|
||||||
throw new Error("DASHBOARD_UNAVAILABLE");
|
throw new Error("DASHBOARD_UNAVAILABLE");
|
||||||
}
|
}
|
||||||
return response.json() as Promise<unknown>;
|
const body: unknown = await response.json();
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
summary: async (signal: AbortSignal, companyId?: string | undefined) =>
|
summary: async (signal: AbortSignal, companyId?: string | undefined) =>
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import {
|
|||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from "@repo/shadcn-ui/lib/icons";
|
} from "@repo/shadcn-ui/lib/icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -31,6 +30,7 @@ import {
|
|||||||
} from "./dashboard-api";
|
} from "./dashboard-api";
|
||||||
import { BlockState } from "./dashboard-block";
|
import { BlockState } from "./dashboard-block";
|
||||||
import { DashboardDetailsDialog } from "./dashboard-details-dialog";
|
import { DashboardDetailsDialog } from "./dashboard-details-dialog";
|
||||||
|
import { DashboardPanel } from "./dashboard-panel";
|
||||||
import { DashboardQuickCreate } from "./dashboard-quick-create";
|
import { DashboardQuickCreate } from "./dashboard-quick-create";
|
||||||
|
|
||||||
const ISSUE_ICONS = {
|
const ISSUE_ICONS = {
|
||||||
@ -40,6 +40,7 @@ const ISSUE_ICONS = {
|
|||||||
missing_site: BoxIcon,
|
missing_site: BoxIcon,
|
||||||
inactive_person: UserRoundXIcon,
|
inactive_person: UserRoundXIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const KIND_ICONS = {
|
const KIND_ICONS = {
|
||||||
ticket: TicketIcon,
|
ticket: TicketIcon,
|
||||||
intervention: WrenchIcon,
|
intervention: WrenchIcon,
|
||||||
@ -479,38 +480,3 @@ export function DashboardPage({
|
|||||||
</div>
|
</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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
EntityListHeader,
|
EntityListHeader,
|
||||||
@ -8,95 +6,14 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/shadcn-ui/components/entity-list";
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { useState } from "react";
|
||||||
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 { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CompanyDTO, DepartmentDTO, DepartmentRequestDTO } from "../../common";
|
import type { CompanyDTO } from "../../common";
|
||||||
import { DepartmentRequestSchema } from "../../common";
|
|
||||||
import { EntityHelp } from "../access/entity-help";
|
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
|
||||||
|
|
||||||
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({
|
export function DepartmentSection({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
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 { 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 { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
@ -13,200 +12,11 @@ import { Label } from "@repo/shadcn-ui/components/label";
|
|||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
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({
|
export function PeopleSection({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
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,
|
FolderMappingInputSchema,
|
||||||
} from "../../common/folder-mappings";
|
} from "../../common/folder-mappings";
|
||||||
import { accessApi, accessKeys } from "../access/access-api";
|
import { accessApi, accessKeys } from "../access/access-api";
|
||||||
import { accessLabel } from "../access/access-labels";
|
|
||||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||||
|
|
||||||
import { folderApi, folderKeys } from "./folder-api";
|
import { folderApi, folderKeys } from "./folder-api";
|
||||||
import { FolderFeedback } from "./folder-feedback";
|
import { FolderFeedback } from "./folder-feedback";
|
||||||
|
import { FolderMappingTargets } from "./folder-mapping-targets";
|
||||||
import { FolderQuickEditor } from "./folder-quick-editor";
|
import { FolderQuickEditor } from "./folder-quick-editor";
|
||||||
|
|
||||||
export function FolderMappingForm({
|
export function FolderMappingForm({
|
||||||
@ -113,52 +113,20 @@ export function FolderMappingForm({
|
|||||||
<>
|
<>
|
||||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<FolderMappingTargets
|
||||||
<div className="space-y-2">
|
company={company}
|
||||||
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
|
fixed={Boolean(mapping)}
|
||||||
<SelectWithAction
|
form={form}
|
||||||
action={
|
initialProfileId={initialProfileId}
|
||||||
mapping || initialProfileId
|
items={items}
|
||||||
? undefined
|
needs={needs}
|
||||||
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
|
onNeedChange={changeNeed}
|
||||||
}
|
onQuickCreate={setQuick}
|
||||||
disabled={!!mapping || !!initialProfileId || !ready}
|
prefix={prefix}
|
||||||
id={`${prefix}-profile`}
|
profile={profile}
|
||||||
label={t("mail.profile")}
|
profiles={profiles.data?.profiles ?? []}
|
||||||
onValueChange={(id) => {
|
ready={ready}
|
||||||
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>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${prefix}-path`}>{t("folders.path")}</Label>
|
<Label htmlFor={`${prefix}-path`}>{t("folders.path")}</Label>
|
||||||
<Input id={`${prefix}-path`} readOnly value={values.path} />
|
<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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type {
|
import type { InterventionTemplateInput } from "../../common/intervention-templates";
|
||||||
InterventionTemplateDTO,
|
|
||||||
InterventionTemplateInput,
|
|
||||||
} from "../../common/intervention-templates";
|
|
||||||
export function TemplateSteps({ values }: { values: InterventionTemplateInput }) {
|
export function TemplateSteps({ values }: { values: InterventionTemplateInput }) {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
return (
|
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";
|
} from "../../common/intervention-templates";
|
||||||
|
|
||||||
import { InterventionTemplateApiError, interventionTemplateApi } from "./template-api";
|
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";
|
import { TemplateStepFields } from "./template-step-fields";
|
||||||
|
|
||||||
export function newTemplateStep() {
|
export function newTemplateStep() {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { InterventionTemplateApiError } from "./template-api";
|
|
||||||
export function InterventionTemplateHelp() {
|
export function InterventionTemplateHelp() {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
return (
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { interventionTemplateApi, interventionTemplateKeys } from "./template-api";
|
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 { InterventionTemplateForm, copyTemplateValues } from "./template-form";
|
||||||
import { InterventionTemplateFeedback } from "./template-help";
|
import { TemplateHistory } from "./template-history";
|
||||||
export function InterventionTemplatesPage({
|
export function InterventionTemplatesPage({
|
||||||
scope = "global",
|
scope = "global",
|
||||||
csrfToken,
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type {
|
import type { InterventionDTO, InterventionListDTO } from "../../common/interventions";
|
||||||
InterventionDTO,
|
|
||||||
InterventionInput,
|
|
||||||
InterventionListDTO,
|
|
||||||
} from "../../common/interventions";
|
|
||||||
import type { TicketLookupsDTO } from "../../common/tickets";
|
|
||||||
import { ticketTargetOptions } from "../tickets/ticket-targets";
|
|
||||||
|
|
||||||
import { interventionApi, interventionKeys } from "./intervention-api";
|
import { interventionApi, interventionKeys } from "./intervention-api";
|
||||||
|
import { InterventionSnapshot } from "./intervention-snapshot";
|
||||||
|
|
||||||
export function stepCompletion(intervention: InterventionDTO, stepId: string) {
|
export function stepCompletion(intervention: InterventionDTO, stepId: string) {
|
||||||
let completed: InterventionDTO["history"][number] | undefined;
|
let completed: InterventionDTO["history"][number] | undefined;
|
||||||
@ -26,92 +21,7 @@ export function stepCompletion(intervention: InterventionDTO, stepId: string) {
|
|||||||
}
|
}
|
||||||
return completed;
|
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({
|
export function InterventionDetail({
|
||||||
intervention,
|
intervention,
|
||||||
data,
|
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 { TicketTargets } from "../tickets/ticket-targets";
|
||||||
|
|
||||||
import { InterventionApiError, interventionApi, interventionKeys } from "./intervention-api";
|
import { InterventionApiError, interventionApi, interventionKeys } from "./intervention-api";
|
||||||
|
import { InterventionCompanyField } from "./intervention-company-field";
|
||||||
|
import { InterventionFeedback } from "./intervention-feedback";
|
||||||
import { InterventionWorkFields } from "./intervention-fields";
|
import { InterventionWorkFields } from "./intervention-fields";
|
||||||
import { InterventionFeedback, InterventionHelp } from "./intervention-help";
|
import { InterventionHelp } from "./intervention-help";
|
||||||
import { InterventionProcedureFields } from "./intervention-procedure-fields";
|
import { InterventionProcedureFields } from "./intervention-procedure-fields";
|
||||||
import { InterventionSteps } from "./intervention-steps";
|
import { InterventionSteps } from "./intervention-steps";
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ export function emptyIntervention(ticket?: TicketDTO): InterventionInput {
|
|||||||
steps: [],
|
steps: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InterventionForm({
|
export function InterventionForm({
|
||||||
scope,
|
scope,
|
||||||
intervention,
|
intervention,
|
||||||
@ -360,38 +363,3 @@ function isCompanyFixed(
|
|||||||
) {
|
) {
|
||||||
return Boolean(scope || intervention || ticket);
|
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 { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { InterventionApiError } from "./intervention-api";
|
|
||||||
export function InterventionHelp() {
|
export function InterventionHelp() {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
return (
|
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 { interventionApi, interventionKeys } from "./intervention-api";
|
||||||
import { InterventionDetail } from "./intervention-detail";
|
import { InterventionDetail } from "./intervention-detail";
|
||||||
|
import { InterventionFeedback } from "./intervention-feedback";
|
||||||
import { InterventionForm } from "./intervention-form";
|
import { InterventionForm } from "./intervention-form";
|
||||||
import { InterventionFeedback } from "./intervention-help";
|
|
||||||
export type InterventionFilters = {
|
export type InterventionFilters = {
|
||||||
search: string;
|
search: string;
|
||||||
company: string;
|
company: string;
|
||||||
@ -79,11 +79,10 @@ export function InterventionsPage({
|
|||||||
assignee: "",
|
assignee: "",
|
||||||
});
|
});
|
||||||
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
|
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
|
||||||
const [initialViewId, setInitialViewId] = useState(initialId);
|
const [viewId, setViewId] = useState(initialId);
|
||||||
const [open, setOpen] = useState(initialCreate);
|
const [open, setOpen] = useState(initialCreate);
|
||||||
const [edit, setEdit] = useState<InterventionDTO | undefined>();
|
const [edit, setEdit] = useState<InterventionDTO | undefined>();
|
||||||
const [view, setView] = useState<InterventionDTO | undefined>();
|
const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId);
|
||||||
const currentView = selectedDetail(view, query.data?.interventions, initialViewId);
|
|
||||||
const items = filterInterventions(query.data?.interventions ?? [], filters, pendingOnly);
|
const items = filterInterventions(query.data?.interventions ?? [], filters, pendingOnly);
|
||||||
const date = (value: string | undefined) => formatDate(value, i18n.language);
|
const date = (value: string | undefined) => formatDate(value, i18n.language);
|
||||||
return (
|
return (
|
||||||
@ -132,7 +131,7 @@ export function InterventionsPage({
|
|||||||
render: (intervention) => (
|
render: (intervention) => (
|
||||||
<button
|
<button
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
{intervention.reference}
|
{intervention.reference}
|
||||||
@ -147,7 +146,7 @@ export function InterventionsPage({
|
|||||||
render: (intervention) => (
|
render: (intervention) => (
|
||||||
<button
|
<button
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
{intervention.values.subject}
|
{intervention.values.subject}
|
||||||
@ -233,7 +232,7 @@ export function InterventionsPage({
|
|||||||
<EntityRowActions label={t("actions")}>
|
<EntityRowActions label={t("actions")}>
|
||||||
{" "}
|
{" "}
|
||||||
<div className="contents">
|
<div className="contents">
|
||||||
<EntityAction onClick={() => setView(intervention)}>
|
<EntityAction onClick={() => setViewId(intervention.id)}>
|
||||||
{t("interventions.details")}
|
{t("interventions.details")}
|
||||||
</EntityAction>
|
</EntityAction>
|
||||||
<EntityAction
|
<EntityAction
|
||||||
@ -343,8 +342,7 @@ export function InterventionsPage({
|
|||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
onOpenChange={(value) => {
|
onOpenChange={(value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setView(undefined);
|
setViewId(undefined);
|
||||||
setInitialViewId(undefined);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open={Boolean(currentView)}
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { TicketDTO } from "../../common/tickets";
|
|
||||||
|
|
||||||
import { interventionApi, interventionKeys } from "./intervention-api";
|
import { interventionApi, interventionKeys } from "./intervention-api";
|
||||||
import { InterventionForm } from "./intervention-form";
|
import { InterventionFeedback } from "./intervention-feedback";
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export function TicketInterventions({
|
export function TicketInterventions({
|
||||||
companyId,
|
companyId,
|
||||||
ticketId,
|
ticketId,
|
||||||
|
|||||||
@ -2,9 +2,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
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 { 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 { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
@ -21,13 +20,13 @@ import {
|
|||||||
import type { MailboxDTO, MailboxUseDTO } from "../../common/mailboxes";
|
import type { MailboxDTO, MailboxUseDTO } from "../../common/mailboxes";
|
||||||
import type { WorkProfileDTO } from "../../common/work-profiles";
|
import type { WorkProfileDTO } from "../../common/work-profiles";
|
||||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||||
import { WorkProfileForm } from "../profiles/work-profile-form";
|
|
||||||
|
|
||||||
import { mailApi, mailKeys } from "./mail-api";
|
import { mailApi, mailKeys } from "./mail-api";
|
||||||
|
import { MailConfigurationTargets } from "./mail-configuration-targets";
|
||||||
import { MailFeedback } from "./mail-feedback";
|
import { MailFeedback } from "./mail-feedback";
|
||||||
|
import { MailQuickEditor } from "./mail-quick-editor";
|
||||||
import { mailboxApi, mailboxKeys } from "./mailbox-api";
|
import { mailboxApi, mailboxKeys } from "./mailbox-api";
|
||||||
import { MailboxForm } from "./mailbox-form";
|
import { MailboxUsagePrompt } from "./mailbox-usage-prompt";
|
||||||
import { MailboxUseForm } from "./mailbox-use-form";
|
|
||||||
|
|
||||||
export function MailConfigurationForm({
|
export function MailConfigurationForm({
|
||||||
company,
|
company,
|
||||||
@ -110,97 +109,31 @@ export function MailConfigurationForm({
|
|||||||
{assetLabel(legacy.assetId)}
|
{assetLabel(legacy.assetId)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<MailConfigurationTargets
|
||||||
<div className="space-y-2">
|
fixed={fixed}
|
||||||
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
|
form={form}
|
||||||
<SelectWithAction
|
initialProfileId={initialProfileId}
|
||||||
action={
|
mailboxes={mailboxOptions(
|
||||||
fixed || initialProfileId
|
mailboxes.data?.mailboxes ?? [],
|
||||||
? undefined
|
values.mailboxId,
|
||||||
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
|
legacy
|
||||||
}
|
).map((item) => ({ value: item.id, label: item.values.address }))}
|
||||||
disabled={fixed || !!initialProfileId || !ready}
|
onQuickCreate={setQuick}
|
||||||
id={`${prefix}-profile`}
|
prefix={prefix}
|
||||||
label={t("mail.profile")}
|
profiles={profileOptions(profiles.data?.profiles ?? [], values.profileId, legacy).map(
|
||||||
onValueChange={(id) => form.setValue("profileId", id)}
|
(item) => ({
|
||||||
options={profileOptions(
|
value: item.id,
|
||||||
profiles.data?.profiles ?? [],
|
label:
|
||||||
values.profileId,
|
profileLabel(item.id) +
|
||||||
legacy
|
" · " +
|
||||||
).map((item) => ({
|
personLabel(item.values.personId) +
|
||||||
value: item.id,
|
" · " +
|
||||||
label:
|
assetLabel(item.values.assetId),
|
||||||
profileLabel(item.id) +
|
})
|
||||||
" · " +
|
)}
|
||||||
personLabel(item.values.personId) +
|
ready={ready}
|
||||||
" · " +
|
states={states}
|
||||||
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>
|
|
||||||
<MailboxUsagePrompt
|
<MailboxUsagePrompt
|
||||||
authorized={authorized ?? false}
|
authorized={authorized ?? false}
|
||||||
mailbox={selectedMailbox}
|
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) {
|
function legacyOf(existing: MailConfigurationDTO["values"] | undefined) {
|
||||||
return existing && "personId" in existing ? existing : undefined;
|
return existing && "personId" in existing ? existing : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileOptions(
|
function profileOptions(
|
||||||
items: readonly WorkProfileDTO[],
|
items: readonly WorkProfileDTO[],
|
||||||
selected: string,
|
selected: string,
|
||||||
@ -353,6 +222,7 @@ function profileOptions(
|
|||||||
(item.values.personId === legacy.personId && item.values.assetId === legacy.assetId))
|
(item.values.personId === legacy.personId && item.values.assetId === legacy.assetId))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mailboxOptions(
|
function mailboxOptions(
|
||||||
items: readonly MailboxDTO[],
|
items: readonly MailboxDTO[],
|
||||||
selected: string,
|
selected: string,
|
||||||
@ -370,30 +240,6 @@ function configurationStates(configuration: MailConfigurationDTO | undefined) {
|
|||||||
? MAIL_STATE_TRANSITIONS[configuration.values.state]
|
? MAIL_STATE_TRANSITIONS[configuration.values.state]
|
||||||
: ["pending", "configured"];
|
: ["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(
|
function hasMailboxUse(
|
||||||
uses: readonly MailboxUseDTO[],
|
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 { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||||
|
|
||||||
import { printerApi, printerKeys } from "./printer-api";
|
import { printerApi, printerKeys } from "./printer-api";
|
||||||
import { PrinterConfigurationForm } from "./printer-configuration-form";
|
import { PrinterConfigurationForm } from "./printer-configuration-form";
|
||||||
import { PrinterFeedback } from "./printer-feedback";
|
import { PrinterFeedback } from "./printer-feedback";
|
||||||
|
import { PrinterHistory } from "./printer-history";
|
||||||
|
|
||||||
export function PrinterConfigurationsPage({
|
export function PrinterConfigurationsPage({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -215,28 +217,3 @@ export function PrinterConfigurationsPage({
|
|||||||
</div>
|
</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 { 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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CompanyDTO, WorkProfileDTO } from "../../common";
|
import type { CompanyDTO } 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 {
|
import { EnvironmentContent } from "./environment-content";
|
||||||
type EnvironmentRow,
|
import { useEnvironmentQueries } from "./use-environment-queries";
|
||||||
type EnvironmentSources,
|
import type { ProfileConfigurationKind } from "./work-environment.model";
|
||||||
type ProfileConfigurationKind,
|
|
||||||
buildEnvironment,
|
|
||||||
} from "./work-environment.model";
|
|
||||||
import { profileApi, profileKeys } from "./work-profile-api";
|
|
||||||
|
|
||||||
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({
|
export function WorkEnvironmentPage({
|
||||||
company,
|
company,
|
||||||
profileId,
|
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 type { CompanyDTO } from "../../common";
|
||||||
import { accessApi, accessKeys } from "../access/access-api";
|
import { accessApi, accessKeys } from "../access/access-api";
|
||||||
import { accessLabel } from "../access/access-labels";
|
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 { ProfileFeedback } from "./profile-feedback";
|
||||||
import type { ProfileConfigurationKind } from "./work-environment.model";
|
import type { ProfileSelection } from "./profile-selection";
|
||||||
import { WorkEnvironmentPage } from "./work-environment-page";
|
|
||||||
import { profileApi, profileKeys } from "./work-profile-api";
|
import { profileApi, profileKeys } from "./work-profile-api";
|
||||||
import { WorkProfileForm } from "./work-profile-form";
|
import { WorkProfileForm } from "./work-profile-form";
|
||||||
|
|
||||||
type ProfileSelection = {
|
|
||||||
kind: ProfileConfigurationKind | "environment" | "telework" | "remote";
|
|
||||||
profileId: string;
|
|
||||||
fromEnvironment?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WorkProfilesPage({
|
export function WorkProfilesPage({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -338,55 +325,3 @@ export function WorkProfilesPage({
|
|||||||
</div>
|
</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