feat: implement record deletion functionality across various configurations
- Added remove methods to repositories for application, certificate, folder, mail, and printer configurations. - Implemented use cases for removing configurations, including validation for company and identity. - Created HTTP routes for deleting configurations with appropriate request handling and error responses. - Introduced a DeleteRecordDialog component for user confirmation before deletion. - Updated UI pages to include delete actions for configurations with modal confirmation. - Added integration tests to verify record deletion functionality and ensure data integrity. - Documented the record deletion process and its implications in the project documentation.
This commit is contained in:
parent
564b666fcc
commit
6c6eee8f93
@ -42,6 +42,7 @@ import { verifyInventory } from "./inventory.integration";
|
||||
import { verifyMail } from "./mail.integration";
|
||||
import { verifyPrinters } from "./printers.integration";
|
||||
import { verifyProcesses } from "./processes.integration";
|
||||
import { verifyRecordDeletion } from "./record-deletion.integration";
|
||||
import { verifyRelationships } from "./relationships.integration";
|
||||
import { verifyRemoteConnections } from "./remote.integration";
|
||||
import { verifyResourceMigration } from "./resource-migration.integration";
|
||||
@ -910,6 +911,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
migratedServer.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
await verifyRecordDeletion((path, body) => operator.request(path, body), database);
|
||||
const countAfterAccess = await database.query<{ total: number }>(
|
||||
"SELECT COUNT(*) AS total FROM users",
|
||||
{ type: QueryTypes.SELECT }
|
||||
|
||||
139
apps/server/src/record-deletion.integration.ts
Normal file
139
apps/server/src/record-deletion.integration.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
type Request = (path: string, body?: unknown) => Promise<Response>;
|
||||
|
||||
export async function verifyRecordDeletion(request: Request, database: Sequelize) {
|
||||
const counts = async () => {
|
||||
const result: number[] = [];
|
||||
for (const table of [
|
||||
"company_contacts",
|
||||
"assets",
|
||||
"resources",
|
||||
"work_profiles",
|
||||
"access_items",
|
||||
"mailbox_uses",
|
||||
]) {
|
||||
const rows = await database.query<{ count: number }>(
|
||||
`SELECT COUNT(*) AS count FROM ${table}`,
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
result.push(Number(rows[0]?.count));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const folderSubjects = await database.query<{
|
||||
profileId: string;
|
||||
companyId: string;
|
||||
needId: string;
|
||||
actorId: string;
|
||||
}>(
|
||||
"SELECT p.id AS profileId,p.companyId,i.id AS needId,u.id AS actorId FROM work_profiles p JOIN access_workspaces w ON w.companyId=p.companyId JOIN access_items i ON i.workspaceId=w.id CROSS JOIN users u WHERE JSON_UNQUOTE(JSON_EXTRACT(i.`values`, '$.kind'))='need' LIMIT 1",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
const subject = folderSubjects[0];
|
||||
assert.ok(subject, "Debe existir un perfil y una necesidad de recurso para la prueba de carpeta");
|
||||
const mappingId = randomUUID();
|
||||
const values = JSON.stringify({
|
||||
profileId: subject.profileId,
|
||||
needId: subject.needId,
|
||||
path: "\\\\SERVER\\Test",
|
||||
driveLetter: null,
|
||||
state: "pending",
|
||||
notes: null,
|
||||
});
|
||||
await database.query(
|
||||
"INSERT INTO folder_mappings (id,companyId,profileId,needId,`values`,currentKey,driveKey) VALUES (:id,:companyId,:profileId,:needId,:values,:currentKey,NULL)",
|
||||
{
|
||||
replacements: {
|
||||
...subject,
|
||||
id: mappingId,
|
||||
values,
|
||||
currentKey: `${subject.profileId}:${subject.needId}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
await database.query(
|
||||
"INSERT INTO folder_mapping_revisions (id,mappingId,position,`values`,actorId,recordedAt) VALUES (:id,:mappingId,0,:values,:actorId,:recordedAt)",
|
||||
{
|
||||
replacements: {
|
||||
id: randomUUID(),
|
||||
mappingId,
|
||||
values,
|
||||
actorId: subject.actorId,
|
||||
recordedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
const before = await counts();
|
||||
const specs = [
|
||||
[
|
||||
"application_configurations",
|
||||
"application_configuration_revisions",
|
||||
"configurationId",
|
||||
"application-configurations",
|
||||
],
|
||||
[
|
||||
"certificate_configurations",
|
||||
"certificate_configuration_revisions",
|
||||
"configurationId",
|
||||
"certificate-configurations",
|
||||
],
|
||||
[
|
||||
"printer_configurations",
|
||||
"printer_configuration_revisions",
|
||||
"configurationId",
|
||||
"printer-configurations",
|
||||
],
|
||||
[
|
||||
"mail_configurations",
|
||||
"mail_configuration_revisions",
|
||||
"configurationId",
|
||||
"mail-configurations",
|
||||
],
|
||||
["folder_mappings", "folder_mapping_revisions", "mappingId", "folder-mappings"],
|
||||
["asset_support", "asset_support_revisions", "connectionId", "asset-support"],
|
||||
["asset_relationships", null, null, "assets"],
|
||||
] as const;
|
||||
for (const [table, history, foreignKey, endpoint] of specs) {
|
||||
const rows = await database.query<{ id: string; companyId: string; assetId?: string }>(
|
||||
`SELECT * FROM ${table}`,
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
assert.ok(rows.length > 0, `No existen datos para probar ${table}`);
|
||||
// Todos los estados, incluidos los cerrados, admiten corregir un alta equivocada.
|
||||
for (const row of rows) {
|
||||
const suffix =
|
||||
endpoint === "asset-support"
|
||||
? `${row.assetId}/${row.id}`
|
||||
: endpoint === "assets"
|
||||
? `relationships/${row.id}`
|
||||
: row.id;
|
||||
const path = `/${endpoint}/${row.companyId}/${suffix}/delete`;
|
||||
assert.equal(
|
||||
(await request(`/${endpoint}/${randomUUID()}/${suffix}/delete`, {})).status,
|
||||
404
|
||||
);
|
||||
assert.equal((await request(path.replace(row.companyId, "invalid"), {})).status, 400);
|
||||
assert.equal((await request(path, { unexpected: true })).status, 400);
|
||||
assert.equal((await request(path, {})).status, 200);
|
||||
assert.equal((await request(path, {})).status, 404);
|
||||
const remaining = await database.query(`SELECT id FROM ${table} WHERE id=:id`, {
|
||||
replacements: { id: row.id },
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
assert.equal(remaining.length, 0);
|
||||
if (history && foreignKey) {
|
||||
const revisions = await database.query(
|
||||
`SELECT id FROM ${history} WHERE ${foreignKey}=:id`,
|
||||
{ replacements: { id: row.id }, type: QueryTypes.SELECT }
|
||||
);
|
||||
assert.equal(revisions.length, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(await counts(), before, "La eliminación debe conservar las entidades de origen");
|
||||
}
|
||||
@ -319,3 +319,9 @@ En el menú de secciones de una compañía, los elementos utilizan el color norm
|
||||
El cuerpo desplazable `FormBody` reserva 4 px interiores en ambos laterales y al inicio para el anillo exterior de foco (3 px). No debe eliminarse ese espacio: `overflow-y-auto` también recorta horizontalmente y produciría un foco asimétrico en campos pegados al borde. Se conserva el scroll y las acciones fijas.
|
||||
|
||||
Los listados comparten la preferencia entity-page-size en localStorage: el número de elementos por página elegido en un listado se restaura en todos los demás. Solo se aceptan los tamaños disponibles en el selector; si el almacenamiento no está disponible o contiene otro valor, se usa el tamaño inicial.
|
||||
|
||||
### Confirmación de eliminación de registros
|
||||
|
||||
`ConfirmDeleteDialog` de `@repo/rdx-ui` presenta una confirmación modal con descripción de consecuencias, Cancelar y Eliminar. El consumidor aporta los textos traducidos y una operación asíncrona. Mientras se ejecuta, evita envíos repetidos y el cierre; si falla, mantiene el diálogo abierto y muestra un error. Solo se cierra tras completar la operación.
|
||||
|
||||
Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de los menús de fila para que la confirmación permanezca montada cuando se cierre el menú. La operación invalida la consulta de la entidad y las revisiones de procesos de la compañía.
|
||||
|
||||
11
docs/eliminacion-de-registros.md
Normal file
11
docs/eliminacion-de-registros.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Eliminación de registros creados por error
|
||||
|
||||
Se permite eliminar configuraciones de aplicaciones, correo, carpetas, impresoras y certificados de un perfil, métodos de asistencia técnica de un activo y relaciones entre activos. Está disponible para cualquier estado del registro, incluidos los retirados y cerrados. No se elimina el catálogo de tipos de relación ni el catálogo de herramientas de asistencia.
|
||||
|
||||
La acción se ofrece en el menú de fila de las configuraciones y en las fichas de asistencia y relaciones. Siempre requiere confirmación modal e informa de que la eliminación es definitiva e incluye el historial propio. Una configuración representa lo documentado en Metis Desk: borrarla no desinstala programas, elimina certificados del dispositivo, revoca permisos ni retira herramientas reales.
|
||||
|
||||
Cada operación valida los UUID, el cuerpo vacío y la consulta vacía, exige autenticación y la protección CSRF general del servidor, y restringe la escritura a la compañía indicada. Para asistencia se comprueba también el activo. La persistencia bloquea la compañía y el registro, elimina primero las revisiones propias y luego el registro, dentro de una transacción. No modifica activos, recursos, cuentas, personas, perfiles ni permisos. Un identificador inexistente o de otra compañía devuelve 404 sin modificar datos.
|
||||
|
||||
Las tareas de procesos que ya copiaron una referencia mantienen su descripción e historial: la eliminación del registro de origen no elimina el proceso. Al refrescar su revisión actual, ese registro deja de aparecer.
|
||||
|
||||
Las entidades con condiciones de eliminación (perfiles, puestos, activos, recursos y catálogos utilizados) quedan fuera de esta ampliación. Las eliminaciones de asignaciones a departamentos y procesos de personas ya estaban disponibles.
|
||||
@ -37,6 +37,14 @@ export class AssetRelationshipUseCases {
|
||||
);
|
||||
});
|
||||
}
|
||||
remove(companyId: string, id: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
if (!(company.isSuccess && identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.removeRelationship(company.data, identity.data);
|
||||
}
|
||||
end(companyId: string, id: string, actorId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
|
||||
@ -20,6 +20,7 @@ export type InventoryContext = Readonly<{
|
||||
actorNames: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface IAssetRepository {
|
||||
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
|
||||
saveWorkstation(
|
||||
companyId: UniqueID,
|
||||
action: (context: InventoryContext) => Result<Workstation, AssetCatalogError>
|
||||
|
||||
@ -11,6 +11,7 @@ export type SupportContext = Readonly<{
|
||||
actorNames: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface ISupportRepository {
|
||||
remove(company: UniqueID, asset: UniqueID, id: UniqueID): Promise<Result<void, SupportError>>;
|
||||
get(company: UniqueID, asset: UniqueID): Promise<Result<SupportContext, SupportError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
|
||||
@ -18,6 +18,15 @@ export class SupportUseCases {
|
||||
}
|
||||
return this.repository.get(company.data, asset.data);
|
||||
}
|
||||
remove(companyId: string, assetId: string, id: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const asset = UniqueID.create(assetId);
|
||||
const identity = UniqueID.create(id);
|
||||
if (!(company.isSuccess && asset.isSuccess && identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new SupportError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.remove(company.data, asset.data, identity.data);
|
||||
}
|
||||
tools() {
|
||||
return this.repository.tools();
|
||||
}
|
||||
|
||||
@ -77,5 +77,28 @@ export function registerRelationshipRoutes(
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
router.post("/:companyId/relationships/:id/delete", (request, response, next) => {
|
||||
const params = AssetParamsSchema.safeParse(request.params);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
z.strictObject({}).safeParse(request.body).success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.remove(params.data.companyId, params.data.id)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({ deleted: true });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/assets", authenticate, router);
|
||||
}
|
||||
|
||||
@ -106,5 +106,28 @@ export function registerSupportRoutes(
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
router.post("/:companyId/:assetId/:id/delete", (request, response, next) => {
|
||||
const params = Params.extend({ id: z.uuid() }).safeParse(request.params);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
Empty.safeParse(request.body).success &&
|
||||
Empty.safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.remove(params.data.companyId, params.data.assetId, params.data.id)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({ deleted: true });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/asset-support", authenticate, router);
|
||||
}
|
||||
|
||||
@ -190,6 +190,25 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
await WorkstationAssetModel.upsert(reference, { transaction });
|
||||
}
|
||||
}
|
||||
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
||||
replacements: { companyId: company.toPrimitive() },
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const row = await RelationshipModel.findOne({
|
||||
where: { companyId: company.toPrimitive(), id: id.toPrimitive() },
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
if (!row) {
|
||||
return Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND"));
|
||||
}
|
||||
await row.destroy({ transaction });
|
||||
return Result.ok(undefined);
|
||||
});
|
||||
}
|
||||
saveRelationship(
|
||||
companyId: UniqueID,
|
||||
action: Parameters<IAssetRepository["saveRelationship"]>[1]
|
||||
|
||||
@ -66,6 +66,36 @@ export class SequelizeSupportRepository implements ISupportRepository {
|
||||
actorNames: new Map(users.map((item) => [item.id, item.name])),
|
||||
});
|
||||
}
|
||||
remove(company: UniqueID, asset: UniqueID, id: UniqueID): Promise<Result<void, SupportError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const replacements = {
|
||||
companyId: company.toPrimitive(),
|
||||
assetId: asset.toPrimitive(),
|
||||
id: id.toPrimitive(),
|
||||
};
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
||||
replacements,
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const rows = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM asset_support WHERE companyId=:companyId AND assetId=:assetId AND id=:id FOR UPDATE",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!rows.length) {
|
||||
return Result.fail(new SupportError("CONNECTION_NOT_FOUND"));
|
||||
}
|
||||
await this.database.query("DELETE FROM asset_support_revisions WHERE connectionId=:id", {
|
||||
replacements,
|
||||
transaction,
|
||||
});
|
||||
await this.database.query(
|
||||
"DELETE FROM asset_support WHERE companyId=:companyId AND assetId=:assetId AND id=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
return Result.ok(undefined);
|
||||
});
|
||||
}
|
||||
async save(
|
||||
company: UniqueID,
|
||||
asset: UniqueID,
|
||||
|
||||
@ -308,5 +308,11 @@
|
||||
"identification": "Identification",
|
||||
"assignment": "Location and assignment",
|
||||
"notes": "Notes"
|
||||
},
|
||||
"deleteRecord": {
|
||||
"title": "Delete record",
|
||||
"description": "This record and its history will be deleted. Related assets, resources, accounts and people will be preserved. This action cannot be undone.",
|
||||
"pending": "Deleting…",
|
||||
"error": "The record could not be deleted. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@ -308,5 +308,11 @@
|
||||
"identification": "Identificación",
|
||||
"assignment": "Ubicación y asignación",
|
||||
"notes": "Observaciones"
|
||||
},
|
||||
"deleteRecord": {
|
||||
"title": "Eliminar registro",
|
||||
"description": "Se eliminarán este registro y su historial. Los activos, recursos, cuentas y personas relacionados se conservarán. Esta acción no se puede deshacer.",
|
||||
"pending": "Eliminando…",
|
||||
"error": "No se ha podido eliminar el registro. Inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { BrowserLink } from "@metis/core/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { RelationshipDTO } from "../../../../common";
|
||||
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
||||
import { EndRelationshipDialog } from "../../end";
|
||||
import { RelationshipEditorDialog } from "../../shared";
|
||||
import { useAssetRelationshipsController } from "../controllers";
|
||||
@ -13,6 +15,7 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
|
||||
const { query, adding, setAdding, setEndingId, ending, active, ended, dateOf } =
|
||||
useAssetRelationshipsController(props);
|
||||
const { t } = useTranslation("assets");
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
const nameOf = (id: string) =>
|
||||
query.data?.assets.find((asset) => asset.id === id)?.values.name ?? id;
|
||||
const render = (link: RelationshipDTO) => {
|
||||
@ -34,6 +37,9 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("relationCreated", { name: link.createdByName, date: dateOf(link.createdAt) })}
|
||||
</p>
|
||||
<Button onClick={() => setDeleteId(link.id)} size="sm" variant="outline">
|
||||
{t("deleteRecord.title")}
|
||||
</Button>
|
||||
{link.endedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("relationEnded", { name: link.endedByName, date: dateOf(link.endedAt) })}
|
||||
@ -73,6 +79,13 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
|
||||
<ul className="mt-3 space-y-3">{ended.map(render)}</ul>
|
||||
</details>
|
||||
)}
|
||||
<DeleteRecordDialog
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
onClose={() => setDeleteId(undefined)}
|
||||
path={deleteId ? `/api/assets/${companyId}/relationships/${deleteId}/delete` : undefined}
|
||||
queryKey={["inventory", companyId]}
|
||||
/>
|
||||
{adding && (
|
||||
<RelationshipEditorDialog
|
||||
assetId={assetId}
|
||||
|
||||
13
modules/assets/src/web/shared/api/delete-record.api.ts
Normal file
13
modules/assets/src/web/shared/api/delete-record.api.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { z } from "zod/v4";
|
||||
export async function deleteRecord(path: string, token: string) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json", "x-csrf-token": token },
|
||||
body: "{}",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("DELETE_FAILED");
|
||||
}
|
||||
z.strictObject({ deleted: z.literal(true) }).parse(await response.json());
|
||||
}
|
||||
48
modules/assets/src/web/shared/ui/delete-record-dialog.tsx
Normal file
48
modules/assets/src/web/shared/ui/delete-record-dialog.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { deleteRecord } from "../api/delete-record.api";
|
||||
export function DeleteRecordDialog({
|
||||
path,
|
||||
csrfToken,
|
||||
queryKey,
|
||||
onClose,
|
||||
companyId,
|
||||
}: {
|
||||
path: string | undefined;
|
||||
csrfToken: string;
|
||||
queryKey: readonly string[];
|
||||
onClose: () => void;
|
||||
companyId: string;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const { t } = useTranslation("assets");
|
||||
return (
|
||||
<ConfirmDeleteDialog
|
||||
cancelLabel={t("cancel")}
|
||||
confirmLabel={t("deleteRecord.title")}
|
||||
description={t("deleteRecord.description")}
|
||||
errorLabel={t("deleteRecord.error")}
|
||||
onConfirm={async () => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
await deleteRecord(path, csrfToken);
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey }),
|
||||
client.invalidateQueries({ queryKey: ["person-processes", companyId] }),
|
||||
client.invalidateQueries({ queryKey: ["process-review", companyId] }),
|
||||
]);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open={Boolean(path)}
|
||||
pendingLabel={t("deleteRecord.pending")}
|
||||
title={t("deleteRecord.title")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
||||
import { SupportToolsDialog } from "../../../support-tools";
|
||||
import { SupportSummary } from "../../detail";
|
||||
import { SupportHistoryDialog } from "../../history";
|
||||
@ -26,6 +28,7 @@ export function AssetSupport(props: AssetSupportProps) {
|
||||
tools,
|
||||
} = useAssetSupportController(props);
|
||||
const { t } = useTranslation("assets");
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
return (
|
||||
<section className={compact ? "space-y-3" : "space-y-3 rounded-md border p-4"}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
@ -82,6 +85,9 @@ export function AssetSupport(props: AssetSupportProps) {
|
||||
<p className="text-sm text-amber-700">{t("support.review")}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => setDeleteId(item.id)} size="sm" variant="outline">
|
||||
{t("deleteRecord.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(item);
|
||||
@ -101,6 +107,15 @@ export function AssetSupport(props: AssetSupportProps) {
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<DeleteRecordDialog
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
onClose={() => setDeleteId(undefined)}
|
||||
path={
|
||||
deleteId ? `/api/asset-support/${companyId}/${assetId}/${deleteId}/delete` : undefined
|
||||
}
|
||||
queryKey={["asset-support", companyId, assetId]}
|
||||
/>
|
||||
<SupportConnectionEditorDialog
|
||||
assetId={assetId}
|
||||
companyId={companyId}
|
||||
|
||||
@ -17,6 +17,7 @@ export type ApplicationContext = Readonly<{
|
||||
actors: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface IApplicationConfigurationRepository {
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, ApplicationConfigurationError>>;
|
||||
get(company: UniqueID): Promise<Result<ApplicationContext, ApplicationConfigurationError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
|
||||
@ -9,6 +9,14 @@ import type { IApplicationConfigurationRepository } from "./application-configur
|
||||
import { mapApplicationInput } from "./application-configuration-input.mapper";
|
||||
export class ApplicationConfigurationUseCases {
|
||||
constructor(private readonly repository: IApplicationConfigurationRepository) {}
|
||||
remove(companyId: string, id: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
if (!(company.isSuccess && identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new ApplicationConfigurationError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.remove(company.data, identity.data);
|
||||
}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
|
||||
@ -19,6 +19,7 @@ export type CertificateContext = Readonly<{
|
||||
actors: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface ICertificateConfigurationRepository {
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, CertificateConfigurationError>>;
|
||||
get(company: UniqueID): Promise<Result<CertificateContext, CertificateConfigurationError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
|
||||
@ -9,6 +9,14 @@ import type { ICertificateConfigurationRepository } from "./certificate-configur
|
||||
import { mapCertificateInput } from "./certificate-configuration-input.mapper";
|
||||
export class CertificateConfigurationUseCases {
|
||||
constructor(private readonly repository: ICertificateConfigurationRepository) {}
|
||||
remove(companyId: string, id: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
if (!(company.isSuccess && identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new CertificateConfigurationError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.remove(company.data, identity.data);
|
||||
}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
|
||||
@ -15,6 +15,7 @@ export type FolderContext = Readonly<{
|
||||
today: string;
|
||||
}>;
|
||||
export interface IFolderMappingRepository {
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, FolderMappingError>>;
|
||||
get(company: UniqueID): Promise<Result<FolderContext, FolderMappingError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
|
||||
@ -9,6 +9,14 @@ import type { IFolderMappingRepository } from "./folder-mapping.repository";
|
||||
import { mapFolderInput } from "./folder-mapping-input.mapper";
|
||||
export class FolderMappingUseCases {
|
||||
constructor(private readonly repository: IFolderMappingRepository) {}
|
||||
remove(companyId: string, id: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
if (!(company.isSuccess && identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new FolderMappingError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.remove(company.data, identity.data);
|
||||
}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
|
||||
@ -20,6 +20,7 @@ export type MailContext = Readonly<{
|
||||
today: string;
|
||||
}>;
|
||||
export interface IMailConfigurationRepository {
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, MailConfigurationError>>;
|
||||
get(companyId: UniqueID): Promise<Result<MailContext, MailConfigurationError>>;
|
||||
save(
|
||||
companyId: UniqueID,
|
||||
|
||||
@ -9,6 +9,14 @@ import type { IMailConfigurationRepository } from "./mail-configuration.reposito
|
||||
import { mapMailInput } from "./mail-configuration-input.mapper";
|
||||
export class MailConfigurationUseCases {
|
||||
constructor(private readonly repository: IMailConfigurationRepository) {}
|
||||
remove(companyId: string, id: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
if (!(company.isSuccess && identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new MailConfigurationError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.remove(company.data, identity.data);
|
||||
}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
|
||||
@ -12,6 +12,7 @@ export type PrinterContext = Readonly<{
|
||||
actors: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface IPrinterConfigurationRepository {
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, PrinterConfigurationError>>;
|
||||
get(company: UniqueID): Promise<Result<PrinterContext, PrinterConfigurationError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
|
||||
@ -9,6 +9,14 @@ import type { IPrinterConfigurationRepository } from "./printer-configuration.re
|
||||
import { mapPrinterInput } from "./printer-configuration-input.mapper";
|
||||
export class PrinterConfigurationUseCases {
|
||||
constructor(private readonly repository: IPrinterConfigurationRepository) {}
|
||||
remove(companyId: string, id: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
if (!(company.isSuccess && identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new PrinterConfigurationError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.remove(company.data, identity.data);
|
||||
}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
|
||||
@ -78,5 +78,28 @@ export function registerApplicationConfigurationRoutes(
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
router.post("/:companyId/:id/delete", (request, response, next) => {
|
||||
const params = ApplicationItemParamsSchema.safeParse(request.params);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
z.strictObject({}).safeParse(request.body).success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.remove(params.data.companyId, params.data.id)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({ deleted: true });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/application-configurations", authenticate, router);
|
||||
}
|
||||
|
||||
@ -78,5 +78,28 @@ export function registerCertificateConfigurationRoutes(
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
router.post("/:companyId/:id/delete", (request, response, next) => {
|
||||
const params = CertificateItemParamsSchema.safeParse(request.params);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
z.strictObject({}).safeParse(request.body).success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.remove(params.data.companyId, params.data.id)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({ deleted: true });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/certificate-configurations", authenticate, router);
|
||||
}
|
||||
|
||||
@ -73,5 +73,28 @@ export function registerFolderMappingRoutes(
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
router.post("/:companyId/:id/delete", (request, response, next) => {
|
||||
const params = MailItemParamsSchema.safeParse(request.params);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
z.strictObject({}).safeParse(request.body).success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.remove(params.data.companyId, params.data.id)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({ deleted: true });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/folder-mappings", authenticate, router);
|
||||
}
|
||||
|
||||
@ -78,5 +78,28 @@ export function registerMailRoutes(
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
router.post("/:companyId/:id/delete", (request, response, next) => {
|
||||
const params = MailItemParamsSchema.safeParse(request.params);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
z.strictObject({}).safeParse(request.body).success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.remove(params.data.companyId, params.data.id)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({ deleted: true });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/mail-configurations", authenticate, router);
|
||||
}
|
||||
|
||||
@ -78,5 +78,28 @@ export function registerPrinterConfigurationRoutes(
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
router.post("/:companyId/:id/delete", (request, response, next) => {
|
||||
const params = PrinterItemParamsSchema.safeParse(request.params);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
z.strictObject({}).safeParse(request.body).success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.remove(params.data.companyId, params.data.id)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({ deleted: true });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/printer-configurations", authenticate, router);
|
||||
}
|
||||
|
||||
@ -104,6 +104,34 @@ export class SequelizeApplicationConfigurationRepository
|
||||
get(company: UniqueID) {
|
||||
return this.database.transaction((t) => this.load(company, t, false));
|
||||
}
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, ApplicationConfigurationError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
||||
replacements,
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const rows = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM application_configurations WHERE companyId=:companyId AND id=:id FOR UPDATE",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!rows.length) {
|
||||
return Result.fail(
|
||||
new ApplicationConfigurationError("APPLICATION_CONFIGURATION_NOT_FOUND")
|
||||
);
|
||||
}
|
||||
await this.database.query(
|
||||
"DELETE FROM application_configuration_revisions WHERE configurationId=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
await this.database.query(
|
||||
"DELETE FROM application_configurations WHERE companyId=:companyId AND id=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
return Result.ok(undefined);
|
||||
});
|
||||
}
|
||||
save(company: UniqueID, action: Parameters<IApplicationConfigurationRepository["save"]>[1]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(company, transaction, true);
|
||||
|
||||
@ -105,6 +105,34 @@ export class SequelizeCertificateConfigurationRepository
|
||||
get(company: UniqueID) {
|
||||
return this.database.transaction((t) => this.load(company, t, false));
|
||||
}
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, CertificateConfigurationError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
||||
replacements,
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const rows = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM certificate_configurations WHERE companyId=:companyId AND id=:id FOR UPDATE",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!rows.length) {
|
||||
return Result.fail(
|
||||
new CertificateConfigurationError("CERTIFICATE_CONFIGURATION_NOT_FOUND")
|
||||
);
|
||||
}
|
||||
await this.database.query(
|
||||
"DELETE FROM certificate_configuration_revisions WHERE configurationId=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
await this.database.query(
|
||||
"DELETE FROM certificate_configurations WHERE companyId=:companyId AND id=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
return Result.ok(undefined);
|
||||
});
|
||||
}
|
||||
save(company: UniqueID, action: Parameters<ICertificateConfigurationRepository["save"]>[1]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(company, transaction, true);
|
||||
|
||||
@ -116,6 +116,32 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
|
||||
get(company: UniqueID) {
|
||||
return this.database.transaction((t) => this.load(company, t, false));
|
||||
}
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, FolderMappingError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
||||
replacements,
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const rows = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM folder_mappings WHERE companyId=:companyId AND id=:id FOR UPDATE",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!rows.length) {
|
||||
return Result.fail(new FolderMappingError("FOLDER_MAPPING_NOT_FOUND"));
|
||||
}
|
||||
await this.database.query("DELETE FROM folder_mapping_revisions WHERE mappingId=:id", {
|
||||
replacements,
|
||||
transaction,
|
||||
});
|
||||
await this.database.query(
|
||||
"DELETE FROM folder_mappings WHERE companyId=:companyId AND id=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
return Result.ok(undefined);
|
||||
});
|
||||
}
|
||||
save(company: UniqueID, action: Parameters<IFolderMappingRepository["save"]>[1]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(company, transaction, true);
|
||||
|
||||
@ -166,6 +166,32 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR
|
||||
get(companyId: UniqueID) {
|
||||
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
|
||||
}
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, MailConfigurationError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
||||
replacements,
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const rows = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM mail_configurations WHERE companyId=:companyId AND id=:id FOR UPDATE",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!rows.length) {
|
||||
return Result.fail(new MailConfigurationError("MAIL_CONFIGURATION_NOT_FOUND"));
|
||||
}
|
||||
await this.database.query(
|
||||
"DELETE FROM mail_configuration_revisions WHERE configurationId=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
await this.database.query(
|
||||
"DELETE FROM mail_configurations WHERE companyId=:companyId AND id=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
return Result.ok(undefined);
|
||||
});
|
||||
}
|
||||
save(companyId: UniqueID, action: Parameters<IMailConfigurationRepository["save"]>[1]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const context = await this.load(companyId, transaction, true);
|
||||
|
||||
@ -96,6 +96,32 @@ export class SequelizePrinterConfigurationRepository implements IPrinterConfigur
|
||||
get(company: UniqueID) {
|
||||
return this.database.transaction((t) => this.load(company, t, false));
|
||||
}
|
||||
remove(company: UniqueID, id: UniqueID): Promise<Result<void, PrinterConfigurationError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const replacements = { companyId: company.toPrimitive(), id: id.toPrimitive() };
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
||||
replacements,
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const rows = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM printer_configurations WHERE companyId=:companyId AND id=:id FOR UPDATE",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!rows.length) {
|
||||
return Result.fail(new PrinterConfigurationError("PRINTER_CONFIGURATION_NOT_FOUND"));
|
||||
}
|
||||
await this.database.query(
|
||||
"DELETE FROM printer_configuration_revisions WHERE configurationId=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
await this.database.query(
|
||||
"DELETE FROM printer_configurations WHERE companyId=:companyId AND id=:id",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
return Result.ok(undefined);
|
||||
});
|
||||
}
|
||||
save(company: UniqueID, action: Parameters<IPrinterConfigurationRepository["save"]>[1]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(company, transaction, true);
|
||||
|
||||
@ -920,7 +920,13 @@
|
||||
"deleteAssignmentConfirm": "Delete the assignment to {{name}}?",
|
||||
"deleteAssignmentError": "The assignment could not be deleted.",
|
||||
"endDateColumn": "End date",
|
||||
"photoOptional": "Photo (optional)"
|
||||
"photoOptional": "Photo (optional)",
|
||||
"deleteRecord": {
|
||||
"title": "Delete record",
|
||||
"description": "This record and its history will be deleted. Related assets, resources, accounts and people will be preserved. This action cannot be undone.",
|
||||
"pending": "Deleting…",
|
||||
"error": "The record could not be deleted. Please try again."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
"helpTitle": "What is it and what is it for?",
|
||||
|
||||
@ -920,7 +920,13 @@
|
||||
"deleteAssignmentConfirm": "¿Eliminar la asignación al departamento {{name}}?",
|
||||
"deleteAssignmentError": "No se puede eliminar la asignación.",
|
||||
"endDateColumn": "Fecha de fin",
|
||||
"photoOptional": "Foto (opcional)"
|
||||
"photoOptional": "Foto (opcional)",
|
||||
"deleteRecord": {
|
||||
"title": "Eliminar registro",
|
||||
"description": "Se eliminarán este registro y su historial. Los activos, recursos, cuentas y personas relacionados se conservarán. Esta acción no se puede deshacer.",
|
||||
"pending": "Eliminando…",
|
||||
"error": "No se ha podido eliminar el registro. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
"helpTitle": "¿Qué es y para qué sirve?",
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
||||
import { ApplicationHistoryDialog } from "../../history";
|
||||
import { ApplicationConfigurationEditorDialog, ApplicationFeedback } from "../../shared";
|
||||
import { useApplicationConfigurationsPageController } from "../controllers";
|
||||
@ -34,8 +36,18 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
|
||||
canCreate,
|
||||
} = useApplicationConfigurationsPageController(props);
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DeleteRecordDialog
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onClose={() => setDeleteId(undefined)}
|
||||
path={
|
||||
deleteId ? `/api/application-configurations/${company.id}/${deleteId}/delete` : undefined
|
||||
}
|
||||
queryKey={["application-configurations", company.id]}
|
||||
/>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
@ -137,6 +149,9 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
|
||||
{t("deleteRecord.title")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
||||
import { CertificateHistoryDialog } from "../../history";
|
||||
import { CertificateConfigurationEditorDialog, CertificateFeedback } from "../../shared";
|
||||
import { useCertificateConfigurationsPageController } from "../controllers";
|
||||
@ -34,8 +36,18 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
|
||||
canCreate,
|
||||
} = useCertificateConfigurationsPageController(props);
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DeleteRecordDialog
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onClose={() => setDeleteId(undefined)}
|
||||
path={
|
||||
deleteId ? `/api/certificate-configurations/${company.id}/${deleteId}/delete` : undefined
|
||||
}
|
||||
queryKey={["certificate-configurations", company.id]}
|
||||
/>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
@ -144,6 +156,9 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
|
||||
{t("deleteRecord.title")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
||||
import { FolderMappingHistoryDialog } from "../../history";
|
||||
import { FolderFeedback, FolderMappingEditorDialog } from "../../shared";
|
||||
import { useFolderMappingsPageController } from "../controllers";
|
||||
@ -32,8 +34,16 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
|
||||
ready,
|
||||
} = useFolderMappingsPageController(props);
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DeleteRecordDialog
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onClose={() => setDeleteId(undefined)}
|
||||
path={deleteId ? `/api/folder-mappings/${company.id}/${deleteId}/delete` : undefined}
|
||||
queryKey={["folder-mappings", company.id]}
|
||||
/>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
@ -145,6 +155,9 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
|
||||
{t("deleteRecord.title")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
||||
import { MailConfigurationHistoryDialog } from "../../history";
|
||||
import { MailConfigurationEditorDialog, MailFeedback } from "../../shared";
|
||||
import { useMailConfigurationsPageController } from "../controllers";
|
||||
@ -32,8 +34,16 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
|
||||
filtered,
|
||||
} = useMailConfigurationsPageController(props);
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DeleteRecordDialog
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onClose={() => setDeleteId(undefined)}
|
||||
path={deleteId ? `/api/mail-configurations/${company.id}/${deleteId}/delete` : undefined}
|
||||
queryKey={["mail-configurations", company.id]}
|
||||
/>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
@ -136,6 +146,9 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
|
||||
{t("deleteRecord.title")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeleteRecordDialog } from "../../../shared/ui/delete-record-dialog";
|
||||
import { PrinterHistoryDialog } from "../../history";
|
||||
import { PrinterConfigurationEditorDialog, PrinterFeedback } from "../../shared";
|
||||
import { usePrinterConfigurationsPageController } from "../controllers";
|
||||
@ -33,8 +35,16 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
|
||||
canCreate,
|
||||
} = usePrinterConfigurationsPageController(props);
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DeleteRecordDialog
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onClose={() => setDeleteId(undefined)}
|
||||
path={deleteId ? `/api/printer-configurations/${company.id}/${deleteId}/delete` : undefined}
|
||||
queryKey={["printer-configurations", company.id]}
|
||||
/>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
@ -131,6 +141,9 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
|
||||
{t("deleteRecord.title")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
|
||||
13
modules/companies/src/web/shared/api/delete-record.api.ts
Normal file
13
modules/companies/src/web/shared/api/delete-record.api.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { z } from "zod/v4";
|
||||
export async function deleteRecord(path: string, token: string) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json", "x-csrf-token": token },
|
||||
body: "{}",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("DELETE_FAILED");
|
||||
}
|
||||
z.strictObject({ deleted: z.literal(true) }).parse(await response.json());
|
||||
}
|
||||
48
modules/companies/src/web/shared/ui/delete-record-dialog.tsx
Normal file
48
modules/companies/src/web/shared/ui/delete-record-dialog.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { deleteRecord } from "../api/delete-record.api";
|
||||
export function DeleteRecordDialog({
|
||||
path,
|
||||
csrfToken,
|
||||
queryKey,
|
||||
onClose,
|
||||
companyId,
|
||||
}: {
|
||||
path: string | undefined;
|
||||
csrfToken: string;
|
||||
queryKey: readonly string[];
|
||||
onClose: () => void;
|
||||
companyId: string;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<ConfirmDeleteDialog
|
||||
cancelLabel={t("cancel")}
|
||||
confirmLabel={t("deleteRecord.title")}
|
||||
description={t("deleteRecord.description")}
|
||||
errorLabel={t("deleteRecord.error")}
|
||||
onConfirm={async () => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
await deleteRecord(path, csrfToken);
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey }),
|
||||
client.invalidateQueries({ queryKey: ["person-processes", companyId] }),
|
||||
client.invalidateQueries({ queryKey: ["process-review", companyId] }),
|
||||
]);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open={Boolean(path)}
|
||||
pendingLabel={t("deleteRecord.pending")}
|
||||
title={t("deleteRecord.title")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
72
packages/rdx-ui/src/components/confirm-delete-dialog.tsx
Normal file
72
packages/rdx-ui/src/components/confirm-delete-dialog.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FormActions, FormBody, FormDialog } from "./form-dialog";
|
||||
|
||||
export function ConfirmDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
pendingLabel,
|
||||
errorLabel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
pendingLabel: string;
|
||||
errorLabel: string;
|
||||
onConfirm: () => Promise<unknown>;
|
||||
}) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={cancelLabel}
|
||||
formActions
|
||||
onOpenChange={(value) => {
|
||||
if (!pending) {
|
||||
setFailed(false);
|
||||
onOpenChange(value);
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
title={title}
|
||||
>
|
||||
<FormBody>
|
||||
<p>{description}</p>
|
||||
{failed && (
|
||||
<p className="text-destructive" role="alert">
|
||||
{errorLabel}
|
||||
</p>
|
||||
)}
|
||||
</FormBody>
|
||||
<FormActions>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
{pending ? pendingLabel : confirmLabel}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user