mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { api as apiClient } from "../auth/http";
|
||||
|
||||
// NOTE: `auth/http`'s response interceptor already unwraps the API's
|
||||
// `{ success, data }` envelope, so `response.data` IS the payload here — a
|
||||
// second `.data` hop reads undefined and silently yields an empty list.
|
||||
|
||||
/** A desk mapped to a yard, joined to its IAM position for display. */
|
||||
export interface YardPositionRow {
|
||||
id: string;
|
||||
yardId: string;
|
||||
yardCode: string;
|
||||
yardLabel: string;
|
||||
positionId: string;
|
||||
positionName: { am?: string; en?: string } | null;
|
||||
positionTypeKey: string | null;
|
||||
}
|
||||
|
||||
export interface SelectablePosition {
|
||||
id: string;
|
||||
name: { am?: string; en?: string } | null;
|
||||
positionTypeKey: string | null;
|
||||
unitKey: string | null;
|
||||
}
|
||||
|
||||
export interface MyYardScope {
|
||||
/** null = unrestricted (super admin or `yards:view_all`). */
|
||||
yardIds: string[] | null;
|
||||
unrestricted: boolean;
|
||||
/** False while the backend is still shadow-logging instead of denying. */
|
||||
enforced: boolean;
|
||||
}
|
||||
|
||||
export const yardPositionsService = {
|
||||
listByYard: async (yardId: string): Promise<YardPositionRow[]> => {
|
||||
const { data } = await apiClient.get(`/yard-positions`, {
|
||||
params: { yardId },
|
||||
});
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
listPositions: async (): Promise<SelectablePosition[]> => {
|
||||
const { data } = await apiClient.get(`/yard-positions/positions`);
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
myScope: async (): Promise<MyYardScope> => {
|
||||
const { data } = await apiClient.get(`/yard-positions/my-yards`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the yard's whole desk set — send every position that should remain
|
||||
* mapped, not just the additions.
|
||||
*/
|
||||
setForYard: async (
|
||||
yardId: string,
|
||||
positionIds: string[],
|
||||
): Promise<YardPositionRow[]> => {
|
||||
const { data } = await apiClient.put(`/yard-positions/yard/${yardId}`, {
|
||||
positionIds,
|
||||
});
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
@@ -1,250 +1,258 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Archive,
|
||||
BarChart,
|
||||
Building2,
|
||||
ChartAreaIcon,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Globe,
|
||||
Settings,
|
||||
Users2,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/shared/common/ui/sidebar";
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
roles?: string[];
|
||||
/** Sidebar section this item is bucketed under. */
|
||||
group: string;
|
||||
}
|
||||
|
||||
// Section render order; groups with no role-visible items are skipped.
|
||||
const GROUP_ORDER = [
|
||||
"Overview",
|
||||
"Organizations",
|
||||
"Content",
|
||||
"Records",
|
||||
"Configuration",
|
||||
"Archive",
|
||||
"System",
|
||||
];
|
||||
|
||||
export const AppMenuTabs = () => {
|
||||
const { user } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const userRoles = user?.roles.map((role) => role.key) || [];
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "organizations",
|
||||
href: "/user-management/organizations",
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "organizationAdmins",
|
||||
href: "/user-management/organization_admins",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "externalUsers",
|
||||
href: "/user-management/external_users",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/user_management-dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "userManagement",
|
||||
href: "/user-management/user_management",
|
||||
icon: <UsersRound className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "contentManagement",
|
||||
href: "/user-management/content-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "webManagement",
|
||||
href: "/user-management/web-management",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Bulk",
|
||||
href: "/user-management/bulk-upload",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Position",
|
||||
href: "/user-management/position-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "settings",
|
||||
href: "/user-management/organization-settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "Add Site",
|
||||
href: "/user-management/add-site",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "migratedRecords",
|
||||
href: "/user-management/migrated-records-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Records",
|
||||
},
|
||||
{
|
||||
label: "Sector Reports",
|
||||
href: "/user-management/sector-reports",
|
||||
icon: <ChartAreaIcon className="h-4 w-4" />,
|
||||
roles: ["unit_admin", "admin", "organization_admin"],
|
||||
group: "Records",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
href: "/user-management/archive-users",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archived Organizations",
|
||||
href: "/user-management/archived-organizations",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
href: "/user-management/archives",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archived Units & Positions",
|
||||
href: "/user-management/archived",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "activityLog",
|
||||
href: "/user-management/activity_log",
|
||||
icon: <ClipboardList className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
{
|
||||
label: "setting",
|
||||
href: "/user-management/settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
{
|
||||
label: "Letter Template",
|
||||
href: "/user-management/templates",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredMenu = menuItems.filter((item) =>
|
||||
item.roles?.some((r) => userRoles.includes(r)),
|
||||
);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<>
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const items = filteredMenu.filter((item) => item.group === group);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SidebarGroup key={group} className="pb-0">
|
||||
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const label = t(`organization.${item.label}`, item.label);
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive(item.href)}
|
||||
tooltip={label}
|
||||
>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Archive,
|
||||
BarChart,
|
||||
Building2,
|
||||
ChartAreaIcon,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Globe,
|
||||
MapPin,
|
||||
Settings,
|
||||
Users2,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/shared/common/ui/sidebar";
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
roles?: string[];
|
||||
/** Sidebar section this item is bucketed under. */
|
||||
group: string;
|
||||
}
|
||||
|
||||
// Section render order; groups with no role-visible items are skipped.
|
||||
const GROUP_ORDER = [
|
||||
"Overview",
|
||||
"Organizations",
|
||||
"Content",
|
||||
"Records",
|
||||
"Configuration",
|
||||
"Archive",
|
||||
"System",
|
||||
];
|
||||
|
||||
export const AppMenuTabs = () => {
|
||||
const { user } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const userRoles = user?.roles.map((role) => role.key) || [];
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "organizations",
|
||||
href: "/user-management/organizations",
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "organizationAdmins",
|
||||
href: "/user-management/organization_admins",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "externalUsers",
|
||||
href: "/user-management/external_users",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/user_management-dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "userManagement",
|
||||
href: "/user-management/user_management",
|
||||
icon: <UsersRound className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "contentManagement",
|
||||
href: "/user-management/content-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "webManagement",
|
||||
href: "/user-management/web-management",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Bulk",
|
||||
href: "/user-management/bulk-upload",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Position",
|
||||
href: "/user-management/position-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "Locations",
|
||||
href: "/user-management/locations",
|
||||
icon: <MapPin className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "settings",
|
||||
href: "/user-management/organization-settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "Add Site",
|
||||
href: "/user-management/add-site",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Configuration",
|
||||
},
|
||||
{
|
||||
label: "migratedRecords",
|
||||
href: "/user-management/migrated-records-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Records",
|
||||
},
|
||||
{
|
||||
label: "Sector Reports",
|
||||
href: "/user-management/sector-reports",
|
||||
icon: <ChartAreaIcon className="h-4 w-4" />,
|
||||
roles: ["unit_admin", "admin", "organization_admin"],
|
||||
group: "Records",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
href: "/user-management/archive-users",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archived Organizations",
|
||||
href: "/user-management/archived-organizations",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
href: "/user-management/archives",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "Archived Units & Positions",
|
||||
href: "/user-management/archived",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
group: "Archive",
|
||||
},
|
||||
{
|
||||
label: "activityLog",
|
||||
href: "/user-management/activity_log",
|
||||
icon: <ClipboardList className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
{
|
||||
label: "setting",
|
||||
href: "/user-management/settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
{
|
||||
label: "Letter Template",
|
||||
href: "/user-management/templates",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
group: "System",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredMenu = menuItems.filter((item) =>
|
||||
item.roles?.some((r) => userRoles.includes(r)),
|
||||
);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<>
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const items = filteredMenu.filter((item) => item.group === group);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SidebarGroup key={group} className="pb-0">
|
||||
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const label = t(`organization.${item.label}`, item.label);
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive(item.href)}
|
||||
tooltip={label}
|
||||
>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
APIProvider,
|
||||
Map as GoogleMap,
|
||||
Marker,
|
||||
type MapMouseEvent,
|
||||
} from "@vis.gl/react-google-maps";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Textarea } from "@/shared/common/ui/textarea";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import type {
|
||||
Location,
|
||||
LocationPayload,
|
||||
LocationType,
|
||||
} from "@/user-management/dto/locations/location.type";
|
||||
import { useLocations } from "@/user-management/hooks/useLocations";
|
||||
|
||||
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||
/** Addis Ababa — where every EDR location is within a map pan. */
|
||||
const DEFAULT_CENTER = { lat: 9.032, lng: 38.7469 };
|
||||
|
||||
const NO_PARENT = "__none__";
|
||||
|
||||
const numeric = (label: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((v) => !v || !Number.isNaN(Number(v)), `${label} must be a number`);
|
||||
|
||||
const locationSchema = z.object({
|
||||
nameAm: z.string().trim().min(1, "Amharic name is required"),
|
||||
nameEn: z.string().trim().optional(),
|
||||
code: z.string().trim().min(1, "Code is required"),
|
||||
locationTypeId: z.string().uuid("Location type is required"),
|
||||
parentId: z.string().optional(),
|
||||
latitude: numeric("Latitude"),
|
||||
longitude: numeric("Longitude"),
|
||||
area: numeric("Area"),
|
||||
boundaryJson: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((v) => {
|
||||
if (!v) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(v);
|
||||
return typeof parsed === "object" && parsed !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Boundary must be a JSON object"),
|
||||
});
|
||||
|
||||
export type LocationFormValues = z.infer<typeof locationSchema>;
|
||||
|
||||
interface LocationFormProps {
|
||||
mode: "create" | "edit";
|
||||
location?: Location;
|
||||
locationTypes: LocationType[];
|
||||
/** Every location, for the parent picker — the API has no filter endpoint. */
|
||||
allLocations: Location[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function LocationForm({
|
||||
mode,
|
||||
location,
|
||||
locationTypes,
|
||||
allLocations,
|
||||
onSuccess,
|
||||
}: LocationFormProps) {
|
||||
const localizedName = useLocalizedName();
|
||||
const { createLocation, updateLocation, isCreatingLocation, isUpdatingLocation } =
|
||||
useLocations();
|
||||
|
||||
const form = useForm<LocationFormValues>({
|
||||
resolver: zodResolver(locationSchema),
|
||||
defaultValues: {
|
||||
nameAm: location?.names?.am ?? "",
|
||||
nameEn: location?.names?.en ?? "",
|
||||
code: location?.code ?? "",
|
||||
locationTypeId: location?.locationTypeId ?? "",
|
||||
parentId: location?.parentId ?? NO_PARENT,
|
||||
latitude: location?.latitude ?? "",
|
||||
longitude: location?.longitude ?? "",
|
||||
area: location?.area ?? "",
|
||||
boundaryJson: location?.boundaryJson
|
||||
? JSON.stringify(location.boundaryJson, null, 2)
|
||||
: "",
|
||||
},
|
||||
});
|
||||
|
||||
const [lat, lng] = [form.watch("latitude"), form.watch("longitude")];
|
||||
const pin =
|
||||
lat && lng && !Number.isNaN(Number(lat)) && !Number.isNaN(Number(lng))
|
||||
? { lat: Number(lat), lng: Number(lng) }
|
||||
: null;
|
||||
|
||||
const dropPin = (event: MapMouseEvent) => {
|
||||
const point = event.detail.latLng;
|
||||
if (!point) return;
|
||||
form.setValue("latitude", point.lat.toFixed(6), { shouldDirty: true });
|
||||
form.setValue("longitude", point.lng.toFixed(6), { shouldDirty: true });
|
||||
};
|
||||
|
||||
// ponytail: self only, not descendants — the API accepts any parentId, so a
|
||||
// deep cycle (A → B → A) is still possible. Walk the chain here if it bites.
|
||||
const parentOptions = allLocations.filter((item) => item.id !== location?.id);
|
||||
|
||||
const submit = (values: LocationFormValues) => {
|
||||
const payload: LocationPayload = {
|
||||
names: {
|
||||
am: values.nameAm,
|
||||
...(values.nameEn ? { en: values.nameEn } : {}),
|
||||
},
|
||||
code: values.code,
|
||||
locationTypeId: values.locationTypeId,
|
||||
parentId:
|
||||
values.parentId && values.parentId !== NO_PARENT
|
||||
? values.parentId
|
||||
: undefined,
|
||||
latitude: values.latitude || undefined,
|
||||
longitude: values.longitude || undefined,
|
||||
area: values.area || undefined,
|
||||
boundaryJson: values.boundaryJson
|
||||
? (JSON.parse(values.boundaryJson) as Record<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
createLocation(payload, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (location) {
|
||||
updateLocation(
|
||||
{ id: location.id, payload },
|
||||
{ onSuccess: () => onSuccess?.() },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameAm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amharic Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="አዲስ አበባ" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameEn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>English Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Addis Ababa" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="LOC-001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locationTypeId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Location Type *</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{locationTypes.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
{localizedName(type.names)} · L{type.level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parentId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Parent Location</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No parent (top level)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PARENT}>
|
||||
No parent (top level)
|
||||
</SelectItem>
|
||||
{parentOptions.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{localizedName(item.names)} ({item.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Coordinates</FormLabel>
|
||||
{GOOGLE_MAPS_API_KEY ? (
|
||||
<div className="h-64 w-full overflow-hidden rounded-md border">
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={pin ?? DEFAULT_CENTER}
|
||||
defaultZoom={pin ? 12 : 6}
|
||||
gestureHandling="greedy"
|
||||
disableDefaultUI={false}
|
||||
onClick={dropPin}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
{pin ? <Marker position={pin} /> : null}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
</div>
|
||||
) : (
|
||||
// Name the missing variable rather than rendering a dead grey box.
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Map picker unavailable — <code>VITE_GOOGLE_MAPS_API_KEY</code> is
|
||||
not set. Type the coordinates below instead.
|
||||
</p>
|
||||
)}
|
||||
{GOOGLE_MAPS_API_KEY ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click the map to drop a pin, or type the values.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="latitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Latitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="9.032000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="longitude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Longitude</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="38.746900" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="area"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Area</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="1000.25" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="boundaryJson"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Boundary (GeoJSON)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder='{"type":"Polygon","coordinates":[]}'
|
||||
className="font-mono text-xs"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isCreatingLocation || isUpdatingLocation}
|
||||
>
|
||||
{mode === "create" ? "Create Location" : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Textarea } from "@/shared/common/ui/textarea";
|
||||
import type {
|
||||
LocationType,
|
||||
LocationTypePayload,
|
||||
} from "@/user-management/dto/locations/location.type";
|
||||
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
|
||||
|
||||
const locationTypeSchema = z.object({
|
||||
nameAm: z.string().trim().min(1, "Amharic name is required"),
|
||||
nameEn: z.string().trim().optional(),
|
||||
code: z.string().trim().min(1, "Code is required"),
|
||||
description: z.string().trim().optional(),
|
||||
// Level is the hierarchy depth (1 = country, 2 = region, …). Server takes a
|
||||
// number, so an empty string would post NaN.
|
||||
level: z.coerce.number().int().min(1, "Level must be 1 or greater"),
|
||||
});
|
||||
|
||||
export type LocationTypeFormValues = z.input<typeof locationTypeSchema>;
|
||||
|
||||
interface LocationTypeFormProps {
|
||||
mode: "create" | "edit";
|
||||
locationType?: LocationType;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function LocationTypeForm({
|
||||
mode,
|
||||
locationType,
|
||||
onSuccess,
|
||||
}: LocationTypeFormProps) {
|
||||
const {
|
||||
createLocationType,
|
||||
updateLocationType,
|
||||
isCreatingLocationType,
|
||||
isUpdatingLocationType,
|
||||
} = useLocationTypes();
|
||||
|
||||
const form = useForm<LocationTypeFormValues, unknown, z.output<typeof locationTypeSchema>>({
|
||||
resolver: zodResolver(locationTypeSchema),
|
||||
defaultValues: {
|
||||
nameAm: locationType?.names?.am ?? "",
|
||||
nameEn: locationType?.names?.en ?? "",
|
||||
code: locationType?.code ?? "",
|
||||
description: locationType?.description ?? "",
|
||||
level: locationType?.level ?? 1,
|
||||
},
|
||||
});
|
||||
|
||||
const submit = (values: z.output<typeof locationTypeSchema>) => {
|
||||
const payload: LocationTypePayload = {
|
||||
names: {
|
||||
am: values.nameAm,
|
||||
...(values.nameEn ? { en: values.nameEn } : {}),
|
||||
},
|
||||
code: values.code,
|
||||
description: values.description || undefined,
|
||||
level: values.level,
|
||||
};
|
||||
|
||||
if (mode === "create") {
|
||||
createLocationType(payload, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (locationType) {
|
||||
updateLocationType(
|
||||
{ id: locationType.id, payload },
|
||||
{ onSuccess: () => onSuccess?.() },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameAm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amharic Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="ከተማ" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameEn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>English Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="City" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="CITY" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="level"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Level *</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min={1} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} placeholder="City level location" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isCreatingLocationType || isUpdatingLocationType}
|
||||
>
|
||||
{mode === "create" ? "Create Location Type" : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { isSuperAdmin } from "@/lib/permissions";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { usePermissions } from "@/shared/context/PermissionContext";
|
||||
import type { LocationType } from "@/user-management/dto/locations/location.type";
|
||||
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
|
||||
import { LocationTypeForm } from "./LocationTypeForm";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function LocationTypesTab() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [editing, setEditing] = useState<LocationType | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleting, setDeleting] = useState<LocationType | null>(null);
|
||||
|
||||
const localizedName = useLocalizedName();
|
||||
const { permissions } = usePermissions();
|
||||
const { user } = useAuth();
|
||||
const superAdmin = isSuperAdmin(user);
|
||||
const can = (key: string) => superAdmin || permissions.includes(key);
|
||||
|
||||
const {
|
||||
locationTypes,
|
||||
isLoadingLocationTypes,
|
||||
refetchLocationTypes,
|
||||
deleteLocationType,
|
||||
isDeletingLocationType,
|
||||
} = useLocationTypes({
|
||||
skip: pageIndex * PAGE_SIZE,
|
||||
take: PAGE_SIZE,
|
||||
orderBy: "level:ASC",
|
||||
});
|
||||
|
||||
const columns: ColumnDef<LocationType>[] = [
|
||||
{
|
||||
accessorKey: "names",
|
||||
header: () => "Name",
|
||||
cell: ({ row }) => <span>{localizedName(row.original.names)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: () => "Code",
|
||||
cell: ({ row }) => <span>{row.original.code}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "level",
|
||||
header: () => "Level",
|
||||
cell: ({ row }) => <span>{row.original.level}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: () => "Description",
|
||||
cell: ({ row }) => <span>{row.original.description || "--"}</span>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => "Actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!can("can:update:location_type")}
|
||||
title={
|
||||
can("can:update:location_type")
|
||||
? "Edit"
|
||||
: "You cannot edit location types"
|
||||
}
|
||||
onClick={() => setEditing(row.original)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!can("can:delete:location_type")}
|
||||
title={
|
||||
can("can:delete:location_type")
|
||||
? "Delete"
|
||||
: "You cannot delete location types"
|
||||
}
|
||||
onClick={() => setDeleting(row.original)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={!can("can:create:location_type")}
|
||||
title={
|
||||
can("can:create:location_type")
|
||||
? undefined
|
||||
: "You cannot create location types"
|
||||
}
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
New Location Type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={locationTypes?.items ?? []}
|
||||
tableName="Location Types"
|
||||
isLoading={isLoadingLocationTypes}
|
||||
itemCount={locationTypes?.count ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPageIndex}
|
||||
nextFunction={() => setPageIndex((page) => page + 1)}
|
||||
prevFunction={() => setPageIndex((page) => Math.max(page - 1, 0))}
|
||||
refresh={refetchLocationTypes}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={creating || !!editing}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? "Edit Location Type" : "New Location Type"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LocationTypeForm
|
||||
key={editing?.id ?? "create"}
|
||||
mode={editing ? "edit" : "create"}
|
||||
locationType={editing ?? undefined}
|
||||
onSuccess={() => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(open) => !open && setDeleting(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete {deleting ? localizedName(deleting.names) : ""}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This is a permanent delete. Locations already using this type will
|
||||
block it at the foreign key.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isDeletingLocationType}
|
||||
onClick={() => {
|
||||
if (deleting) {
|
||||
deleteLocationType(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { isSuperAdmin } from "@/lib/permissions";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { usePermissions } from "@/shared/context/PermissionContext";
|
||||
import type { Location } from "@/user-management/dto/locations/location.type";
|
||||
import { useLocations } from "@/user-management/hooks/useLocations";
|
||||
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
|
||||
import { LocationForm } from "./LocationForm";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
/** The API has no filter endpoint, so the parent picker and the type/parent
|
||||
* name columns are resolved from one big list. */
|
||||
const LOOKUP_TAKE = 1000;
|
||||
|
||||
export function LocationsTab() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [editing, setEditing] = useState<Location | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Location | null>(null);
|
||||
|
||||
const localizedName = useLocalizedName();
|
||||
const { permissions } = usePermissions();
|
||||
const { user } = useAuth();
|
||||
const superAdmin = isSuperAdmin(user);
|
||||
const can = (key: string) => superAdmin || permissions.includes(key);
|
||||
|
||||
const { locations, isLoadingLocations, refetchLocations, deleteLocation, isDeletingLocation } =
|
||||
useLocations({ skip: pageIndex * PAGE_SIZE, take: PAGE_SIZE });
|
||||
const { locations: allLocations } = useLocations({ take: LOOKUP_TAKE });
|
||||
const { locationTypes } = useLocationTypes({ take: LOOKUP_TAKE });
|
||||
|
||||
const typeName = useMemo(() => {
|
||||
const byId = new Map(
|
||||
(locationTypes?.items ?? []).map((type) => [type.id, type]),
|
||||
);
|
||||
return (id: string) => {
|
||||
const type = byId.get(id);
|
||||
return type ? `${localizedName(type.names)} (L${type.level})` : "--";
|
||||
};
|
||||
}, [locationTypes, localizedName]);
|
||||
|
||||
const parentName = useMemo(() => {
|
||||
const byId = new Map(
|
||||
(allLocations?.items ?? []).map((item) => [item.id, item]),
|
||||
);
|
||||
return (id?: string | null) => {
|
||||
if (!id) return "--";
|
||||
const parent = byId.get(id);
|
||||
return parent ? localizedName(parent.names) : id.slice(0, 8);
|
||||
};
|
||||
}, [allLocations, localizedName]);
|
||||
|
||||
const columns: ColumnDef<Location>[] = [
|
||||
{
|
||||
accessorKey: "names",
|
||||
header: () => "Name",
|
||||
cell: ({ row }) => <span>{localizedName(row.original.names)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: () => "Code",
|
||||
cell: ({ row }) => <span>{row.original.code}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "locationTypeId",
|
||||
header: () => "Type",
|
||||
cell: ({ row }) => <span>{typeName(row.original.locationTypeId)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "parentId",
|
||||
header: () => "Parent",
|
||||
cell: ({ row }) => <span>{parentName(row.original.parentId)}</span>,
|
||||
},
|
||||
{
|
||||
id: "coordinates",
|
||||
header: () => "Coordinates",
|
||||
cell: ({ row }) => {
|
||||
const { latitude, longitude } = row.original;
|
||||
return (
|
||||
<span>{latitude && longitude ? `${latitude}, ${longitude}` : "--"}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => "Actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!can("can:update:location")}
|
||||
title={
|
||||
can("can:update:location") ? "Edit" : "You cannot edit locations"
|
||||
}
|
||||
onClick={() => setEditing(row.original)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!can("can:delete:location")}
|
||||
title={
|
||||
can("can:delete:location")
|
||||
? "Delete"
|
||||
: "You cannot delete locations"
|
||||
}
|
||||
onClick={() => setDeleting(row.original)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={!can("can:create:location")}
|
||||
title={
|
||||
can("can:create:location")
|
||||
? undefined
|
||||
: "You cannot create locations"
|
||||
}
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
New Location
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={locations?.items ?? []}
|
||||
tableName="Locations"
|
||||
isLoading={isLoadingLocations}
|
||||
itemCount={locations?.count ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPageIndex}
|
||||
nextFunction={() => setPageIndex((page) => page + 1)}
|
||||
prevFunction={() => setPageIndex((page) => Math.max(page - 1, 0))}
|
||||
refresh={refetchLocations}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={creating || !!editing}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? "Edit Location" : "New Location"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LocationForm
|
||||
key={editing?.id ?? "create"}
|
||||
mode={editing ? "edit" : "create"}
|
||||
location={editing ?? undefined}
|
||||
locationTypes={locationTypes?.items ?? []}
|
||||
allLocations={allLocations?.items ?? []}
|
||||
onSuccess={() => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(open) => !open && setDeleting(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete {deleting ? localizedName(deleting.names) : ""}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This is a permanent delete, not an archive. A location that still
|
||||
has child locations or unit clusters attached will be refused by
|
||||
the database.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isDeletingLocation}
|
||||
onClick={() => {
|
||||
if (deleting) {
|
||||
deleteLocation(deleting.id, {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* IAM organisation-structure locations. The API is `@tria-plc/iamapi-common`'s
|
||||
* generic CRUD controller: list returns `{ count, items }` flattened into the
|
||||
* response envelope (`/api/locations` is in `flatResponseModules`), and it
|
||||
* joins nothing — `locationType` and `parent` are NOT expanded, so the UI
|
||||
* resolves both from the type/location lists it already loaded.
|
||||
*/
|
||||
export interface LocaleName {
|
||||
am: string;
|
||||
en?: string;
|
||||
}
|
||||
|
||||
export interface LocationType {
|
||||
id: string;
|
||||
code: string;
|
||||
names: LocaleName;
|
||||
description?: string | null;
|
||||
level: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
parentId?: string | null;
|
||||
locationTypeId: string;
|
||||
names: LocaleName;
|
||||
code: string;
|
||||
/** Decimal strings server-side, not numbers. */
|
||||
latitude?: string | null;
|
||||
longitude?: string | null;
|
||||
area?: string | null;
|
||||
boundaryJson?: Record<string, unknown> | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
count: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface ListQuery {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
/** `field:ASC` / `field:DESC`, comma separated. No search or filter exists. */
|
||||
orderBy?: string;
|
||||
}
|
||||
|
||||
export type LocationPayload = Omit<Location, "id" | "createdAt" | "updatedAt">;
|
||||
export type LocationTypePayload = Omit<
|
||||
LocationType,
|
||||
"id" | "createdAt" | "updatedAt"
|
||||
>;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import type {
|
||||
ListQuery,
|
||||
ListResponse,
|
||||
LocationType,
|
||||
LocationTypePayload,
|
||||
} from "@/user-management/dto/locations/location.type";
|
||||
import { locationTypeService } from "../services/api/locationService";
|
||||
|
||||
export const useLocationTypes = (params: ListQuery = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["location-types"] });
|
||||
|
||||
const {
|
||||
data: locationTypes,
|
||||
isLoading: isLoadingLocationTypes,
|
||||
isError: isErrorLocationTypes,
|
||||
refetch: refetchLocationTypes,
|
||||
} = useQuery<ListResponse<LocationType>>({
|
||||
queryKey: ["location-types", params],
|
||||
queryFn: async () => {
|
||||
const { data } = await locationTypeService.list(params);
|
||||
return { count: data?.count ?? 0, items: data?.items ?? [] };
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { mutate: createLocationType, isPending: isCreatingLocationType } =
|
||||
useMutation({
|
||||
mutationFn: async (payload: LocationTypePayload) => {
|
||||
const { data } = await locationTypeService.create(payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("locationType.created", "Location type created"));
|
||||
invalidate();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const { mutate: updateLocationType, isPending: isUpdatingLocationType } =
|
||||
useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: string;
|
||||
payload: LocationTypePayload;
|
||||
}) => {
|
||||
const { data } = await locationTypeService.update(id, payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("locationType.updated", "Location type updated"));
|
||||
invalidate();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const { mutate: deleteLocationType, isPending: isDeletingLocationType } =
|
||||
useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await locationTypeService.remove(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("locationType.deleted", "Location type deleted"));
|
||||
invalidate();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
return {
|
||||
locationTypes,
|
||||
isLoadingLocationTypes,
|
||||
isErrorLocationTypes,
|
||||
refetchLocationTypes,
|
||||
createLocationType,
|
||||
isCreatingLocationType,
|
||||
updateLocationType,
|
||||
isUpdatingLocationType,
|
||||
deleteLocationType,
|
||||
isDeletingLocationType,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import type {
|
||||
ListQuery,
|
||||
ListResponse,
|
||||
Location,
|
||||
LocationPayload,
|
||||
} from "@/user-management/dto/locations/location.type";
|
||||
import { locationService } from "../services/api/locationService";
|
||||
|
||||
/**
|
||||
* `params` is the whole server-side query surface: skip/take/orderBy. There is
|
||||
* no search or filter endpoint, so a caller that needs every location (parent
|
||||
* picker, name lookups) asks for a large `take`.
|
||||
*/
|
||||
export const useLocations = (params: ListQuery = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["locations"] });
|
||||
|
||||
const {
|
||||
data: locations,
|
||||
isLoading: isLoadingLocations,
|
||||
isError: isErrorLocations,
|
||||
refetch: refetchLocations,
|
||||
} = useQuery<ListResponse<Location>>({
|
||||
queryKey: ["locations", params],
|
||||
queryFn: async () => {
|
||||
const { data } = await locationService.list(params);
|
||||
return { count: data?.count ?? 0, items: data?.items ?? [] };
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { mutate: createLocation, isPending: isCreatingLocation } = useMutation({
|
||||
mutationFn: async (payload: LocationPayload) => {
|
||||
const { data } = await locationService.create(payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("location.created", "Location created"));
|
||||
invalidate();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const { mutate: updateLocation, isPending: isUpdatingLocation } = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: string;
|
||||
payload: LocationPayload;
|
||||
}) => {
|
||||
const { data } = await locationService.update(id, payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("location.updated", "Location updated"));
|
||||
invalidate();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const { mutate: deleteLocation, isPending: isDeletingLocation } = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await locationService.remove(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("location.deleted", "Location deleted"));
|
||||
invalidate();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
return {
|
||||
locations,
|
||||
isLoadingLocations,
|
||||
isErrorLocations,
|
||||
refetchLocations,
|
||||
createLocation,
|
||||
isCreatingLocation,
|
||||
updateLocation,
|
||||
isUpdatingLocation,
|
||||
deleteLocation,
|
||||
isDeletingLocation,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/common/ui/tabs";
|
||||
import { LocationsTab } from "@/user-management/components/location-management/LocationsTab";
|
||||
import { LocationTypesTab } from "@/user-management/components/location-management/LocationTypesTab";
|
||||
|
||||
export default function LocationManagementPage() {
|
||||
return (
|
||||
<div className="w-full space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Location Management</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Locations and their hierarchy levels, shared across the IAM
|
||||
organisation structure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="locations">
|
||||
<TabsList>
|
||||
<TabsTrigger value="locations">Locations</TabsTrigger>
|
||||
<TabsTrigger value="types">Location Types</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="locations" className="pt-4">
|
||||
<LocationsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="types" className="pt-4">
|
||||
<LocationTypesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import TemplatePage from "@/super-admin/components/templates/components/template
|
||||
import CreatePositionPage from "./pages/position-management/create";
|
||||
import EditPositionPage from "./pages/position-management/edit";
|
||||
import PositionManagementPage from "./pages/position-management";
|
||||
import LocationManagementPage from "./pages/location-management";
|
||||
import MigratedDataManagementPage from "./pages/position-management/MigratedDataManagementPage";
|
||||
import UserPositionApprovalPage from "./pages/UserPositionApprovalPage";
|
||||
import ViewMigratedDataPage from "./components/MigratedRecords/ViewMigratedDataPage";
|
||||
@@ -140,6 +141,10 @@ export function UserManagementRoutes(): ReactElement {
|
||||
path="user-management/position-management"
|
||||
element={<PositionManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="user-management/locations"
|
||||
element={<LocationManagementPage />}
|
||||
/>
|
||||
{/*
|
||||
The per-officer teeter (ማህተም) + signature upload. This
|
||||
is NOT the company stamp: it is the individual approval
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
import type {
|
||||
ListQuery,
|
||||
ListResponse,
|
||||
Location,
|
||||
LocationPayload,
|
||||
LocationType,
|
||||
LocationTypePayload,
|
||||
} from "@/user-management/dto/locations/location.type";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export const locationService = {
|
||||
list: (
|
||||
params: ListQuery = {},
|
||||
): Promise<AxiosResponse<ListResponse<Location>>> =>
|
||||
axiosInstance.get(`/locations`, { params, headers: withHeaders() }),
|
||||
|
||||
create: (payload: LocationPayload): Promise<AxiosResponse<Location>> =>
|
||||
axiosInstance.post(`/locations`, payload, { headers: withHeaders() }),
|
||||
|
||||
update: (
|
||||
id: string,
|
||||
payload: LocationPayload,
|
||||
): Promise<AxiosResponse<Location>> =>
|
||||
axiosInstance.put(`/locations/${id}`, payload, { headers: withHeaders() }),
|
||||
|
||||
// Hard delete server-side — a location with children or unit clusters fails
|
||||
// on the foreign key rather than returning a tidy 409.
|
||||
remove: (id: string): Promise<AxiosResponse<void>> =>
|
||||
axiosInstance.delete(`/locations/${id}`, { headers: withHeaders() }),
|
||||
};
|
||||
|
||||
export const locationTypeService = {
|
||||
list: (
|
||||
params: ListQuery = {},
|
||||
): Promise<AxiosResponse<ListResponse<LocationType>>> =>
|
||||
axiosInstance.get(`/location-types`, { params, headers: withHeaders() }),
|
||||
|
||||
create: (
|
||||
payload: LocationTypePayload,
|
||||
): Promise<AxiosResponse<LocationType>> =>
|
||||
axiosInstance.post(`/location-types`, payload, { headers: withHeaders() }),
|
||||
|
||||
update: (
|
||||
id: string,
|
||||
payload: LocationTypePayload,
|
||||
): Promise<AxiosResponse<LocationType>> =>
|
||||
axiosInstance.put(`/location-types/${id}`, payload, {
|
||||
headers: withHeaders(),
|
||||
}),
|
||||
|
||||
remove: (id: string): Promise<AxiosResponse<void>> =>
|
||||
axiosInstance.delete(`/location-types/${id}`, { headers: withHeaders() }),
|
||||
};
|
||||
Reference in New Issue
Block a user