Añadir funcionalidad para eliminar procesos y mejorar la gestión de procesos en la aplicación

This commit is contained in:
David Arranz 2026-10-09 23:11:31 +02:00
parent a622eacfe7
commit 564b666fcc
12 changed files with 63 additions and 11 deletions

View File

@ -19,6 +19,7 @@ export interface IProcessRepository {
action: (context: ProcessCatalogContext) => Result<ProcessTemplate, ProcessError> action: (context: ProcessCatalogContext) => Result<ProcessTemplate, ProcessError>
): Promise<Result<{ template: ProcessTemplate; context: ProcessCatalogContext }, ProcessError>>; ): Promise<Result<{ template: ProcessTemplate; context: ProcessCatalogContext }, ProcessError>>;
get(company: UniqueID): Promise<Result<ProcessContext, ProcessError>>; get(company: UniqueID): Promise<Result<ProcessContext, ProcessError>>;
remove(company: UniqueID, process: UniqueID): Promise<Result<void, ProcessError>>;
save( save(
company: UniqueID, company: UniqueID,
action: (context: ProcessContext) => Result<PersonProcess, ProcessError> action: (context: ProcessContext) => Result<PersonProcess, ProcessError>

View File

@ -105,6 +105,12 @@ export class ProcessUseCases {
: values; : values;
}); });
} }
remove(companyId: string, id: string) {
const company = UniqueID.create(companyId);
const process = UniqueID.create(id);
if (!(company.isSuccess && process.isSuccess)) return Promise.resolve(Result.fail(new ProcessError("INVALID_REQUEST")));
return this.repository.remove(company.data, process.data);
}
update(companyId: string, id: string, input: UpdateProcessInput, actorId: string) { update(companyId: string, id: string, input: UpdateProcessInput, actorId: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId); const actor = UniqueID.create(actorId);

View File

@ -146,6 +146,15 @@ export class SequelizeProcessRepository implements IProcessRepository {
return Result.ok({ template: candidate.data, context }); return Result.ok({ template: candidate.data, context });
}); });
} }
remove(company: UniqueID, process: UniqueID) {
return this.database.transaction(async (transaction) => {
const rows = await this.database.query<{ id: string }>("SELECT id FROM person_processes WHERE id=:id AND companyId=:companyId FOR UPDATE", { replacements: { id: process.toPrimitive(), companyId: company.toPrimitive() }, transaction, type: QueryTypes.SELECT });
if (!rows.length) return Result.fail(new ProcessError("PROCESS_NOT_FOUND"));
await this.database.query("DELETE FROM person_process_revisions WHERE itemId=:id", { replacements: { id: process.toPrimitive() }, transaction });
await this.database.query("DELETE FROM person_processes WHERE id=:id AND companyId=:companyId", { replacements: { id: process.toPrimitive(), companyId: company.toPrimitive() }, transaction });
return Result.ok(undefined);
});
}
save(company: UniqueID, action: Parameters<IProcessRepository["save"]>[1]) { save(company: UniqueID, action: Parameters<IProcessRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => { return this.database.transaction(async (transaction) => {
const loaded = await this.load(company, transaction, true); const loaded = await this.load(company, transaction, true);

View File

@ -327,7 +327,9 @@
"INTERNAL_ERROR": "The operation could not be completed. Try again.", "INTERNAL_ERROR": "The operation could not be completed. Try again.",
"FORBIDDEN": "Sign in again.", "FORBIDDEN": "Sign in again.",
"UNAUTHENTICATED": "Sign in again." "UNAUTHENTICATED": "Sign in again."
} },
"delete": "Delete process",
"deleteConfirm": "Do you want to delete the process “{{name}}”? This also deletes its history."
}, },
"remote": { "remote": {
"title": "Remote access", "title": "Remote access",

View File

@ -327,7 +327,9 @@
"INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo.", "INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo.",
"FORBIDDEN": "Vuelve a iniciar sesión.", "FORBIDDEN": "Vuelve a iniciar sesión.",
"UNAUTHENTICATED": "Vuelve a iniciar sesión." "UNAUTHENTICATED": "Vuelve a iniciar sesión."
} },
"delete": "Eliminar proceso",
"deleteConfirm": "¿Quieres eliminar el proceso «{{name}}»? Esta acción también elimina su historial."
}, },
"remote": { "remote": {
"title": "Accesos remotos", "title": "Accesos remotos",

View File

@ -4,4 +4,6 @@ export type ProcessDetailProps = {
process: PersonProcessDTO; process: PersonProcessDTO;
person: string; person: string;
csrfToken: string; csrfToken: string;
onDelete?: (id: string) => void;
onDeleted?: () => void;
}; };

View File

@ -1,4 +1,5 @@
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { Card, CardContent } from "@repo/shadcn-ui/components/card"; import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -9,9 +10,10 @@ import { useProcessDetailController } from "../controllers";
import type { ProcessDetailProps } from "../types"; import type { ProcessDetailProps } from "../types";
export function ProcessDetail(props: ProcessDetailProps) { export function ProcessDetail(props: ProcessDetailProps) {
const { process, person, csrfToken } = props; const { process, person, csrfToken, onDelete, onDeleted } = props;
const { edit, setEdit, refresh, open, resolved } = useProcessDetailController(props); const { edit, setEdit, refresh, open, resolved } = useProcessDetailController(props);
const { t, i18n } = useTranslation("companies"); const { t, i18n } = useTranslation("companies");
const [deleting, setDeleting] = useState(false);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<header className="flex flex-wrap items-start justify-between gap-3"> <header className="flex flex-wrap items-start justify-between gap-3">
@ -39,6 +41,18 @@ export function ProcessDetail(props: ProcessDetailProps) {
<Button disabled={refresh.isPending} onClick={() => setEdit(true)}> <Button disabled={refresh.isPending} onClick={() => setEdit(true)}>
{t("processes.update")} {t("processes.update")}
</Button> </Button>
<Button
disabled={deleting}
variant="destructive"
onClick={() => {
if (!window.confirm(t("processes.deleteConfirm", { name: process.values.name }))) return;
setDeleting(true);
onDelete?.(process.id);
onDeleted?.();
}}
>
{t("processes.delete")}
</Button>
</div> </div>
)} )}
</header> </header>

