Merge pull request #1335 from Tria-plc/freight/feat/yard-loc-ac

feat: yard scoping to position
This commit is contained in:
Nathnael Wondisha
2026-08-18 15:57:03 +03:00
committed by GitHub
27 changed files with 2657 additions and 257 deletions

View File

@@ -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}

View File

@@ -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>
);
}