Fix people deletion

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-12-22 14:30:25 +01:00
parent 0da25edc15
commit 233e197fdb
5 changed files with 186 additions and 36 deletions

View File

@@ -10,7 +10,7 @@ import {
IconBank,
IconBook,
IconBox,
IconCalendar1,
IconCalendar2,
IconCheckmark1,
IconChevronGrabberVertical,
IconCircleProgress,
@@ -122,7 +122,7 @@ function MainLayoutContent({
{isAuthorized("Organization", "listMeetings") && (
<SidebarItem
label={__("Meetings")}
icon={IconCalendar1}
icon={IconCalendar2}
to={`${prefix}/meetings`}
/>
)}
@@ -441,7 +441,7 @@ function OrganizationSelector({
{isLoading ? __("Loading...") : currentOrganization?.name || ""}
</Button>
}
>
>
<div className="px-3 py-2">
<Input
icon={IconMagnifyingGlass}
@@ -449,7 +449,7 @@ function OrganizationSelector({
value={search}
onValueChange={setSearch}
onKeyDown={(e) => {
e.stopPropagation();
e.stopPropagation();
}}
autoFocus
/>

View File

@@ -11,6 +11,7 @@ import {
ActionDropdown,
DropdownItem,
IconTrashCan,
IconCalendar2,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import type { PeopleGraphPaginatedQuery } from "/hooks/graph/__generated__/PeopleGraphPaginatedQuery.graphql";
@@ -22,9 +23,10 @@ import type { NodeOf } from "/types";
import { usePageTitle } from "@probo/hooks";
import { getRole } from "@probo/helpers";
import { CreatePeopleDialog } from "./dialogs/CreatePeopleDialog";
import { SetEndOfContractDialog, type SetEndOfContractDialogRef } from "./dialogs/SetEndOfContractDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { PermissionsContext } from "/providers/PermissionsContext";
import { use } from "react";
import { use, useRef } from "react";
type People = NodeOf<PeopleGraphPaginatedFragment$data["peoples"]>;
@@ -108,40 +110,56 @@ function PeopleRow({
const deletePeople = useDeletePeople(people, connectionId);
const contractEnded = isContractEnded(people);
const { isAuthorized } = use(PermissionsContext);
const dialogRef = useRef<SetEndOfContractDialogRef>(null);
return (
<Tr
to={`/organizations/${organizationId}/people/${people.id}/tasks`}
className={contractEnded ? "opacity-50" : ""}
>
<Td>
<div className="flex gap-3 items-center">
<Avatar name={people.fullName} />
<div>
<div className="text-sm">{people.fullName}</div>
<div className="text-xs text-txt-tertiary">
{people.primaryEmailAddress}
<>
<SetEndOfContractDialog
peopleId={people.id}
currentContractEndDate={people.contractEndDate}
ref={dialogRef}
/>
<Tr
to={`/organizations/${organizationId}/people/${people.id}/profile`}
className={contractEnded ? "opacity-50" : ""}
>
<Td>
<div className="flex gap-3 items-center">
<Avatar name={people.fullName} />
<div>
<div className="text-sm">{people.fullName}</div>
<div className="text-xs text-txt-tertiary">
{people.primaryEmailAddress}
</div>
</div>
</div>
</div>
</Td>
<Td className="text-sm">{getRole(__, people.kind)}</Td>
<Td className="text-sm">{people.position}</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{isAuthorized("People", "deletePeople") && (
<DropdownItem
icon={IconTrashCan}
variant="danger"
onClick={deletePeople}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
<Td className="text-sm">{getRole(__, people.kind)}</Td>
<Td className="text-sm">{people.position}</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{isAuthorized("People", "updatePeople") && (
<DropdownItem
icon={IconCalendar2}
onClick={() => dialogRef.current?.open()}
>
{__("Set end of contract")}
</DropdownItem>
)}
{isAuthorized("People", "deletePeople") && (
<DropdownItem
icon={IconTrashCan}
variant="danger"
onClick={deletePeople}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
</>
);
}

View File

@@ -0,0 +1,107 @@
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Spinner,
useDialogRef,
} from "@probo/ui";
import { forwardRef, useImperativeHandle } from "react";
import { z } from "zod";
import { formatDatetime, toDateInput } from "@probo/helpers";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { updatePeopleMutation } from "/hooks/graph/PeopleGraph";
const schema = z.object({
contractEndDate: z.string().optional(),
});
export type SetEndOfContractDialogRef = {
open: () => void;
close: () => void;
};
type Props = {
peopleId: string;
currentContractEndDate?: string | null;
};
export const SetEndOfContractDialog = forwardRef<SetEndOfContractDialogRef, Props>(function SetEndOfContractDialog(
{
peopleId,
currentContractEndDate,
},
ref
) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
useImperativeHandle(ref, () => ({
open: () => dialogRef.current?.open(),
close: () => dialogRef.current?.close(),
}));
const {
register,
handleSubmit,
formState: { isSubmitting },
reset,
} = useFormWithSchema(schema, {
defaultValues: {
contractEndDate: toDateInput(currentContractEndDate),
},
});
const [mutate] = useMutationWithToasts(updatePeopleMutation, {
successMessage: __("End of contract updated successfully"),
errorMessage: __("Failed to update end of contract"),
});
const onSubmit = handleSubmit(async (data) => {
await mutate({
variables: {
input: {
id: peopleId,
contractEndDate: formatDatetime(data.contractEndDate),
},
},
});
dialogRef.current?.close();
});
const handleClose = () => {
reset();
};
return (
<Dialog
title={__("Set End of Contract")}
ref={dialogRef}
className="max-w-lg"
onClose={handleClose}
>
<form onSubmit={onSubmit}>
<DialogContent padded className="space-y-4">
<Field label={__("End of contract")}>
<Input {...register("contractEndDate")} type="date" />
</Field>
</DialogContent>
<DialogFooter>
<Button
type="submit"
disabled={isSubmitting}
icon={isSubmitting ? Spinner : undefined}
>
{__("Update")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
});

View File

@@ -22,6 +22,7 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
@@ -52,6 +53,10 @@ type (
ErrPeopleAlreadyExists struct {
message string
}
ErrPeopleReferenced struct {
message string
}
)
func (e ErrPeopleNotFound) Error() string {
@@ -62,6 +67,10 @@ func (e ErrPeopleAlreadyExists) Error() string {
return e.message
}
func (e ErrPeopleReferenced) Error() string {
return e.message
}
func (p People) CursorKey(orderBy PeopleOrderField) page.CursorKey {
switch orderBy {
case PeopleOrderFieldCreatedAt:
@@ -349,7 +358,19 @@ DELETE FROM peoples WHERE %s AND id = @people_id
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
return err
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23503" {
return &ErrPeopleReferenced{
message: fmt.Sprintf("person with id %s cannot be deleted because it is referenced by other records", p.ID),
}
}
}
return fmt.Errorf("cannot delete person: %w", err)
}
return nil
}
func (p *Peoples) CountByOrganizationID(

View File

@@ -2126,6 +2126,10 @@ func (r *mutationResolver) DeletePeople(ctx context.Context, input types.DeleteP
err := prb.Peoples.Delete(ctx, input.PeopleID)
if err != nil {
var errReferenced *coredata.ErrPeopleReferenced
if errors.As(err, &errReferenced) {
return nil, gqlutils.Conflict(errReferenced)
}
panic(fmt.Errorf("cannot delete people: %w", err))
}