View File

@ -4,3 +4,7 @@ import { processRequest } from "../../shared/api/process-request";
export async function getProcesses(company: string) { export async function getProcesses(company: string) {
return ProcessesResponseSchema.parse(await processRequest(company)); return ProcessesResponseSchema.parse(await processRequest(company));
} }
export async function deleteProcess(company: string, id: string, token: string) {
await processRequest(`${company}/${id}/delete`, {}, token);
}

View File

@ -1 +1 @@
export { getProcesses } from "./get-processes.api"; export { deleteProcess, getProcesses } from "./get-processes.api";

View File

@ -1,14 +1,15 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client"; import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { processKeys } from "../../shared/queries"; import { processKeys } from "../../shared/queries";
import { getProcesses } from "../api"; import { deleteProcess, getProcesses } from "../api";
import type { ProcessesPageProps } from "../types"; import type { ProcessesPageProps } from "../types";
export function useProcessesPageController({ company }: ProcessesPageProps) { export function useProcessesPageController({ company }: ProcessesPageProps) {
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
const client = useQueryClient();
const [create, setCreate] = useState(false); const [create, setCreate] = useState(false);
const { selectedId: selected, selectEntity: setSelected } = useEntityNavigation( const { selectedId: selected, selectEntity: setSelected } = useEntityNavigation(
`/companies/${company.id}/processes` `/companies/${company.id}/processes`
@ -32,6 +33,7 @@ export function useProcessesPageController({ company }: ProcessesPageProps) {
params.delete("closed"); params.delete("closed");
} }
}); });
const remove = useMutation({ mutationFn: ({ id, token }: { id: string; token: string }) => deleteProcess(company.id, id, token), onSuccess: async () => { await client.invalidateQueries({ queryKey: processKeys.company(company.id) }); setSelected(undefined); } });
const query = useQuery({ const query = useQuery({
queryKey: processKeys.company(company.id), queryKey: processKeys.company(company.id),
queryFn: () => getProcesses(company.id), queryFn: () => getProcesses(company.id),
@ -56,6 +58,7 @@ export function useProcessesPageController({ company }: ProcessesPageProps) {
closed, closed,
setClosed, setClosed,
query, query,
remove,
person, person,
current, current,
missingView: Boolean(selected && query.isSuccess && !current), missingView: Boolean(selected && query.isSuccess && !current),

View File

@ -28,6 +28,7 @@ export function ProcessesPage(props: ProcessesPageProps) {
closed, closed,
setClosed, setClosed,
query, query,
remove,
person, person,
current, current,
missingView, missingView,
@ -66,6 +67,8 @@ export function ProcessesPage(props: ProcessesPageProps) {
csrfToken={csrfToken} csrfToken={csrfToken}
person={person(current.values.personId)} person={person(current.values.personId)}
process={current} process={current}
onDeleted={() => setSelected(undefined)}
onDelete={(id) => remove.mutate({ id, token: csrfToken })}
/> />
) : ( ) : (
!selected && ( !selected && (
@ -150,10 +153,16 @@ export function ProcessesPage(props: ProcessesPageProps) {
hideable: false, hideable: false,
render: (p) => ( render: (p) => (
<EntityRowActions label={t("actions")}> <EntityRowActions label={t("actions")}>
{" "} <EntityAction onClick={() => setSelected(p.id)}>{t("processes.openDetail")}</EntityAction>
<EntityAction onClick={() => setSelected(p.id)}> <EntityAction
{t("processes.openDetail")} onClick={() => {
</EntityAction>{" "} if (window.confirm(t("processes.deleteConfirm", { name: p.values.name }))) {
remove.mutate({ id: p.id, token: csrfToken });
}
}}
>
{t("processes.delete")}
</EntityAction>
</EntityRowActions> </EntityRowActions>
), ),
}, },

View File

@ -10,7 +10,7 @@ export async function processRequest(path: string, body?: unknown, token?: strin
body === undefined ? {} : { "content-type": "application/json", "x-csrf-token": token ?? "" }, body === undefined ? {} : { "content-type": "application/json", "x-csrf-token": token ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }), ...(body === undefined ? {} : { body: JSON.stringify(body) }),
}); });
const data: unknown = await response.json(); const data: unknown = response.status === 204 ? undefined : await response.json();
if (!response.ok) { if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data); const error = z.object({ code: z.string() }).safeParse(data);
throw new ProcessApiError(error.success ? error.data.code : "INTERNAL_ERROR"); throw new ProcessApiError(error.success ? error.data.code : "INTERNAL_ERROR");