mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
feat: yard scoping to position
This commit is contained in:
@@ -26,6 +26,7 @@ import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
||||
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
|
||||
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
@@ -167,6 +168,10 @@ const RuleEngineResourcePage = () => {
|
||||
null,
|
||||
);
|
||||
const [chainOpen, setChainOpen] = useState(false);
|
||||
// Yards only: which desks work at this yard (input to yard access scoping).
|
||||
const [desksYard, setDesksYard] = useState<Record<string, unknown> | null>(
|
||||
null,
|
||||
);
|
||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
@@ -566,6 +571,17 @@ const RuleEngineResourcePage = () => {
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{config.slug === "yards" ? (
|
||||
<Tooltip label="Desks that work at this yard">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
onClick={() => setDesksYard(row.original)}
|
||||
>
|
||||
Desks
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{config.orderConfig && canUpdateControls ? (
|
||||
<RuleEngineOrderControls
|
||||
record={row.original}
|
||||
@@ -968,6 +984,21 @@ const RuleEngineResourcePage = () => {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<YardDesksModal
|
||||
opened={!!desksYard}
|
||||
onClose={() => setDesksYard(null)}
|
||||
readOnly={!canUpdateControls}
|
||||
yard={
|
||||
desksYard
|
||||
? {
|
||||
id: String(desksYard.id),
|
||||
code: String(desksYard.code ?? ""),
|
||||
label: String(desksYard.label ?? ""),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
<RuleEngineFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
import { yardPositionsService } from "@/services/yardPositions.service";
|
||||
|
||||
interface YardDesksModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
yard: { id: string; code: string; label: string } | null;
|
||||
/** Read-only when the caller lacks the yards update permission. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const positionLabel = (
|
||||
name: { am?: string; en?: string } | null,
|
||||
fallback: string,
|
||||
) => name?.en?.trim() || name?.am?.trim() || fallback;
|
||||
|
||||
/**
|
||||
* Which desks staff a yard — the input to yard access scoping.
|
||||
*
|
||||
* Saving REPLACES the yard's whole set (the API's PUT is a replace), which is
|
||||
* why the control is a multi-select holding the complete list rather than
|
||||
* add/remove buttons.
|
||||
*/
|
||||
export function YardDesksModal({
|
||||
opened,
|
||||
onClose,
|
||||
yard,
|
||||
readOnly = false,
|
||||
}: YardDesksModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const positions = useQuery({
|
||||
queryKey: ["yard-positions", "positions"],
|
||||
queryFn: yardPositionsService.listPositions,
|
||||
enabled: opened,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const mapping = useQuery({
|
||||
queryKey: ["yard-positions", "yard", yard?.id],
|
||||
queryFn: () => yardPositionsService.listByYard(yard!.id),
|
||||
enabled: opened && !!yard?.id,
|
||||
});
|
||||
|
||||
// Reset to what the server holds whenever the modal opens on a new yard, so a
|
||||
// cancelled edit never leaks into the next one.
|
||||
useEffect(() => {
|
||||
if (mapping.data) setSelected(mapping.data.map((row) => row.positionId));
|
||||
}, [mapping.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => yardPositionsService.setForYard(yard!.id, selected),
|
||||
onSuccess: () => {
|
||||
toast.success("Yard desks updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["yard-positions"] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(extractErrorMessage(error, "Failed to update yard desks")),
|
||||
});
|
||||
|
||||
const options = (positions.data ?? []).map((position) => ({
|
||||
value: position.id,
|
||||
label: positionLabel(position.name, position.id.slice(0, 8)),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={yard ? `Desks at ${yard.label} (${yard.code})` : "Desks"}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
Positions mapped here are the desks that work at this yard. Yard
|
||||
access scoping reads this mapping — a staff member acting on this
|
||||
desk is scoped to this yard.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{positions.isLoading || mapping.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<MultiSelect
|
||||
data={options}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
disabled={readOnly}
|
||||
label="Positions"
|
||||
placeholder={selected.length ? undefined : "Select positions"}
|
||||
description="Saving replaces the whole set — anything removed here loses this yard."
|
||||
searchable
|
||||
clearable
|
||||
hidePickedOptions
|
||||
maxDropdownHeight={280}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => save.mutate()}
|
||||
loading={save.isPending}
|
||||
disabled={readOnly || mapping.isLoading}
|
||||
title={readOnly ? "You cannot edit yards" : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user