mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
changes
This commit is contained in:
@@ -632,7 +632,9 @@ const filterSidebarByPermission = (
|
||||
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
||||
items
|
||||
.map((item) =>
|
||||
item.children ? { ...item, children: filterItems(item.children) } : item,
|
||||
item.children
|
||||
? { ...item, children: filterItems(item.children) }
|
||||
: item,
|
||||
)
|
||||
.filter((item) => {
|
||||
if (etGl || djGl) {
|
||||
@@ -800,8 +802,22 @@ const App = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="support" element={<SupportInboxPage />} />
|
||||
<Route path="customers" element={<CustomersPage />} />
|
||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||
<Route
|
||||
path="customers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||
<CustomersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="customers/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||
<CustomerDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
|
||||
/**
|
||||
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
||||
@@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (isBackdated(pickupDate)) {
|
||||
toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliver.mutateAsync({
|
||||
id: cargoId,
|
||||
@@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
<Label>Pickup date</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
min={nowLocalDateTimeInput()}
|
||||
value={pickupDate}
|
||||
onChange={(e) => setPickupDate(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Button, Group, TextInput } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ListControlsProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
/** `YYYY-MM-DD`, matching Mantine 9's date inputs. */
|
||||
dateFrom: string | null;
|
||||
onDateFromChange: (value: string | null) => void;
|
||||
dateTo: string | null;
|
||||
onDateToChange: (value: string | null) => void;
|
||||
/** Label above the range, naming the date being filtered (e.g. "Arrival date"). */
|
||||
dateLabel?: string;
|
||||
hasFilters?: boolean;
|
||||
onReset?: () => void;
|
||||
/** Page-specific selects (status, warehouse…) rendered after the date range. */
|
||||
children?: ReactNode;
|
||||
showSearch?: boolean;
|
||||
showDateRange?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search box + inclusive date range + clear, shared by every freight list so the
|
||||
* controls sit in the same place and behave the same way on all of them.
|
||||
* Pair with `useListControls`, which owns the state and does the filtering.
|
||||
*/
|
||||
const ListControls = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search…",
|
||||
dateFrom,
|
||||
onDateFromChange,
|
||||
dateTo,
|
||||
onDateToChange,
|
||||
dateLabel,
|
||||
hasFilters,
|
||||
onReset,
|
||||
children,
|
||||
showSearch = true,
|
||||
showDateRange = true,
|
||||
}: ListControlsProps) => (
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={16} />}
|
||||
style={{ flex: "1 1 240px", minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDateRange && (
|
||||
<>
|
||||
<DatePickerInput
|
||||
label={dateLabel ? `${dateLabel} from` : "From"}
|
||||
placeholder="Any"
|
||||
value={dateFrom}
|
||||
onChange={onDateFromChange}
|
||||
// Cannot start after it ends — the picker refuses the invalid range
|
||||
// instead of silently returning nothing.
|
||||
maxDate={dateTo ?? undefined}
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
<DatePickerInput
|
||||
label={dateLabel ? `${dateLabel} to` : "To"}
|
||||
placeholder="Any"
|
||||
value={dateTo}
|
||||
onChange={onDateToChange}
|
||||
minDate={dateFrom ?? undefined}
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{hasFilters && onReset && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={onReset}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
export default ListControls;
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { useState } from "react";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
@@ -128,6 +130,8 @@ function DiffRow({
|
||||
* (with note) actions, plus a short history of past decisions.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||
const query = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
@@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: pending.id })}
|
||||
>
|
||||
Approve changes
|
||||
</Button>
|
||||
</Group>
|
||||
{/* Reviewing the diff is `customers:view`; deciding on it is
|
||||
`customers:verify`. Without it the request stays readable but
|
||||
un-actionable. */}
|
||||
{canReview && (
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: pending.id })}
|
||||
>
|
||||
Approve changes
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import type {
|
||||
@@ -286,6 +288,19 @@ export function InvoiceStatusBadge({
|
||||
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
||||
* an already-active profile is still managable.
|
||||
*/
|
||||
/**
|
||||
* Which permission each status write needs. Mirrors `STATUS_PERM` in the API's
|
||||
* `companies.controller.ts` — approving is a different authority from
|
||||
* suspending, and both go through the same endpoint. Keep the two in step.
|
||||
*/
|
||||
const STATUS_PERM: Record<ProfileStatus, string> = {
|
||||
active: FREIGHT_PERMS.customers.verify,
|
||||
pending: FREIGHT_PERMS.customers.verify,
|
||||
rejected: FREIGHT_PERMS.customers.verify,
|
||||
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||
};
|
||||
|
||||
export function ProfileApprovalActions({
|
||||
profileId,
|
||||
status,
|
||||
@@ -295,6 +310,10 @@ export function ProfileApprovalActions({
|
||||
status: ProfileStatus;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
/** The API rejects these anyway — hide rather than offer a button that 403s. */
|
||||
const canSet = (next: ProfileStatus) =>
|
||||
hasPermission(user, STATUS_PERM[next]);
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
);
|
||||
@@ -414,35 +433,41 @@ export function ProfileApprovalActions({
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
if (!canSet("active") && !canSet("rejected")) return null;
|
||||
return (
|
||||
<>
|
||||
{decisionModal}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => openDecision("reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
{canSet("active") && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{canSet("rejected") && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => openDecision("reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "rejected") {
|
||||
if (!canSet("active")) return null;
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -458,6 +483,7 @@ export function ProfileApprovalActions({
|
||||
}
|
||||
|
||||
if (status === "active") {
|
||||
if (!canSet("suspended")) return null;
|
||||
return (
|
||||
<>
|
||||
{decisionModal}
|
||||
@@ -476,34 +502,40 @@ export function ProfileApprovalActions({
|
||||
}
|
||||
|
||||
if (status === "suspended") {
|
||||
if (!canSet("active") && !canSet("blacklisted")) return null;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{decisionModal}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => openDecision("reactivate")}
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("blacklisted")}
|
||||
>
|
||||
Blacklist
|
||||
</Button>
|
||||
{canSet("active") && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => openDecision("reactivate")}
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
)}
|
||||
{canSet("blacklisted") && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("blacklisted")}
|
||||
>
|
||||
Blacklist
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "blacklisted") {
|
||||
if (!canSet("pending")) return null;
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
|
||||
@@ -126,6 +126,28 @@ const FleetFormDialog = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, recordId]);
|
||||
|
||||
// Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g.
|
||||
// _hasTrailer) for the value already on the record. Without this, editing a
|
||||
// rigid truck would show a Trailer Plate field until the type is re-picked.
|
||||
// Only scratch keys are written, so a stored one-off capacity is never
|
||||
// clobbered by the type's default; re-deriving from the live value is
|
||||
// idempotent, so this is safe to run again when the options finally load.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValues((current) => {
|
||||
const scratch: Record<string, unknown> = {};
|
||||
fields.forEach((field) => {
|
||||
if (!field.onOptionSelected) return;
|
||||
const selected = field.options?.find((o) => o.value === current[field.name]);
|
||||
if (!selected) return;
|
||||
Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => {
|
||||
if (key.startsWith("_")) scratch[key] = value;
|
||||
});
|
||||
});
|
||||
return Object.keys(scratch).length ? { ...current, ...scratch } : current;
|
||||
});
|
||||
}, [open, fields]);
|
||||
|
||||
// Receive the ?code&state relayed by the /callback popup, exchange it for
|
||||
// the verified identity, and prefill the matching form fields.
|
||||
useEffect(() => {
|
||||
@@ -202,18 +224,52 @@ const FleetFormDialog = ({
|
||||
|
||||
const faydaVerified = values.faydaVerified === true;
|
||||
|
||||
/**
|
||||
* Fields the current answers actually apply to — a rigid truck type (Casoni)
|
||||
* has no trailer, so its plate field disappears. Honoured in three places, not
|
||||
* just here: a hidden field must also skip validation (an invisible "required"
|
||||
* error blocks submit with nothing to fix) and must submit an explicit null
|
||||
* (so switching to a rigid type CLEARS the stored trailer plate rather than
|
||||
* stranding it on the row).
|
||||
*/
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
fields.filter((field) => {
|
||||
if (
|
||||
field.hideWhen &&
|
||||
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.showWhen &&
|
||||
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (field.showIf && !field.showIf(values)) return false;
|
||||
return true;
|
||||
}),
|
||||
[fields, values],
|
||||
);
|
||||
|
||||
const hiddenFieldNames = useMemo(() => {
|
||||
const visible = new Set(visibleFields.map((f) => f.name));
|
||||
return fields.filter((f) => !visible.has(f.name)).map((f) => f.name);
|
||||
}, [fields, visibleFields]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
[fields],
|
||||
() => visibleFields.filter((f) => f.type !== "textarea"),
|
||||
[visibleFields],
|
||||
);
|
||||
const longFields = useMemo(
|
||||
() => fields.filter((f) => f.type === "textarea"),
|
||||
[fields],
|
||||
() => visibleFields.filter((f) => f.type === "textarea"),
|
||||
[visibleFields],
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
const next: Record<string, string> = {};
|
||||
fields.forEach((field) => {
|
||||
visibleFields.forEach((field) => {
|
||||
const value = values[field.name];
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
@@ -295,9 +351,19 @@ const FleetFormDialog = ({
|
||||
fields.forEach((field) => {
|
||||
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
|
||||
});
|
||||
// A field the answers hid no longer applies to this record — send an explicit
|
||||
// null so the column is unset, instead of leaving a stale value behind.
|
||||
hiddenFieldNames.forEach((name) => {
|
||||
submitted[name] = null;
|
||||
});
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(submitted)
|
||||
// `_`-prefixed keys are form-local scratch written by `onOptionSelected`
|
||||
// (e.g. _hasTrailer, which drives visibility). The API validates with
|
||||
// forbidNonWhitelisted, so an undeclared key would 400 the whole save.
|
||||
.filter(([key]) => !key.startsWith("_"))
|
||||
.map(([key, value]) => {
|
||||
if (hiddenFieldNames.includes(key)) return [key, null];
|
||||
if (value === FLEET_SELECT_NONE || value === "" || value == null)
|
||||
return [key, clearableByName[key] ? null : undefined];
|
||||
if (fieldTypeByName[key] === "number") {
|
||||
@@ -371,7 +437,15 @@ const FleetFormDialog = ({
|
||||
: String(value)
|
||||
}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
setValues((current) => {
|
||||
const patch = field.onOptionSelected
|
||||
? field.onOptionSelected(
|
||||
field.options?.find((o) => o.value === next),
|
||||
current,
|
||||
)
|
||||
: {};
|
||||
return { ...current, [field.name]: next ?? "", ...patch };
|
||||
})
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated } from '@/lib/no-backdate';
|
||||
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
||||
|
||||
interface TruckDetentionModalProps {
|
||||
@@ -111,6 +112,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
@@ -118,11 +120,26 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" loading={saveTimes.isPending} onClick={() => saveTimes.mutate()}>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={saveTimes.isPending}
|
||||
onClick={() => {
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (isBackdated(arrived) || isBackdated(delivered)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Detention times cannot be in the past',
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
}}
|
||||
>
|
||||
Save times
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -18,6 +18,11 @@ import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path; reused here rather than adding a second one.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob, saveBlob } from './pdf';
|
||||
|
||||
@@ -56,8 +61,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
|
||||
const controls = useListControls(items, {
|
||||
searchKeys: ['grnNumber', 'bookingReference', 'customerName', 'status', 'releaseOrderReference', 'notes'],
|
||||
dateKey: 'arrivedAt',
|
||||
});
|
||||
const visible = controls.filteredRows;
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const allSelected = items.length > 0 && selected.size === items.length;
|
||||
// Select-all spans everything matching the current filters, not just the rows
|
||||
// on screen — bulk "mark inspected" over one page of a filtered set would be a
|
||||
// surprise. Counts compare against the filtered set for the same reason.
|
||||
const allSelected = visible.length > 0 && selected.size === visible.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleSelect = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
@@ -66,7 +80,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
return next;
|
||||
});
|
||||
const toggleSelectAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
|
||||
setSelected(allSelected ? new Set() : new Set(visible.map((i) => i.id)));
|
||||
|
||||
const markInspected = async () => {
|
||||
if (selected.size === 0) {
|
||||
@@ -268,8 +282,21 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="GRN, container, booking, customer…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<WarehouseInventoryTable
|
||||
items={items}
|
||||
items={controls.pagedRows}
|
||||
busyId={busyId}
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
@@ -287,6 +314,14 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
/>
|
||||
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="items"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
@@ -204,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
||||
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||||
driverName: truckPrefill.driverName ?? '',
|
||||
driverLicense: truckPrefill.driverLicense ?? '',
|
||||
driverPhone: truckPrefill.driverPhone ?? '',
|
||||
truckType: truckPrefill.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||
@@ -217,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||||
trailerPlate: '',
|
||||
driverName: t.driverName,
|
||||
driverLicense: '',
|
||||
driverPhone: '',
|
||||
truckType: t.truckType,
|
||||
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||
@@ -230,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
||||
trailerPlate: t.trailerPlateNumber ?? '',
|
||||
driverName: t.driverName ?? '',
|
||||
driverLicense: t.driverLicense ?? '',
|
||||
driverPhone: t.driverPhone ?? '',
|
||||
truckType: t.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(t.containerNumber),
|
||||
@@ -280,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
||||
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
||||
// The freight order's truck details are the customer's / fleet's record — the
|
||||
// gate may FILL blanks (walk-in license, phone) but never edit shown values.
|
||||
const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate);
|
||||
const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense);
|
||||
const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone);
|
||||
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
||||
|
||||
/** Load a truck into the form: its saved block if any, else its assignment. */
|
||||
@@ -292,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
setTruckPlateNumber(plate);
|
||||
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||
setDriverName(block?.driverName || option?.driverName || '');
|
||||
setDriverLicense(block?.driverLicense || '');
|
||||
setDriverLicense(block?.driverLicense || option?.driverLicense || '');
|
||||
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||
setTruckType(block?.truckType || option?.truckType || '');
|
||||
const loaded = block
|
||||
@@ -440,6 +449,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
});
|
||||
return;
|
||||
}
|
||||
// No backdating: gate times are recorded as they happen. The locked
|
||||
// entrance (exit step) keeps its original past gate-in untouched.
|
||||
if (!isEntranceLocked && isBackdated(gateInTime)) {
|
||||
toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@@ -447,6 +462,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isExitStep && isBackdated(gateOutTime)) {
|
||||
toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
@@ -600,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label="Trailer plate number"
|
||||
value={trailerPlateNumber}
|
||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={isTrailerLocked}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
@@ -646,7 +665,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Gate in time" type="datetime-local" min={isEntranceLocked ? undefined : nowLocalDateTimeInput()} value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
{hasContainerWeights && (
|
||||
<Group gap="md" align="center">
|
||||
@@ -679,7 +698,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
<TextInput label="Gate out time" type="datetime-local" min={nowLocalDateTimeInput()} value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
|
||||
@@ -199,6 +199,7 @@ export const QUERY_KEYS = {
|
||||
|
||||
MAINTENANCE: {
|
||||
ROOT: ["maintenance"] as const,
|
||||
dueBoard: () => ["maintenance", "due-board"] as const,
|
||||
schedules: (vehicleId?: string) =>
|
||||
["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||
upcoming: (vehicleId?: string) =>
|
||||
@@ -207,6 +208,8 @@ export const QUERY_KEYS = {
|
||||
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||
stats: (vehicleId?: string) =>
|
||||
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||
intervals: (vehicleId?: string) =>
|
||||
["maintenance", "intervals", vehicleId ?? "all"] as const,
|
||||
},
|
||||
|
||||
FINANCIAL_REPORTS: {
|
||||
|
||||
@@ -417,6 +417,9 @@ export const URL_CONSTANTS = {
|
||||
WAGON_TYPES: "/wagon-types",
|
||||
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
|
||||
|
||||
TRUCK_TYPES: "/truck-types",
|
||||
TRUCK_TYPE_BY_ID: (id: string) => `/truck-types/${id}`,
|
||||
|
||||
PRIORITY_CONFIGS: "/priority-configs",
|
||||
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,
|
||||
|
||||
|
||||
@@ -67,10 +67,3 @@ export function useDisputeInterchangeDocument() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelInterchangeDocument() {
|
||||
const onSuccess = useInterchangeInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => interchangeDocumentsService.cancel(id),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
164
apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
Normal file
164
apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePagination } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Search + date-range + pagination over an already-fetched array.
|
||||
*
|
||||
* Client-side on purpose: the freight lists are hundreds of rows (largest table
|
||||
* is ~1.1k), so filtering in the browser avoids paginating ~20 API endpoints —
|
||||
* several of which sit on billing paths. If a list ever outgrows this (roughly
|
||||
* 5k rows, where the per-keystroke filter starts to feel slow), move that ONE
|
||||
* page to a server-side query; the component API here stays the same.
|
||||
*
|
||||
* Dates are `YYYY-MM-DD` strings, matching Mantine 9's date inputs. Comparing
|
||||
* them lexically keeps the range on calendar days and sidesteps timezone drift
|
||||
* entirely — a UTC timestamp is truncated to its date before the comparison.
|
||||
*
|
||||
* ponytail: linear scan per keystroke, no debounce — fine at this size; add
|
||||
* a debounce (or server-side filtering) if a list gets big enough to stutter.
|
||||
*/
|
||||
export interface ListControlsOptions<T> {
|
||||
/**
|
||||
* Fields matched against the search box. Constrained to real keys of the row
|
||||
* so a typo is a compile error rather than a filter that silently matches
|
||||
* nothing. For nested or derived values, pass `searchValue` instead.
|
||||
*/
|
||||
searchKeys?: (keyof T)[];
|
||||
/**
|
||||
* Row's meaningful business date (arrival, invoice, dispatch…), which is what
|
||||
* staff actually filter by. Falls back to `createdAt` when the row has no
|
||||
* value for it, so a record is never silently invisible to a date range.
|
||||
*/
|
||||
dateKey?: keyof T;
|
||||
/** Rows per page. */
|
||||
pageSize?: number;
|
||||
/** Custom search extractor when the value isn't a top-level field. */
|
||||
searchValue?: (row: T) => string;
|
||||
}
|
||||
|
||||
const readField = (row: unknown, key: string): unknown =>
|
||||
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
|
||||
|
||||
/**
|
||||
* Reduce any stored date to its `YYYY-MM-DD` calendar day. ISO strings are cut
|
||||
* directly rather than parsed, so a timestamp is never shifted into the
|
||||
* previous/next day by the viewer's timezone.
|
||||
*/
|
||||
export const toDayString = (raw: unknown): string | null => {
|
||||
if (!raw) return null;
|
||||
if (raw instanceof Date) {
|
||||
return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10);
|
||||
}
|
||||
const text = String(raw);
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10);
|
||||
const parsed = new Date(text);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
/**
|
||||
* Does a stored date fall inside an inclusive `YYYY-MM-DD` range? Exported for
|
||||
* lists that already own their filtering (e.g. FleetResourcePage, which folds
|
||||
* server-side filters and search together) so the range semantics — inclusive
|
||||
* ends, undated rows excluded — stay defined in exactly one place.
|
||||
*/
|
||||
export const matchesDayRange = (
|
||||
raw: unknown,
|
||||
dateFrom: string | null,
|
||||
dateTo: string | null,
|
||||
): boolean => {
|
||||
if (!dateFrom && !dateTo) return true;
|
||||
const day = toDayString(raw);
|
||||
if (!day) return false;
|
||||
if (dateFrom && day < dateFrom) return false;
|
||||
if (dateTo && day > dateTo) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const useListControls = <T,>(rows: T[], options: ListControlsOptions<T> = {}) => {
|
||||
const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState<string | null>(null);
|
||||
const [dateTo, setDateTo] = useState<string | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize });
|
||||
|
||||
const keys = searchKeys.map(String);
|
||||
const keySignature = keys.join("|");
|
||||
const dateKeyStr = dateKey ? String(dateKey) : undefined;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term && !dateFrom && !dateTo) return rows;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (term) {
|
||||
const haystack = searchValue
|
||||
? searchValue(row)
|
||||
: keys.map((key) => String(readField(row, key) ?? "")).join(" ");
|
||||
if (!haystack.toLowerCase().includes(term)) return false;
|
||||
}
|
||||
if (dateFrom || dateTo) {
|
||||
const raw = dateKeyStr
|
||||
? (readField(row, dateKeyStr) ?? readField(row, "createdAt"))
|
||||
: null;
|
||||
if (!matchesDayRange(raw, dateFrom, dateTo)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, search, dateFrom, dateTo, keySignature, dateKeyStr, searchValue]);
|
||||
|
||||
// Narrowing the result set can strand the user on a page that no longer
|
||||
// exists (filter to 3 rows while on page 5 → empty table). Snap back to the
|
||||
// first page whenever the filters change.
|
||||
useEffect(() => {
|
||||
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
|
||||
}, [search, dateFrom, dateTo, setPagination]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filteredRows.slice(start, start + pagination.pageSize);
|
||||
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const hasFilters = Boolean(search || dateFrom || dateTo);
|
||||
|
||||
const reset = () => {
|
||||
setSearch("");
|
||||
setDateFrom(null);
|
||||
setDateTo(null);
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
dateFrom,
|
||||
setDateFrom,
|
||||
dateTo,
|
||||
setDateTo,
|
||||
hasFilters,
|
||||
reset,
|
||||
filteredRows,
|
||||
pagedRows,
|
||||
pageCount,
|
||||
pagination,
|
||||
setPagination,
|
||||
totalCount: filteredRows.length,
|
||||
/** Spread straight onto <DataTable /> so every list paginates identically. */
|
||||
tableProps: {
|
||||
pagination: {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filteredRows.length,
|
||||
},
|
||||
tableOptions: {
|
||||
manualPagination: true as const,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -88,8 +88,8 @@ export const resolveModuleConfig = (config: TenantConfig): ModuleConfig => ({
|
||||
});
|
||||
|
||||
const defaultConfig: TenantConfig = {
|
||||
appName: "Smart Office",
|
||||
organizationName: "Smart Office",
|
||||
appName: "EDR Freight",
|
||||
organizationName: "Ethio-Djibouti Railways",
|
||||
canUseAttachmentFromDMS: false,
|
||||
logo: "/assets/TriaTradinglogo.png",
|
||||
primaryColor: "#1b354d",
|
||||
@@ -116,8 +116,8 @@ const defaultConfig: TenantConfig = {
|
||||
|
||||
const tenantConfigs: Record<string, TenantConfig> = {
|
||||
localhost: {
|
||||
appName: "Smart Office",
|
||||
organizationName: "Addis Ababa City Administration",
|
||||
appName: "EDR Freight",
|
||||
organizationName: "Ethio-Djibouti Railways",
|
||||
logo: "",
|
||||
primaryColor: "#0EA371",
|
||||
moduleConfig: {
|
||||
|
||||
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Backdating guard for operational time entries (gate in/out, mile truck
|
||||
* times, delivery pickups): times must be recorded as they happen, never
|
||||
* dated back. A one-hour grace covers real-world lag (weighbridge queue,
|
||||
* operator finishing the form after the event).
|
||||
*/
|
||||
export const BACKDATE_GRACE_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */
|
||||
export const nowLocalDateTimeInput = (): string =>
|
||||
new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
|
||||
/** True when the value is more than the grace period in the past. */
|
||||
export const isBackdated = (value: string | Date | null | undefined): boolean => {
|
||||
if (!value) return false;
|
||||
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS;
|
||||
};
|
||||
@@ -2039,6 +2039,7 @@
|
||||
"setting": "ቅንብሮች",
|
||||
"loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...",
|
||||
"errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል",
|
||||
"errorLoadingUnits": "ክፍሎችን ማጫን ላይ ስህተት ተፈጥሯል",
|
||||
"retry": "ደግመው ይሞክሩ",
|
||||
"assignAdmin": "አስተዳዳሪ መመደብ",
|
||||
"addAdmin": "አስተዳዳሪ ያክሉ",
|
||||
@@ -2585,7 +2586,6 @@
|
||||
"archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ",
|
||||
"deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ",
|
||||
"deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?",
|
||||
"delete": "ሰርዝ",
|
||||
"deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ",
|
||||
"cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም",
|
||||
"reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።",
|
||||
@@ -2652,6 +2652,18 @@
|
||||
"selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ",
|
||||
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
|
||||
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
|
||||
"selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ",
|
||||
"cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።",
|
||||
"permissionsSelected": "{{count}} ተመርጠዋል",
|
||||
"positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል",
|
||||
"positionTypeUpdated": "የቦታ ዓይነት ተሻሽሏል",
|
||||
"positionTypeDeleted": "የቦታ ዓይነት ተሰርዟል",
|
||||
"positionTypeMigrated": "የቦታ ዓይነት ዝውውር ተሻሽሏል",
|
||||
"positionTypeNotFound": "የቦታ ዓይነት አልተገኘም",
|
||||
"permissionsAssignFailed": "የቦታ ዓይነቱ ተቀምጧል፣ ነገር ግን ፍቃዶቹን መመደብ አልተቻለም። እንደገና ለመሞከር ደግመው ይክፈቱት።",
|
||||
"failedToLoadPermissions": "ፍቃዶችን መጫን አልተቻለም",
|
||||
"failedToLoadPositionTypes": "የቦታ ዓይነቶችን መጫን አልተቻለም",
|
||||
"exportFailed": "የቦታ ዓይነት ቁልፎችን መላክ አልተቻለም",
|
||||
"perFailed": "ፍቃድ መፍጠር አልተቻለም",
|
||||
"perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ",
|
||||
"updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል",
|
||||
@@ -3117,7 +3129,7 @@
|
||||
"referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች",
|
||||
"other": "ሌላ",
|
||||
"styleCategories": "የስታይል ምድቦች",
|
||||
"styleEditor": "ስታይል አርታኢ",
|
||||
"styleEditor": "የስታይል አርታዒ",
|
||||
"livePreview": "የቀጥታ ቅድመ-እይታ",
|
||||
"fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።",
|
||||
"fontResource": "የቅርጸ-ቁምፊ ፋይል",
|
||||
@@ -3209,11 +3221,9 @@
|
||||
"previewLanguage": "የቅድመ-እይታ ቋንቋ",
|
||||
"amharic": "አማርኛ",
|
||||
"english": "እንግሊዝኛ",
|
||||
"styleCategories": "የስታይል ምድቦች",
|
||||
"categoryHint": "ለማርትዕ ክፍል ይምረጡ",
|
||||
"expandSidebar": "የጎን ማውጫን ዘርጋ",
|
||||
"collapseSidebar": "የጎን ማውጫን ጠቅልል",
|
||||
"styleEditor": "የስታይል አርታዒ",
|
||||
"headerFooterSelection": "ራስጌ እና ግርጌ",
|
||||
"noSettings": "ምንም የስታይል ቅንብሮች የሉም",
|
||||
"visible": "የሚታይ",
|
||||
@@ -7393,5 +7403,100 @@
|
||||
"department": "ዲፓርትመንት",
|
||||
"unit": "ክፍል",
|
||||
"notAvailable": "ያልዋቀረ"
|
||||
},
|
||||
"orgAdmins": {
|
||||
"title": "የድርጅት አስተዳዳሪዎች",
|
||||
"subtitle": "አስተዳዳሪዎቹን ለማየት እና ለማስተዳደር ድርጅት ይምረጡ።",
|
||||
"tableName": "የድርጅት አስተዳዳሪዎች",
|
||||
"selectOrg": "ድርጅት ይምረጡ",
|
||||
"searchOrgs": "ድርጅቶችን ይፈልጉ...",
|
||||
"noOrgsFound": "ምንም ድርጅት አልተገኘም።",
|
||||
"adminsCount": "{{count}} አስተዳዳሪ",
|
||||
"adminsCount_other": "{{count}} አስተዳዳሪዎች",
|
||||
"noAdmins": "አስተዳዳሪ የለም",
|
||||
"activeEmployees": "{{count}} ንቁ ሰራተኞች",
|
||||
"selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ",
|
||||
"selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።",
|
||||
"noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።",
|
||||
"assignExisting": "ነባር ሰራተኛ ይመድቡ",
|
||||
"roleOrgAdmin": "የድርጅት አስተዳዳሪ",
|
||||
"roleUnitAdmin": "የክፍል አስተዳዳሪ",
|
||||
"statusInvited": "የተጋበዘ",
|
||||
"statusActive": "ንቁ",
|
||||
"statusInactive": "ንቁ ያልሆነ",
|
||||
"columns": {
|
||||
"name": "ስም",
|
||||
"email": "ኢሜይል",
|
||||
"phone": "ስልክ",
|
||||
"role": "ሚና",
|
||||
"status": "ሁኔታ",
|
||||
"addedOn": "የተጨመረበት ቀን",
|
||||
"actions": "እርምጃዎች"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "መገለጫ ያስተካክሉ",
|
||||
"resend": "ግብዣ እንደገና ይላኩ",
|
||||
"activate": "መለያ ያንቁ",
|
||||
"deactivate": "መለያ ያቦዝኑ",
|
||||
"remove": "አስተዳዳሪ ያስወግዱ"
|
||||
},
|
||||
"form": {
|
||||
"nameEn": "ስም (እንግሊዝኛ)",
|
||||
"nameAm": "ስም (አማርኛ)",
|
||||
"username": "የተጠቃሚ ስም",
|
||||
"email": "ኢሜይል",
|
||||
"phoneNumber": "ስልክ ቁጥር",
|
||||
"unit": "ክፍል",
|
||||
"selectUnit": "ክፍል ይምረጡ",
|
||||
"loadingUnits": "ክፍሎች በመጫን ላይ...",
|
||||
"noUnit": "ምንም — የድርጅት አስተዳዳሪ",
|
||||
"unitRequired": "ክፍል ያስፈልጋል"
|
||||
},
|
||||
"edit": {
|
||||
"title": "የአስተዳዳሪ መገለጫ ያስተካክሉ",
|
||||
"description": "የዚህን አስተዳዳሪ የመገለጫ ዝርዝሮች ያዘምኑ።",
|
||||
"submit": "ለውጦችን ያስቀምጡ"
|
||||
},
|
||||
"assign": {
|
||||
"title": "ነባር ሰራተኛ ይመድቡ",
|
||||
"description": "የዚህን ድርጅት ሰራተኛ ወደ አስተዳዳሪነት ያሳድጉ።",
|
||||
"searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...",
|
||||
"noUsersFound": "ምንም ሰራተኛ አልተገኘም።",
|
||||
"alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው",
|
||||
"submit": "እንደ አስተዳዳሪ ይመድቡ",
|
||||
"loadError": "ሰራተኞችን መጫን አልተሳካም።",
|
||||
"users": "ተጠቃሚዎች"
|
||||
},
|
||||
"confirmRemove": {
|
||||
"title": "አስተዳዳሪ ይወገድ?",
|
||||
"description": "ይህ የ{{name}}ን የአስተዳዳሪነት ሚና ከ{{org}} ያስወግዳል። የተጠቃሚው መለያ ራሱ ይቀራል።",
|
||||
"removing": "በማስወገድ ላይ..."
|
||||
},
|
||||
"confirmToggle": {
|
||||
"activateTitle": "መለያ ይንቃ?",
|
||||
"deactivateTitle": "መለያ ይቦዝን?",
|
||||
"description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።"
|
||||
},
|
||||
"toasts": {
|
||||
"assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!",
|
||||
"removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!",
|
||||
"resent": "ግብዣው እንደገና ተልኳል!",
|
||||
"activated": "መለያው ነቅቷል!",
|
||||
"deactivated": "መለያው ቦዝኗል!",
|
||||
"profileUpdated": "መገለጫው ተዘምኗል!",
|
||||
"resending": "ግብዣ በመላክ ላይ...",
|
||||
"missingContact": "ይህ አስተዳዳሪ ኢሜይል ወይም ስልክ ቁጥር የለውም።",
|
||||
"added": "አስተዳዳሪ በተሳካ ሁኔታ ተጨምሯል!"
|
||||
},
|
||||
"loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።",
|
||||
"pickerError": "ድርጅቶችን መጫን አልተሳካም።",
|
||||
"addAdmin": "አስተዳዳሪ ጨምር",
|
||||
"add": {
|
||||
"title": "አስተዳዳሪ ጨምር",
|
||||
"description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።",
|
||||
"submit": "አስተዳዳሪ ጨምር",
|
||||
"inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።",
|
||||
"noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2057,6 +2057,7 @@
|
||||
"setting": "Setting",
|
||||
"loadingAdmins": "Loading admins...",
|
||||
"errorLoadingAdmins": "Error loading admin data",
|
||||
"errorLoadingUnits": "Error loading units",
|
||||
"retry": "Retry",
|
||||
"assignAdmin": "Assign Admin",
|
||||
"addAdmin": "Add Admin",
|
||||
@@ -2760,6 +2761,18 @@
|
||||
"selectApplicationToLoadPermissions": "Select an application to load its permissions",
|
||||
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
|
||||
"copyPermissionsFailed": "Failed to copy permissions",
|
||||
"selectOrganizationToCopy": "Select an organization to see the position types you can copy from",
|
||||
"cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.",
|
||||
"permissionsSelected": "{{count}} selected",
|
||||
"positionTypeCreated": "Position type created",
|
||||
"positionTypeUpdated": "Position type updated",
|
||||
"positionTypeDeleted": "Position type deleted",
|
||||
"positionTypeMigrated": "Position type migration updated",
|
||||
"positionTypeNotFound": "Position type not found",
|
||||
"permissionsAssignFailed": "Position type saved, but assigning its permissions failed. Reopen it to try again.",
|
||||
"failedToLoadPermissions": "Failed to load permissions",
|
||||
"failedToLoadPositionTypes": "Failed to load position types",
|
||||
"exportFailed": "Failed to export position type keys",
|
||||
"perFailed": "Failed To Create Permission",
|
||||
"perSuccess": "Permission type created and permissions assigned",
|
||||
"updatePerSuccess": "Permission updated successfully",
|
||||
@@ -7391,5 +7404,100 @@
|
||||
"department": "Department",
|
||||
"unit": "Unit",
|
||||
"notAvailable": "Not Available"
|
||||
},
|
||||
"orgAdmins": {
|
||||
"title": "Organization Admins",
|
||||
"subtitle": "Pick an organization to view and manage its administrators.",
|
||||
"tableName": "Organization Admins",
|
||||
"selectOrg": "Select an organization",
|
||||
"searchOrgs": "Search organizations...",
|
||||
"noOrgsFound": "No organizations found.",
|
||||
"adminsCount": "{{count}} admin",
|
||||
"adminsCount_other": "{{count}} admins",
|
||||
"noAdmins": "No admins",
|
||||
"activeEmployees": "{{count}} active employees",
|
||||
"selectOrgPrompt": "Select an organization to manage its admins",
|
||||
"selectOrgPromptHint": "Use the selector above to search and pick an organization.",
|
||||
"noAdminsHint": "{{name}} has no administrators yet. Invite a new admin or assign an existing employee.",
|
||||
"assignExisting": "Assign Existing",
|
||||
"roleOrgAdmin": "Org Admin",
|
||||
"roleUnitAdmin": "Unit Admin",
|
||||
"statusInvited": "Invited",
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Inactive",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"addedOn": "Added On",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit Profile",
|
||||
"resend": "Resend Invite",
|
||||
"activate": "Activate Account",
|
||||
"deactivate": "Deactivate Account",
|
||||
"remove": "Remove Admin"
|
||||
},
|
||||
"form": {
|
||||
"nameEn": "Name (English)",
|
||||
"nameAm": "Name (Amharic)",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"phoneNumber": "Phone Number",
|
||||
"unit": "Unit",
|
||||
"selectUnit": "Select a unit",
|
||||
"loadingUnits": "Loading units...",
|
||||
"noUnit": "None — organization admin",
|
||||
"unitRequired": "Unit is required"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Admin Profile",
|
||||
"description": "Update this administrator's profile details.",
|
||||
"submit": "Save Changes"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Assign Existing Employee",
|
||||
"description": "Promote an employee of this organization to administrator.",
|
||||
"searchUsers": "Search employees by name or email...",
|
||||
"noUsersFound": "No employees found.",
|
||||
"alreadyAdmin": "Already admin",
|
||||
"submit": "Assign as Admin",
|
||||
"loadError": "Failed to load employees.",
|
||||
"users": "Users"
|
||||
},
|
||||
"confirmRemove": {
|
||||
"title": "Remove Admin?",
|
||||
"description": "This removes the admin role of {{name}} for {{org}}. The user account itself is kept.",
|
||||
"removing": "Removing..."
|
||||
},
|
||||
"confirmToggle": {
|
||||
"activateTitle": "Activate Account?",
|
||||
"deactivateTitle": "Deactivate Account?",
|
||||
"description": "This changes the account status of {{name}} across the whole platform, not just for this organization."
|
||||
},
|
||||
"toasts": {
|
||||
"assigned": "Admin assigned successfully!",
|
||||
"removed": "Admin removed successfully!",
|
||||
"resent": "Invitation re-sent successfully!",
|
||||
"activated": "Account activated successfully!",
|
||||
"deactivated": "Account deactivated successfully!",
|
||||
"profileUpdated": "Profile updated successfully!",
|
||||
"resending": "Sending invite...",
|
||||
"missingContact": "This admin has no email or phone number on file.",
|
||||
"added": "Admin added successfully!"
|
||||
},
|
||||
"loadError": "Failed to load admins.",
|
||||
"pickerError": "Failed to load organizations.",
|
||||
"addAdmin": "Add Admin",
|
||||
"add": {
|
||||
"title": "Add Admin",
|
||||
"description": "Create a user account and grant admin access in this organization.",
|
||||
"submit": "Add Admin",
|
||||
"inviteNote": "The user is created and receives an SMS invitation to set their password.",
|
||||
"noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1525,6 +1525,7 @@
|
||||
"setting": "Paramètre",
|
||||
"loadingAdmins": "Chargement des administrateurs...",
|
||||
"errorLoadingAdmins": "Erreur lors du chargement des données administrateur",
|
||||
"errorLoadingUnits": "Erreur lors du chargement des unités",
|
||||
"retry": "Réessayer",
|
||||
"assignAdmin": "Assigner un administrateur",
|
||||
"addAdmin": "Ajouter un administrateur",
|
||||
@@ -1886,6 +1887,18 @@
|
||||
"selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations",
|
||||
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
|
||||
"copyPermissionsFailed": "Échec de la copie des autorisations",
|
||||
"selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier",
|
||||
"cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.",
|
||||
"permissionsSelected": "{{count}} sélectionné(s)",
|
||||
"positionTypeCreated": "Type de poste créé",
|
||||
"positionTypeUpdated": "Type de poste mis à jour",
|
||||
"positionTypeDeleted": "Type de poste supprimé",
|
||||
"positionTypeMigrated": "Migration du type de poste mise à jour",
|
||||
"positionTypeNotFound": "Type de poste introuvable",
|
||||
"permissionsAssignFailed": "Type de poste enregistré, mais l'attribution de ses autorisations a échoué. Rouvrez-le pour réessayer.",
|
||||
"failedToLoadPermissions": "Échec du chargement des autorisations",
|
||||
"failedToLoadPositionTypes": "Échec du chargement des types de poste",
|
||||
"exportFailed": "Échec de l'exportation des clés de type de poste",
|
||||
"perFailed": "Échec de la création de l’autorisation",
|
||||
"perSuccess": "Type d’autorisation créé et autorisations assignées",
|
||||
"updatePerSuccess": "Autorisation mise à jour avec succès",
|
||||
|
||||
@@ -11,6 +11,7 @@ import "../index.css";
|
||||
import "@edr/ui-common/theme.css";
|
||||
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Toaster as SonnerToaster } from "./shared/common/ui/sonner";
|
||||
|
||||
// Initialize i18next before first paint so the detected/persisted language
|
||||
// applies immediately (the vendored IAM UI also imports this via @/i18n).
|
||||
@@ -70,6 +71,9 @@ createRoot(rootElement).render(
|
||||
message (suppressed on warehouse / mile / onboarding pages). */}
|
||||
<ApiErrorModal />
|
||||
<Toaster position="top-right" />
|
||||
{/* sonner toasts (used across super-admin & user-management)
|
||||
rendered nowhere without this mount */}
|
||||
<SonnerToaster position="top-right" richColors />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
|
||||
@@ -67,7 +67,8 @@ const DashboardPage = () => {
|
||||
refetch();
|
||||
refetchAdmins();
|
||||
}}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("organization.retry")}
|
||||
</Button>
|
||||
@@ -140,7 +141,8 @@ const DashboardPage = () => {
|
||||
<Link to="/organizations">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-primary dark:text-primary-400 text-sm px-0">
|
||||
className="text-primary dark:text-primary-400 text-sm px-0"
|
||||
>
|
||||
{t("organization.viewMore")}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins";
|
||||
import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage";
|
||||
|
||||
const OrganizationAdminsPage = () => {
|
||||
return <OrganizationsAdmins />;
|
||||
return <OrgAdminsPage />;
|
||||
};
|
||||
|
||||
export default OrganizationAdminsPage;
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
downloadBookingFile,
|
||||
fetchViewableFile,
|
||||
@@ -108,6 +110,7 @@ export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: company, isLoading } = useQuery(
|
||||
api.customers.getById.queryOptions({
|
||||
@@ -180,6 +183,11 @@ export default function CustomerDetailPage() {
|
||||
// API's rule exactly, so no button is offered that the server would reject.
|
||||
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
||||
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
||||
// Workflow gate (above) AND authority: asking the customer to correct a
|
||||
// document is a `customers:verify` action, so a view-only reviewer reads the
|
||||
// documents but is not offered the request-change control.
|
||||
const canRequestDocChange =
|
||||
canReview && hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||
|
||||
/** Document the reviewer is asking the customer to correct; null = closed. */
|
||||
const [changeRequestDoc, setChangeRequestDoc] =
|
||||
@@ -446,7 +454,7 @@ export default function CustomerDetailPage() {
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{canReview && (
|
||||
{canRequestDocChange && (
|
||||
<ActionIcon
|
||||
component="button"
|
||||
type="button"
|
||||
@@ -468,7 +476,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[view, canReview],
|
||||
[view, canRequestDocChange],
|
||||
);
|
||||
|
||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus, AlertTriangle } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
complianceService,
|
||||
@@ -86,6 +91,11 @@ export default function CompliancePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const controls = useListControls(records as ComplianceRecord[], {
|
||||
searchKeys: ["type", "status", "documentNumber"],
|
||||
dateKey: "expiryDate",
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const res = await complianceService.create({
|
||||
@@ -210,6 +220,18 @@ export default function CompliancePage() {
|
||||
Compliance Records
|
||||
</Title>
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search type, status, document no…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Expiry"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -239,7 +261,7 @@ export default function CompliancePage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{(records as ComplianceRecord[]).map((record) => (
|
||||
{controls.pagedRows.map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>{vehicleLabel(record)}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -259,6 +281,13 @@ export default function CompliancePage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="records"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -15,6 +16,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { matchesDayRange } from "@/hooks/useListControls";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
||||
@@ -53,6 +55,10 @@ const FleetResourcePage = () => {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
// Registration date range. Server-side list filters (status/yard/train) are
|
||||
// applied by the API; this narrows what comes back, alongside search.
|
||||
const [dateFrom, setDateFrom] = useState<string | null>(null);
|
||||
const [dateTo, setDateTo] = useState<string | null>(null);
|
||||
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
@@ -100,6 +106,9 @@ const FleetResourcePage = () => {
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
||||
api.wagonTypes.list.queryOptions(),
|
||||
);
|
||||
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
|
||||
api.truckTypes.list.queryOptions(),
|
||||
);
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
@@ -128,7 +137,7 @@ const FleetResourcePage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
}, [search, listFilterValues, setPagination]);
|
||||
}, [search, listFilterValues, dateFrom, dateTo, setPagination]);
|
||||
|
||||
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
|
||||
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
||||
@@ -175,17 +184,34 @@ const FleetResourcePage = () => {
|
||||
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
|
||||
);
|
||||
|
||||
// Carries capacity + trailer configuration so picking a truck type can
|
||||
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
|
||||
const truckTypeOpts = (
|
||||
truckTypes as Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string;
|
||||
capacityTons?: number | null;
|
||||
hasTrailer?: boolean;
|
||||
}>
|
||||
).map((t) => ({
|
||||
value: t.id,
|
||||
label: t.name ? `${t.name} (${t.code})` : t.code,
|
||||
meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer },
|
||||
}));
|
||||
|
||||
registerFleetOptionLabels("currentYardId", yardOpts);
|
||||
|
||||
return {
|
||||
wagonTypes: wagonTypeOpts,
|
||||
containerTypes: containerTypeOpts,
|
||||
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
|
||||
truckTypes: truckTypeOpts,
|
||||
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
|
||||
containers: containerOpts,
|
||||
yards: yardOpts,
|
||||
};
|
||||
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
|
||||
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
@@ -218,6 +244,7 @@ const FleetResourcePage = () => {
|
||||
registerFleetOptionLabels("containerId", dynamicOptions.containers);
|
||||
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
|
||||
registerFleetOptionLabels("locationId", dynamicOptions.yards);
|
||||
registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes);
|
||||
}, [dynamicOptions]);
|
||||
|
||||
const formFields = useMemo((): FleetFormFieldDef[] => {
|
||||
@@ -233,16 +260,20 @@ const FleetResourcePage = () => {
|
||||
wagonTypesLoading ||
|
||||
containerTypesLoading ||
|
||||
cargoTypesLoading ||
|
||||
truckTypesLoading ||
|
||||
wagonsLoading ||
|
||||
containersLoading ||
|
||||
yardsLoading;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
if (usesServerListFilters) return allRows;
|
||||
const term = search.trim().toLowerCase();
|
||||
return allRows.filter((row) => {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
// The date range applies even when the API already filtered the list —
|
||||
// it is not one of the server-side filters.
|
||||
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
|
||||
if (usesServerListFilters) return true;
|
||||
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
@@ -253,7 +284,7 @@ const FleetResourcePage = () => {
|
||||
.includes(term),
|
||||
);
|
||||
});
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters]);
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
@@ -452,7 +483,30 @@ const FleetResourcePage = () => {
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
listFilterSelects ? (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<DatePickerInput
|
||||
aria-label="Created from"
|
||||
placeholder="Created from"
|
||||
value={dateFrom}
|
||||
onChange={setDateFrom}
|
||||
maxDate={dateTo ?? undefined}
|
||||
clearable
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={160}
|
||||
/>
|
||||
<DatePickerInput
|
||||
aria-label="Created to"
|
||||
placeholder="Created to"
|
||||
value={dateTo}
|
||||
onChange={setDateTo}
|
||||
minDate={dateFrom ?? undefined}
|
||||
clearable
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={160}
|
||||
/>
|
||||
{listFilterSelects ? (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Select
|
||||
@@ -495,7 +549,8 @@ const FleetResourcePage = () => {
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
) : undefined
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/auth/http";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
@@ -118,6 +123,11 @@ export default function FuelPurchasePage() {
|
||||
const totalCost = formData.liters * formData.costPerLiter;
|
||||
|
||||
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
|
||||
const controls = useListControls(purchasesData as FuelPurchase[], {
|
||||
searchKeys: ["fuelStation", "paymentMethod"],
|
||||
dateKey: "purchaseDate",
|
||||
});
|
||||
|
||||
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
|
||||
(sum, p) => sum + Number(p.liters),
|
||||
0
|
||||
@@ -185,6 +195,18 @@ export default function FuelPurchasePage() {
|
||||
|
||||
{/* Purchases Table */}
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search station or payment method…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Purchased"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -215,7 +237,7 @@ export default function FuelPurchasePage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{(purchasesData as FuelPurchase[])?.map((purchase) => (
|
||||
{controls.pagedRows.map((purchase) => (
|
||||
<Table.Tr key={purchase.id}>
|
||||
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
|
||||
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
|
||||
@@ -230,6 +252,13 @@ export default function FuelPurchasePage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="purchases"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
incidentsService,
|
||||
@@ -161,6 +166,10 @@ export default function IncidentsPage() {
|
||||
})) || [];
|
||||
|
||||
const incidents = incidentsData as Incident[];
|
||||
const controls = useListControls(incidents, {
|
||||
searchKeys: ["type", "severity", "status"],
|
||||
dateKey: "occurredAt",
|
||||
});
|
||||
const totalCount = incidents.length;
|
||||
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
|
||||
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
|
||||
@@ -237,6 +246,18 @@ export default function IncidentsPage() {
|
||||
|
||||
{/* Incidents Table */}
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search type, severity, status…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Occurred"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -267,7 +288,7 @@ export default function IncidentsPage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{incidents.map((incident) => (
|
||||
{controls.pagedRows.map((incident) => (
|
||||
<Table.Tr key={incident.id}>
|
||||
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -294,6 +315,13 @@ export default function IncidentsPage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="incidents"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
Container,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { CheckCircle2, Plus, Trash2 } from 'lucide-react';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
@@ -26,6 +28,7 @@ interface MaintenanceSchedule {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
maintenanceType: string;
|
||||
serviceItem?: string | null;
|
||||
description: string;
|
||||
scheduledDate: string;
|
||||
completedDate?: string;
|
||||
@@ -35,8 +38,36 @@ interface MaintenanceSchedule {
|
||||
serviceProvider?: string;
|
||||
}
|
||||
|
||||
interface DueBoardRow {
|
||||
scheduleId: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
description: string;
|
||||
scheduledDate: string;
|
||||
nextDueDate: string | null;
|
||||
nextDueKm: number | null;
|
||||
currentKm: number | null;
|
||||
kmRemaining: number | null;
|
||||
daysRemaining: number | null;
|
||||
overdue: boolean;
|
||||
}
|
||||
|
||||
interface MaintenanceInterval {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
intervalKm: number | null;
|
||||
intervalDays: number | null;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
serviceItem: '',
|
||||
description: '',
|
||||
scheduledDate: new Date().toISOString().split('T')[0],
|
||||
estimatedCost: 0,
|
||||
@@ -44,12 +75,34 @@ const emptyForm = {
|
||||
notes: '',
|
||||
};
|
||||
|
||||
const emptyIntervalForm = {
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
serviceItem: '',
|
||||
intervalKm: '' as number | '',
|
||||
intervalDays: '' as number | '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
export function MaintenancePage() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
||||
const [formData, setFormData] = useState(emptyForm);
|
||||
const [intervalForm, setIntervalForm] = useState(emptyIntervalForm);
|
||||
const [completeTarget, setCompleteTarget] = useState<MaintenanceSchedule | null>(null);
|
||||
const [completeOdometer, setCompleteOdometer] = useState<number | ''>('');
|
||||
const [completeCost, setCompleteCost] = useState<number | ''>('');
|
||||
|
||||
// Maintenance is driven by time AND km, not a picked-then-scheduled action —
|
||||
// this is the fleet-wide board of what's actually due, by date or mileage.
|
||||
const { data: dueBoard, isLoading: dueLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.dueBoard(),
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/maintenance/due-board');
|
||||
return (res.data || []) as DueBoardRow[];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: QUERY_KEYS.VEHICLES.list(),
|
||||
@@ -69,7 +122,32 @@ export function MaintenancePage() {
|
||||
enabled: !!selectedVehicle,
|
||||
});
|
||||
|
||||
const { data: intervals } = useQuery({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''),
|
||||
queryFn: async () => {
|
||||
if (!selectedVehicle) return [];
|
||||
const res = await api.get(`/maintenance/intervals/${selectedVehicle}`);
|
||||
return (res.data || []) as MaintenanceInterval[];
|
||||
},
|
||||
enabled: !!selectedVehicle,
|
||||
});
|
||||
|
||||
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
|
||||
const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : [];
|
||||
|
||||
const invalidateVehicle = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT });
|
||||
};
|
||||
|
||||
const onError = (err: unknown) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description:
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -77,24 +155,79 @@ export function MaintenancePage() {
|
||||
const res = await api.post('/maintenance/schedules', {
|
||||
vehicleId: selectedVehicle,
|
||||
...formData,
|
||||
serviceItem: formData.serviceItem.trim() || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Maintenance scheduled' });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
|
||||
});
|
||||
invalidateVehicle();
|
||||
setOpenScheduleModal(false);
|
||||
setFormData(emptyForm);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
onError,
|
||||
});
|
||||
|
||||
// Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling
|
||||
// of the next service when a maintenance completes with an odometer reading.
|
||||
const intervalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedVehicle) return;
|
||||
const res = await api.post('/maintenance/intervals', {
|
||||
vehicleId: selectedVehicle,
|
||||
maintenanceType: intervalForm.maintenanceType,
|
||||
serviceItem: intervalForm.serviceItem.trim() || undefined,
|
||||
intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm),
|
||||
intervalDays:
|
||||
intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays),
|
||||
description: intervalForm.description.trim() || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Interval saved' });
|
||||
invalidateVehicle();
|
||||
setIntervalForm(emptyIntervalForm);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const deactivateIntervalMutation = useMutation({
|
||||
mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Interval deactivated' });
|
||||
invalidateVehicle();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
// Completion with odometer: the reading is what advances KM-based
|
||||
// scheduling — the API auto-creates the next SCHEDULED item from it.
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!completeTarget) return;
|
||||
const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, {
|
||||
status: 'COMPLETED',
|
||||
completedDate: new Date().toISOString(),
|
||||
odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer),
|
||||
actualCost: completeCost === '' ? undefined : Number(completeCost),
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Maintenance completed',
|
||||
description:
|
||||
completeOdometer === ''
|
||||
? 'No odometer recorded — next service was NOT auto-scheduled.'
|
||||
: 'Next service auto-scheduled from the recorded odometer.',
|
||||
});
|
||||
invalidateVehicle();
|
||||
setCompleteTarget(null);
|
||||
setCompleteOdometer('');
|
||||
setCompleteCost('');
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const vehicleOptions =
|
||||
@@ -132,6 +265,64 @@ export function MaintenancePage() {
|
||||
</Group>
|
||||
|
||||
<Stack gap="md">
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Due Board — by date and driven km</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{dueLoading ? (
|
||||
<Text>Loading…</Text>
|
||||
) : dueBoard && dueBoard.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Next Due Date</Table.Th>
|
||||
<Table.Th>Next Due Km</Table.Th>
|
||||
<Table.Th>Current Km</Table.Th>
|
||||
<Table.Th>Remaining</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{dueBoard.map((row) => (
|
||||
<Table.Tr
|
||||
key={row.scheduleId}
|
||||
onClick={() => setSelectedVehicle(row.vehicleId)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Table.Td>{row.plateNumber}</Table.Td>
|
||||
<Table.Td>{row.maintenanceType}</Table.Td>
|
||||
<Table.Td>{row.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>{row.nextDueKm ?? '—'}</Table.Td>
|
||||
<Table.Td>{row.currentKm ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.kmRemaining != null
|
||||
? `${row.kmRemaining} km`
|
||||
: row.daysRemaining != null
|
||||
? `${row.daysRemaining} d`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={row.overdue ? 'edr-red' : 'edr-blue'}>
|
||||
{row.overdue ? 'OVERDUE' : 'SCHEDULED'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">Nothing scheduled fleet-wide</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
<Card withBorder padding="md">
|
||||
<Select
|
||||
label="Select Vehicle"
|
||||
@@ -150,50 +341,179 @@ export function MaintenancePage() {
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Upcoming Maintenance</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{isLoading ? (
|
||||
<Text>Loading...</Text>
|
||||
) : upcomingList.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Scheduled</Table.Th>
|
||||
<Table.Th>Est. Cost</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{upcomingList.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||
<Table.Td>{m.description}</Table.Td>
|
||||
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
{m.estimatedCost != null
|
||||
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||
</Table.Td>
|
||||
<>
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Service Intervals — drives auto-scheduling</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
e.g. oil change every 10,000 km. On completion with an odometer reading, the
|
||||
next service is scheduled automatically at reading + interval.
|
||||
</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
<Stack gap="sm">
|
||||
{intervalList.length > 0 && (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Every (km)</Table.Th>
|
||||
<Table.Th>Every (days)</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{intervalList.map((i) => (
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td>{i.maintenanceType}</Table.Td>
|
||||
<Table.Td>{i.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.intervalKm ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.intervalDays ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.description ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Deactivate — stops auto-scheduling">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => deactivateIntervalMutation.mutate(i.id)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
<Group align="flex-end" gap="sm" wrap="wrap">
|
||||
<Select
|
||||
label="Type"
|
||||
w={150}
|
||||
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
|
||||
value={intervalForm.maintenanceType}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({ ...intervalForm, maintenanceType: v || 'PREVENTIVE' })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Service item"
|
||||
placeholder="e.g. oil change"
|
||||
w={170}
|
||||
value={intervalForm.serviceItem}
|
||||
onChange={(e) =>
|
||||
setIntervalForm({ ...intervalForm, serviceItem: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Every (km)"
|
||||
min={0}
|
||||
w={130}
|
||||
value={intervalForm.intervalKm}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({ ...intervalForm, intervalKm: v === '' ? '' : Number(v) })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Every (days)"
|
||||
min={0}
|
||||
w={130}
|
||||
value={intervalForm.intervalDays}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({
|
||||
...intervalForm,
|
||||
intervalDays: v === '' ? '' : Number(v),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="Oil and filter change"
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
value={intervalForm.description}
|
||||
onChange={(e) =>
|
||||
setIntervalForm({ ...intervalForm, description: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
loading={intervalMutation.isPending}
|
||||
disabled={
|
||||
intervalForm.intervalKm === '' && intervalForm.intervalDays === ''
|
||||
}
|
||||
onClick={() => intervalMutation.mutate()}
|
||||
>
|
||||
Save interval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Upcoming Maintenance</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{isLoading ? (
|
||||
<Text>Loading...</Text>
|
||||
) : upcomingList.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Scheduled</Table.Th>
|
||||
<Table.Th>Est. Cost</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">No upcoming maintenance</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{upcomingList.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||
<Table.Td>{m.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>{m.description}</Table.Td>
|
||||
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
{m.estimatedCost != null
|
||||
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{(m.status === 'SCHEDULED' || m.status === 'IN_PROGRESS') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
onClick={() => setCompleteTarget(m)}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">No upcoming maintenance</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -210,6 +530,12 @@ export function MaintenancePage() {
|
||||
value={formData.maintenanceType}
|
||||
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Service item"
|
||||
placeholder="e.g. oil change — links this schedule to its interval"
|
||||
value={formData.serviceItem}
|
||||
onChange={(e) => setFormData({ ...formData, serviceItem: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="What needs to be done?"
|
||||
@@ -254,6 +580,49 @@ export function MaintenancePage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={completeTarget != null}
|
||||
onClose={() => setCompleteTarget(null)}
|
||||
title={`Complete maintenance${completeTarget?.serviceItem ? ` — ${completeTarget.serviceItem}` : ''}`}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Record the odometer at completion — the next service is auto-scheduled at reading +
|
||||
interval (e.g. completed at 50,000 km with a 10,000 km interval ⇒ next due at 60,000
|
||||
km).
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Odometer reading (km)"
|
||||
placeholder="e.g. 50000"
|
||||
min={0}
|
||||
required
|
||||
value={completeOdometer}
|
||||
onChange={(v) => setCompleteOdometer(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual cost (ETB)"
|
||||
min={0}
|
||||
value={completeCost}
|
||||
onChange={(v) => setCompleteCost(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setCompleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={15} />}
|
||||
loading={completeMutation.isPending}
|
||||
disabled={completeOdometer === ''}
|
||||
onClick={() => completeMutation.mutate()}
|
||||
>
|
||||
Complete & schedule next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
|
||||
) as Partial<T>;
|
||||
|
||||
const emptyAcquisition = {
|
||||
itemName: "",
|
||||
vehicleId: "",
|
||||
vendorId: "",
|
||||
acquisitionType: "PURCHASE" as AcquisitionType,
|
||||
@@ -239,6 +240,7 @@ export default function ProcurementPage() {
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Item / Asset</Table.Th>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
@@ -249,7 +251,7 @@ export default function ProcurementPage() {
|
||||
<Table.Tbody>
|
||||
{loadingAcquisitions ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Table.Td colSpan={6}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
@@ -257,7 +259,7 @@ export default function ProcurementPage() {
|
||||
</Table.Tr>
|
||||
) : acquisitions.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No acquisitions recorded yet.
|
||||
</Text>
|
||||
@@ -266,6 +268,7 @@ export default function ProcurementPage() {
|
||||
) : null}
|
||||
{acquisitions.map((a: AssetAcquisition) => (
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td>{a.itemName || "—"}</Table.Td>
|
||||
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
|
||||
@@ -411,31 +414,51 @@ export default function ProcurementPage() {
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Item / Asset"
|
||||
placeholder="What was acquired — e.g. brake pads, tyres, truck 3-15288"
|
||||
value={acqForm.itemName}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, itemName: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
label="Related vehicle (optional)"
|
||||
description="Only when the acquisition is a fleet vehicle itself — parts and general procurement stay unlinked."
|
||||
placeholder="Not tied to a vehicle"
|
||||
data={vehicleOptions}
|
||||
value={acqForm.vehicleId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Button variant="light" size="sm" onClick={() => setVendorModalOpen(true)}>
|
||||
Register vendor
|
||||
</Button>
|
||||
</Group>
|
||||
<Select
|
||||
label="Acquisition Type"
|
||||
data={ACQUISITION_TYPES}
|
||||
value={acqForm.acquisitionType}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" })
|
||||
}
|
||||
onChange={(val) => {
|
||||
const acquisitionType = (val as AcquisitionType) || "PURCHASE";
|
||||
// Lease terms are invalid on a purchase — drop them on switch.
|
||||
setAcqForm(
|
||||
acquisitionType === "PURCHASE"
|
||||
? { ...acqForm, acquisitionType, leaseStart: "", leaseEnd: "", monthlyPayment: undefined }
|
||||
: { ...acqForm, acquisitionType },
|
||||
);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
@@ -471,28 +494,32 @@ export default function ProcurementPage() {
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
{acqForm.acquisitionType !== "PURCHASE" && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
label="Status"
|
||||
data={ACQUISITION_STATUSES}
|
||||
@@ -514,7 +541,7 @@ export default function ProcurementPage() {
|
||||
<Button
|
||||
onClick={() => createAcquisition.mutate()}
|
||||
loading={createAcquisition.isPending}
|
||||
disabled={!acqForm.acquisitionDate}
|
||||
disabled={!acqForm.acquisitionDate || acqForm.itemName.trim().length < 2}
|
||||
>
|
||||
Save Acquisition
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
import {
|
||||
ArrowLeft,
|
||||
Fuel,
|
||||
Gauge,
|
||||
History,
|
||||
Route,
|
||||
Truck,
|
||||
@@ -28,7 +33,13 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { api as apiClient2 } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
vehiclesService,
|
||||
type SaveVehiclePayload,
|
||||
type Vehicle,
|
||||
} from "@/services/vehicles.service";
|
||||
import { driversService } from "@/services/drivers.service";
|
||||
import { fleetHistoryService } from "@/services/fleet-history.service";
|
||||
|
||||
@@ -132,6 +143,7 @@ const VehicleDetailPage = () => {
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
|
||||
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
|
||||
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
|
||||
<Tabs.Tab value="operations" leftSection={<Gauge size={14} />}>Operations</Tabs.Tab>
|
||||
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
@@ -142,6 +154,8 @@ const VehicleDetailPage = () => {
|
||||
<InfoRow label="Code" value={vehicle.code ?? "—"} />
|
||||
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
|
||||
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
|
||||
<InfoRow label="VIN" value={vehicle.vin ?? "—"} />
|
||||
<InfoRow label="Ownership" value={vehicle.ownership ?? "—"} />
|
||||
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
|
||||
<InfoRow label="Model" value={vehicle.model ?? "—"} />
|
||||
<InfoRow label="Year" value={vehicle.year ?? "—"} />
|
||||
@@ -172,6 +186,10 @@ const VehicleDetailPage = () => {
|
||||
<FuelTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="operations" pt="lg">
|
||||
<OperationsTab vehicle={vehicle} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="mile" pt="lg">
|
||||
<MileTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
@@ -181,6 +199,103 @@ const VehicleDetailPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a truck is and what it costs to run are per-trip operational facts, not
|
||||
* part of registering the vehicle — so they are edited here rather than on the
|
||||
* Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges
|
||||
* are `distance × pricePerKm`.
|
||||
*/
|
||||
const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState({
|
||||
locationId: vehicle.locationId ?? "",
|
||||
estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "",
|
||||
actualDistanceKm: vehicle.actualDistanceKm ?? "",
|
||||
pricePerKm: vehicle.pricePerKm ?? "",
|
||||
currency: vehicle.currency ?? "ETB",
|
||||
});
|
||||
|
||||
const { data: yards = [], isLoading: yardsLoading } = useQuery(
|
||||
apiClient2.routes.yards.queryOptions(),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
vehiclesService.update(vehicle.id, {
|
||||
locationId: form.locationId || null,
|
||||
// Empty means "not recorded" — send null so the column is unset rather
|
||||
// than coerced to 0, which would read as a real measurement.
|
||||
estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm),
|
||||
actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm),
|
||||
pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm),
|
||||
currency: form.currency || null,
|
||||
} as Partial<SaveVehiclePayload> & { locationId?: string | null }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] });
|
||||
toast({ title: "Operational details saved" });
|
||||
},
|
||||
onError: () =>
|
||||
toast({ title: "Could not save operational details", variant: "destructive" }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Location"
|
||||
placeholder={yardsLoading ? "Loading yards..." : "Not set"}
|
||||
data={(yards as Array<{ id: string; label?: string; code?: string }>).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code ?? y.id,
|
||||
}))}
|
||||
value={form.locationId || null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, locationId: v ?? "" }))}
|
||||
disabled={yardsLoading}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<NumberInput
|
||||
label="Price per KM"
|
||||
description="Used for first/last-mile billing"
|
||||
value={form.pricePerKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, pricePerKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Estimated Distance (KM)"
|
||||
value={form.estimatedDistanceKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual Distance (KM)"
|
||||
value={form.actualDistanceKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<Radio.Group
|
||||
label="Currency"
|
||||
value={form.currency}
|
||||
onChange={(v) => setForm((f) => ({ ...f, currency: v }))}
|
||||
>
|
||||
<Group gap="lg" mt={6}>
|
||||
<Radio value="ETB" label="ETB" />
|
||||
<Radio value="USD" label="USD" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
</SimpleGrid>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={() => save.mutate()} loading={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DriverTab = ({
|
||||
vehicleId,
|
||||
driverId,
|
||||
|
||||
@@ -30,10 +30,22 @@ export type FleetDynamicOptions =
|
||||
| "wagonTypes"
|
||||
| "containerTypes"
|
||||
| "cargoTypes"
|
||||
| "truckTypes"
|
||||
| "wagons"
|
||||
| "containers"
|
||||
| "yards";
|
||||
|
||||
/**
|
||||
* A dynamic select option that can carry the record it came from. Picking a
|
||||
* truck type has to pull its capacity and trailer configuration into the form,
|
||||
* which a bare {label, value} pair cannot express.
|
||||
*/
|
||||
export interface FleetSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FleetResourceColumn {
|
||||
id: string;
|
||||
header: string;
|
||||
@@ -45,6 +57,17 @@ export interface FleetResourceColumn {
|
||||
export interface FleetFormFieldDef extends FormFieldDef {
|
||||
dynamicOptions?: FleetDynamicOptions;
|
||||
noneOption?: boolean;
|
||||
/** Options carrying their source record, so `onOptionSelected` can read it. */
|
||||
options?: FleetSelectOption[];
|
||||
/**
|
||||
* Patch merged into the form when this select changes — for values that are a
|
||||
* property of the chosen option rather than typed per record (a vehicle's
|
||||
* capacity comes from its truck type). Returns the fields to overwrite.
|
||||
*/
|
||||
onOptionSelected?: (
|
||||
option: FleetSelectOption | undefined,
|
||||
values: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
/**
|
||||
* Read-only field whose value is computed from the other fields rather than
|
||||
* typed. Rendered non-editable and recomputed on every change, so the stored
|
||||
|
||||
@@ -10,6 +10,16 @@ const PLATE_PATTERN = {
|
||||
message: "Use letters and numbers like ET-9875 or AA-8642",
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy static list. Truck configurations now live in `freight.truck_types`
|
||||
* and are edited in the back office (Rule Engine → Truck Types), so the form
|
||||
* loads them through `dynamicOptions: "truckTypes"` instead.
|
||||
*
|
||||
* Kept only for the driver "authorized vehicle types" multiselect, which stores
|
||||
* free-text categories rather than truck-type ids.
|
||||
*
|
||||
* @deprecated prefer the managed truck types
|
||||
*/
|
||||
const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Truck", value: "TRUCK" },
|
||||
{ label: "Van", value: "VAN" },
|
||||
@@ -20,6 +30,11 @@ const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Flatbed", value: "FLATBED" },
|
||||
];
|
||||
|
||||
const OWNERSHIP_OPTIONS = [
|
||||
{ label: "Owned", value: "OWNED" },
|
||||
{ label: "Outsourced", value: "OUTSOURCED" },
|
||||
];
|
||||
|
||||
const FUEL_TYPE_OPTIONS = [
|
||||
{ label: "Petrol", value: "PETROL" },
|
||||
{ label: "Diesel", value: "DIESEL" },
|
||||
@@ -39,11 +54,6 @@ const VEHICLE_AVAILABILITY_OPTIONS = [
|
||||
{ label: "Busy", value: "BUSY" },
|
||||
];
|
||||
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ label: "ETB", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
];
|
||||
|
||||
export const vehiclesConfig: FleetResourceConfig = {
|
||||
slug: "vehicles",
|
||||
label: "Vehicles",
|
||||
@@ -72,35 +82,65 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
options: VEHICLE_AVAILABILITY_OPTIONS,
|
||||
},
|
||||
],
|
||||
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
|
||||
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "vin", "status"],
|
||||
columns: [
|
||||
{ id: "code", header: "Code", accessorKey: "code", size: 90 },
|
||||
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
|
||||
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
|
||||
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
|
||||
{ id: "model", header: "Model", accessorKey: "model", size: 100 },
|
||||
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 },
|
||||
{ id: "truckTypeId", header: "Truck Type", accessorKey: "truckTypeId", format: "entityLabel", size: 130 },
|
||||
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 },
|
||||
{ id: "locationId", header: "Location", accessorKey: "locationId", size: 140 },
|
||||
{ id: "ownership", header: "Ownership", accessorKey: "ownership", size: 100 },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||
{ id: "availability", header: "Availability", accessorKey: "availability", format: "statusBadge", size: 100 },
|
||||
],
|
||||
// Registration captures what the vehicle IS. Location, distances and haulage
|
||||
// pricing are per-trip operational data and live on the vehicle's Operations
|
||||
// tab instead (VehicleDetailPage) — they are not part of registering a truck.
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text" },
|
||||
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
|
||||
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
|
||||
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
|
||||
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
|
||||
// The truck type decides whether a trailer exists at all: a rigid truck
|
||||
// (Casoni) has none, so the field disappears and submits null. Mirrored
|
||||
// server-side in VehiclesService — hiding a field is not enforcement.
|
||||
{
|
||||
name: "trailerPlateNo",
|
||||
label: "Trailer Plate No",
|
||||
type: "text",
|
||||
pattern: PLATE_PATTERN,
|
||||
showIf: (values) => values._hasTrailer !== false,
|
||||
},
|
||||
{
|
||||
name: "truckTypeId",
|
||||
label: "Truck Type",
|
||||
type: "select",
|
||||
required: true,
|
||||
dynamicOptions: "truckTypes",
|
||||
description: "Managed in Rule Engine → Truck Types",
|
||||
// Capacity is a property of the type, not of each individual truck.
|
||||
// `_hasTrailer` is form-local scratch (stripped before submit) that drives
|
||||
// the trailer plate's visibility.
|
||||
onOptionSelected: (option) => ({
|
||||
_hasTrailer: option?.meta?.hasTrailer ?? true,
|
||||
...(option?.meta?.capacityTons != null
|
||||
? { capacity: option.meta.capacityTons }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
{ name: "vin", label: "VIN", type: "text", description: "Vehicle Identification Number" },
|
||||
{ name: "ownership", label: "Ownership", type: "radio", options: OWNERSHIP_OPTIONS },
|
||||
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
||||
{ name: "model", label: "Model", type: "text", required: true },
|
||||
{ name: "year", label: "Year", type: "number", required: true },
|
||||
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
|
||||
{ name: "capacity", label: "Capacity", type: "number", required: true },
|
||||
{ name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" },
|
||||
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
|
||||
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
|
||||
{ name: "pricePerKm", label: "Price per KM", type: "number" },
|
||||
{ name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS },
|
||||
{
|
||||
name: "capacity",
|
||||
label: "Capacity (tons)",
|
||||
type: "number",
|
||||
required: true,
|
||||
description: "Pre-filled from the truck type — override only for a one-off",
|
||||
},
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
||||
{ name: "description", label: "Description", type: "textarea" },
|
||||
@@ -108,19 +148,15 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
emptyValues: {
|
||||
code: "03-ET",
|
||||
plateNumber: "",
|
||||
powerPlateNo: "",
|
||||
trailerPlateNo: "",
|
||||
vehicleType: "TRUCK",
|
||||
truckTypeId: "",
|
||||
vin: "",
|
||||
ownership: "OWNED",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
year: new Date().getFullYear(),
|
||||
fuelType: "DIESEL",
|
||||
capacity: 0,
|
||||
locationId: null,
|
||||
estimatedDistanceKm: "",
|
||||
actualDistanceKm: "",
|
||||
pricePerKm: "",
|
||||
currency: "ETB",
|
||||
status: "ACTIVE",
|
||||
availability: "FREE",
|
||||
description: "",
|
||||
|
||||
@@ -229,6 +229,21 @@ const cargoDesc = (r: FirstMileRecord) => {
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
const cargoTypeName = (r: FirstMileRecord) =>
|
||||
r.booking?.cargoType?.cargoTypeName ??
|
||||
r.booking?.cargoType?.label ??
|
||||
r.booking?.cargoType?.name ??
|
||||
r.booking?.cargoFreeText ??
|
||||
"—";
|
||||
const isBulkBooking = (r: FirstMileRecord) => r.booking?.freightType === "BULK";
|
||||
const bookingTotalTons = (r: FirstMileRecord) => Number(r.booking?.cargoTotalWeightVgm) || 0;
|
||||
const trainScheduleLabel = (r: FirstMileRecord) => {
|
||||
const s = r.booking?.trainSchedule;
|
||||
if (!s?.trainNumber && !s?.departureDate) return "—";
|
||||
return [s.trainNumber, s.departureDate ? new Date(s.departureDate).toLocaleDateString() : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
};
|
||||
// First-mile destination is the origin yard (pickup → origin yard)
|
||||
const destinationYardName = (r: FirstMileRecord) =>
|
||||
r.booking?.originYard?.label ?? "—";
|
||||
@@ -277,7 +292,9 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo Type" value={cargoTypeName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Train Schedule" value={trainScheduleLabel(record)} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment, currencyOf(record))} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
@@ -496,10 +513,11 @@ const FirstMilePage = () => {
|
||||
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
||||
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||
// Multi-vehicle assign: one row per truck — vehicle + its load (container for
|
||||
// container bookings; tonnes + optional item count for bulk).
|
||||
const [vehicleRows, setVehicleRows] = useState<
|
||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||
>([{ vehicleId: null, containerNumber: "" }]);
|
||||
Array<{ vehicleId: string | null; containerNumber: string; tons: number | ""; quantity: number | "" }>
|
||||
>([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
||||
@@ -925,13 +943,19 @@ const FirstMilePage = () => {
|
||||
? rec.vehicleAssignments.map((a, i) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
||||
tons: (a.tons != null ? Number(a.tons) : "") as number | "",
|
||||
quantity: (a.quantity != null ? Number(a.quantity) : "") as number | "",
|
||||
}))
|
||||
: rec?.vehicleId
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }];
|
||||
setBulkMode(false);
|
||||
setActiveId(resolved);
|
||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
||||
setVehicleRows(
|
||||
rows.length
|
||||
? rows
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "", quantity: "" }],
|
||||
);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -944,7 +968,7 @@ const FirstMilePage = () => {
|
||||
}
|
||||
setBulkMode(true);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -952,15 +976,41 @@ const FirstMilePage = () => {
|
||||
setAssignOpen(false);
|
||||
setBulkMode(false);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
const bulkCargo = activeRecord != null && isBulkBooking(activeRecord);
|
||||
if (bulkCargo && vehicleRows.some((r) => r.vehicleId && r.tons === "")) {
|
||||
toast({
|
||||
title: "Tonnes required",
|
||||
description: "Enter the tonnage each truck hauls — bulk assignment draws down the booking total.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (bulkCargo) {
|
||||
const total = bookingTotalTons(activeRecord);
|
||||
const assigning = vehicleRows.reduce((s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0), 0);
|
||||
if (total > 0 && assigning > total + 0.001) {
|
||||
toast({
|
||||
title: "Over booking tonnage",
|
||||
description: `Assigned ${assigning} t exceeds the booking's ${total} t.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const vehicles = vehicleRows
|
||||
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
||||
.filter((r): r is (typeof vehicleRows)[number] & { vehicleId: string } => Boolean(r.vehicleId))
|
||||
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
||||
.map((r) => ({
|
||||
vehicleId: r.vehicleId,
|
||||
containerNumber: r.containerNumber.trim() || null,
|
||||
tons: bulkCargo && r.tons !== "" ? Number(r.tons) : null,
|
||||
quantity: bulkCargo && r.quantity !== "" ? Number(r.quantity) : null,
|
||||
}));
|
||||
const count = vehicles.length;
|
||||
const targetIds = bulkMode
|
||||
? selectedIds
|
||||
@@ -1432,30 +1482,63 @@ const FirstMilePage = () => {
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
/>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
{!bulkMode && activeRecord && isBulkBooking(activeRecord) ? (
|
||||
<>
|
||||
<NumberInput
|
||||
style={{ flex: 0.8 }}
|
||||
label={i === 0 ? "Tonnes" : undefined}
|
||||
placeholder="t"
|
||||
min={0}
|
||||
value={row.tons}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
style={{ flex: 0.8 }}
|
||||
label={i === 0 ? "Items qty (pcs)" : undefined}
|
||||
placeholder="optional"
|
||||
min={0}
|
||||
value={row.quantity}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
{vehicleRows.length > 1 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -1479,6 +1562,8 @@ const FirstMilePage = () => {
|
||||
vehicleId: null,
|
||||
containerNumber:
|
||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
||||
tons: "" as const,
|
||||
quantity: "" as const,
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -1491,6 +1576,22 @@ const FirstMilePage = () => {
|
||||
>
|
||||
Add vehicle
|
||||
</Button>
|
||||
{!bulkMode && activeRecord && isBulkBooking(activeRecord) && (() => {
|
||||
const total = bookingTotalTons(activeRecord);
|
||||
const assigning = vehicleRows.reduce(
|
||||
(s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0),
|
||||
0,
|
||||
);
|
||||
const remaining = Math.round((total - assigning) * 1000) / 1000;
|
||||
return (
|
||||
<Text size="sm" c={remaining < 0 ? "red" : "dimmed"}>
|
||||
Bulk drawdown: {assigning} t of {total} t assigned —{" "}
|
||||
<Text span fw={600} c={remaining < 0 ? "red" : undefined}>
|
||||
{remaining} t remaining
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
</Stack>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||
|
||||
@@ -409,6 +409,43 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "truck-types",
|
||||
label: "Truck Types",
|
||||
category: "configuration",
|
||||
subtitle:
|
||||
"Configure the truck configurations vehicles are registered against — capacity and whether a trailer applies",
|
||||
searchPlaceholder: "Search truck types by name or code...",
|
||||
cardTitleKey: "name",
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
||||
{ id: "hasTrailer", header: "Has trailer", accessorKey: "hasTrailer" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text", required: true },
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
{
|
||||
name: "capacityTons",
|
||||
label: "Capacity (tons)",
|
||||
type: "number",
|
||||
optional: true,
|
||||
description: "Pre-fills the capacity of every vehicle registered on this type",
|
||||
},
|
||||
// Drives the trailer plate on vehicle registration: a rigid truck (Casoni)
|
||||
// has none, so registering one with a trailer plate is rejected.
|
||||
{
|
||||
name: "hasTrailer",
|
||||
label: "Pulls a trailer",
|
||||
type: "boolean",
|
||||
description: "Off for a rigid truck (e.g. Casoni) — its registration has no trailer plate",
|
||||
},
|
||||
{ name: "description", label: "Description", type: "textarea", optional: true },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "yard-distances",
|
||||
label: "Yard Distances",
|
||||
|
||||
@@ -12,16 +12,17 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import {
|
||||
useAcknowledgeInterchangeDocument,
|
||||
useCancelInterchangeDocument,
|
||||
useDisputeInterchangeDocument,
|
||||
useInterchangeDocument,
|
||||
useInterchangeDocuments,
|
||||
@@ -66,7 +67,7 @@ const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
||||
(item, index) => `
|
||||
<tr>
|
||||
<td>${index + 1}</td>
|
||||
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
|
||||
<td>${escapeHtml(item.bookingReference)}</td>
|
||||
<td>${escapeHtml(item.itemType)}</td>
|
||||
<td>${escapeHtml(item.containerNumber)}</td>
|
||||
<td>${escapeHtml(item.sealNumber)}</td>
|
||||
@@ -118,7 +119,6 @@ const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
||||
<div class="grid">
|
||||
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
|
||||
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
|
||||
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
|
||||
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
|
||||
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
|
||||
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
|
||||
@@ -191,7 +191,6 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<DetailField label="Document No" value={document.documentNo} />
|
||||
<DetailField label="Direction" value={document.direction} />
|
||||
<DetailField label="Schedule" value={document.scheduleId?.slice(0, 8)} />
|
||||
<DetailField label="Train No" value={document.trainNo} />
|
||||
<DetailField label="Handover Location" value={document.handoverLocation} />
|
||||
<DetailField label="Handover From" value={document.handoverFrom} />
|
||||
@@ -232,7 +231,7 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
|
||||
<Table.Tbody>
|
||||
{items.map((item) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>{item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.bookingReference ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.itemType}</Table.Td>
|
||||
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.sealNumber ?? '-'}</Table.Td>
|
||||
@@ -266,9 +265,11 @@ export default function InterchangeDocumentsPage() {
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
|
||||
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
|
||||
// Search stays server-side (passed in `filter`); this adds the date range and
|
||||
// pagination over what comes back.
|
||||
const controls = useListControls(documents, { dateKey: 'generatedAt' });
|
||||
const acknowledge = useAcknowledgeInterchangeDocument();
|
||||
const dispute = useDisputeInterchangeDocument();
|
||||
const cancel = useCancelInterchangeDocument();
|
||||
|
||||
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||
if (interchangeDocument.items?.length) return interchangeDocument;
|
||||
@@ -336,19 +337,7 @@ export default function InterchangeDocumentsPage() {
|
||||
),
|
||||
},
|
||||
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
|
||||
{
|
||||
id: 'train',
|
||||
header: 'Train No / Schedule',
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{row.original.trainNo ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.scheduleId?.slice(0, 8) ?? '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{ id: 'route', header: 'Route', cell: ({ row }) => row.original.routeId?.slice(0, 8) ?? '-' },
|
||||
{ id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' },
|
||||
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
|
||||
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
|
||||
{ id: 'handoverTo', header: 'Handover To', cell: ({ row }) => row.original.handoverTo },
|
||||
@@ -390,7 +379,7 @@ export default function InterchangeDocumentsPage() {
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{doc.status !== 'ACKNOWLEDGED' && doc.status !== 'CANCELLED' ? (
|
||||
{doc.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="green"
|
||||
@@ -423,7 +412,9 @@ export default function InterchangeDocumentsPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{doc.status !== 'CANCELLED' ? (
|
||||
{/* Disputes are raised BEFORE acknowledgement; a registered dispute
|
||||
(DISPUTED) is read-only — its row offers View only. */}
|
||||
{doc.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
@@ -434,17 +425,6 @@ export default function InterchangeDocumentsPage() {
|
||||
Dispute
|
||||
</Button>
|
||||
) : null}
|
||||
{doc.status === 'DRAFT' || doc.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<XCircle size={14} />}
|
||||
onClick={() => run(() => cancel.mutateAsync(doc.id), 'Interchange document cancelled')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -460,7 +440,7 @@ export default function InterchangeDocumentsPage() {
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{documents.length} document(s)</Text>
|
||||
<Text fw={600}>{controls.totalCount} document(s)</Text>
|
||||
<TextInput
|
||||
w={{ base: '100%', sm: 320 }}
|
||||
leftSection={<Search size={16} />}
|
||||
@@ -470,6 +450,19 @@ export default function InterchangeDocumentsPage() {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
showSearch={false}
|
||||
search=""
|
||||
onSearchChange={() => {}}
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Generated"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{!isLoading && documents.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
@@ -479,9 +472,10 @@ export default function InterchangeDocumentsPage() {
|
||||
) : (
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
@@ -12,10 +13,16 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { IntercityRideAlongRow } from "@/types/trainScheduling";
|
||||
|
||||
@@ -74,7 +81,45 @@ function FacilityCell({
|
||||
);
|
||||
}
|
||||
|
||||
const apiErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const message = (error as { response?: { data?: { message?: unknown } } }).response?.data
|
||||
?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const refresh = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.intercityBookings.queryKey(undefined),
|
||||
});
|
||||
// Same endpoints as the schedule page's ride-along panel — the server still
|
||||
// validates the train's recorded checkpoint, payment and yard equipment.
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadIntercityBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast({ title: "Cargo loaded onto the train" });
|
||||
void refresh();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({ variant: "destructive", title: "Load failed", description: apiErrorMessage(error) }),
|
||||
}),
|
||||
);
|
||||
const unload = useMutation(
|
||||
api.trainScheduling.unloadIntercityBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast({ title: "Cargo unloaded — booking completed" });
|
||||
void refresh();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({ variant: "destructive", title: "Unload failed", description: apiErrorMessage(error) }),
|
||||
}),
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Alert variant="light" color="gray">
|
||||
@@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Table.Th ta="right">Weight</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* Work the cargo right here while the train is at the yard. */}
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={load.isPending}
|
||||
onClick={() =>
|
||||
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
)}
|
||||
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={unload.isPending}
|
||||
onClick={() =>
|
||||
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
@@ -205,6 +283,15 @@ export default function IntercityPage() {
|
||||
[rows],
|
||||
);
|
||||
|
||||
// Controls follow the active tab, so search/date/paging always describe what
|
||||
// is on screen. Panels unmount when hidden (keepMounted={false}) so a hidden
|
||||
// tab can never render another tab's paged slice.
|
||||
const active = tab === "riding" ? riding : tab === "done" ? done : waiting;
|
||||
const controls = useListControls(active, {
|
||||
searchKeys: ["reference", "grnNumber", "customer", "origin", "destination", "trainNumber"],
|
||||
dateKey: "loadedAt",
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -283,19 +370,40 @@ export default function IntercityPage() {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="waiting">
|
||||
<Rows rows={waiting} />
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Reference, GRN, customer, train…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Loaded"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<Tabs.Panel value="waiting" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="riding">
|
||||
<Rows rows={riding} />
|
||||
<Tabs.Panel value="riding" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="done">
|
||||
<Rows rows={done} />
|
||||
<Tabs.Panel value="done" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
Loading and unloading happen on the train's schedule page, where the ride-along
|
||||
panel confirms the train is at the yard.
|
||||
Load and Unload appear on a row while its train is recorded at that yard ("train
|
||||
here"); the same actions also live on the train's schedule page.
|
||||
</Text>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
useInventoryInquiry,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function InventoryInquiryPage() {
|
||||
@@ -29,6 +34,11 @@ export default function InventoryInquiryPage() {
|
||||
const { data, isFetching } = useInventoryInquiry(applied);
|
||||
const results = data ?? [];
|
||||
|
||||
const controls = useListControls(results, {
|
||||
searchKeys: ['containerNumber', 'bookingReference', 'customerName', 'cargoDescription', 'locationSummary'],
|
||||
dateKey: 'arrivedAt',
|
||||
});
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
@@ -173,7 +183,28 @@ export default function InventoryInquiryPage() {
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||
<>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Container, booking, customer, location…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="items"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Badge, Card, Group, Text } from '@mantine/core';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -67,6 +69,10 @@ export default function LoadedInventoryPage() {
|
||||
api.warehouses.loadings.queryOptions({ input: {} }),
|
||||
);
|
||||
const loadings = data ?? [];
|
||||
const controls = useListControls(loadings, {
|
||||
searchKeys: ['wagonNumber'],
|
||||
dateKey: 'loadedAt',
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -83,12 +89,27 @@ export default function LoadedInventoryPage() {
|
||||
description="Once items are loaded onto a wagon, their records show here."
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={loadings}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search by wagon…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Loaded"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</PageContainer>
|
||||
|
||||
@@ -7,12 +7,15 @@ import {
|
||||
SegmentedControl,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path; reused here rather than adding a second one.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
||||
import type { TruckOnSite } from "@/types/warehouse";
|
||||
|
||||
@@ -148,20 +151,21 @@ export default function TrucksOnSitePage() {
|
||||
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
|
||||
);
|
||||
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return trucks
|
||||
.filter((t) => scope === "ALL" || t.status === scope)
|
||||
.filter((t) => source === "ALL" || t.source === source)
|
||||
.filter((t) =>
|
||||
!term
|
||||
? true
|
||||
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
|
||||
.some((field) => field?.toLowerCase().includes(term)),
|
||||
);
|
||||
}, [trucks, scope, source, search]);
|
||||
// Scope/source are page filters and run first; the shared control then does
|
||||
// search + arrival-date range + pagination over what they leave.
|
||||
const scoped = useMemo(
|
||||
() =>
|
||||
trucks
|
||||
.filter((t) => scope === "ALL" || t.status === scope)
|
||||
.filter((t) => source === "ALL" || t.source === source),
|
||||
[trucks, scope, source],
|
||||
);
|
||||
|
||||
const controls = useListControls(scoped, {
|
||||
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
|
||||
dateKey: "arrivedAt",
|
||||
});
|
||||
|
||||
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
|
||||
const inboundCount = trucks.length - onSiteCount;
|
||||
@@ -198,17 +202,35 @@ export default function TrucksOnSitePage() {
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
size="xs"
|
||||
w={280}
|
||||
placeholder="Plate, driver, booking, container…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? <Text size="sm">Loading…</Text> : <Rows rows={rows} />}
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Plate, driver, booking, container…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Text size="sm">Loading…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="trucks"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { AccrualDashboard } from '@/components/warehouses';
|
||||
@@ -50,7 +52,6 @@ const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
@@ -60,11 +61,10 @@ export default function WarehouseInvoicesPage() {
|
||||
);
|
||||
const invoices = data ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return invoices;
|
||||
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
|
||||
}, [invoices, search]);
|
||||
const controls = useListControls(invoices, {
|
||||
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
|
||||
dateKey: 'issuedAt',
|
||||
});
|
||||
|
||||
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
|
||||
{
|
||||
@@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() {
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search invoice no / booking / customer"
|
||||
leftSection={<Search size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={320}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||
value={status}
|
||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search invoice no / booking / customer"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Issued"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="All statuses"
|
||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||
value={status}
|
||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</ListControls>
|
||||
|
||||
<DataTable
|
||||
columns={invoiceColumns}
|
||||
data={filtered}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No invoices found."
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
WarehouseTable,
|
||||
type WarehouseView,
|
||||
} from '@/components/warehouses';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||
|
||||
@@ -31,6 +36,11 @@ export default function WarehouseListPage() {
|
||||
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||
const warehouses = data ?? [];
|
||||
|
||||
// Search stays with WarehouseFilters — it is server-side and debounced, so
|
||||
// re-doing it client-side here would be a regression. This adds only the date
|
||||
// range + pagination, shared by both the table and card views.
|
||||
const controls = useListControls(warehouses, { dateKey: 'createdAt' });
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
@@ -57,6 +67,19 @@ export default function WarehouseListPage() {
|
||||
<Stack gap="md">
|
||||
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
|
||||
|
||||
<ListControls
|
||||
showSearch={false}
|
||||
search=""
|
||||
onSearchChange={() => {}}
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Created"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
@@ -65,10 +88,21 @@ export default function WarehouseListPage() {
|
||||
<Text c="red" ta="center" py="xl">
|
||||
Failed to load warehouses.
|
||||
</Text>
|
||||
) : view === 'table' ? (
|
||||
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
|
||||
) : (
|
||||
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
|
||||
<>
|
||||
{view === 'table' ? (
|
||||
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
|
||||
) : (
|
||||
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
|
||||
)}
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="warehouses"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
@@ -39,7 +41,6 @@ import {
|
||||
FEE_RULE_BASIS_LABELS,
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
VEHICLE_TYPES,
|
||||
type AllocationRule,
|
||||
type FeeRule,
|
||||
type FeeRuleBasis,
|
||||
@@ -141,6 +142,10 @@ function AllocationRules() {
|
||||
});
|
||||
|
||||
const rules = data ?? [];
|
||||
const controls = useListControls(rules, {
|
||||
searchKeys: ['name', 'targetYardCode', 'targetWarehouseCode', 'targetZoneCode', 'freightType', 'tradeDirection', 'cargoTypeCode', 'storageType'],
|
||||
dateKey: 'createdAt',
|
||||
});
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.code)
|
||||
.map((yard) => ({
|
||||
@@ -255,7 +260,7 @@ function AllocationRules() {
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
{controls.totalCount} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New allocation rule
|
||||
@@ -268,12 +273,26 @@ function AllocationRules() {
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search allocation rules…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Created"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={allocationColumns}
|
||||
data={rules}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||
@@ -393,6 +412,12 @@ function FeeRules() {
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
// Detention rules match on the truck-type CODE denormalised onto
|
||||
// vehicles.vehicle_type, so the options come from the managed truck types
|
||||
// rather than a hardcoded list that drifts the moment a type is added.
|
||||
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
|
||||
api.truckTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const update = useUpdateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
@@ -414,6 +439,10 @@ function FeeRules() {
|
||||
currency: 'USD',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
const controls = useListControls(rules, {
|
||||
searchKeys: ['name', 'ruleType', 'freightType', 'tradeDirection', 'cargoTypeCode', 'containerType', 'vehicleType', 'currency'],
|
||||
dateKey: 'createdAt',
|
||||
});
|
||||
const cargoTypeOptions = codeOptions(cargoTypes);
|
||||
const containerTypeOptions = codeOptions(containerTypes);
|
||||
const isBulkRule = form.freightType === 'BULK';
|
||||
@@ -642,19 +671,33 @@ function FeeRules() {
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
{controls.totalCount} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search fee rules…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Created"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={feeColumns}
|
||||
data={rules}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No storage or demurrage fee rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||
@@ -712,8 +755,9 @@ function FeeRules() {
|
||||
{isTruckDetention && (
|
||||
<Select
|
||||
label="Truck type"
|
||||
placeholder="Any truck type"
|
||||
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
|
||||
placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'}
|
||||
data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))}
|
||||
disabled={truckTypesLoading}
|
||||
value={form.vehicleType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
|
||||
clearable
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Upload,
|
||||
Download,
|
||||
CheckSquare,
|
||||
UserCheck,
|
||||
FileText,
|
||||
LucideIcon,
|
||||
Settings,
|
||||
Handshake,
|
||||
BellRing,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { useUserDetail } from "../hooks/useUserDetail";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import Cookies from "js-cookie";
|
||||
import { useDelegations } from "../hooks/useDelegations";
|
||||
import Top from "./Top";
|
||||
|
||||
import { useReport } from "../hooks/useReport";
|
||||
import { hasApprovalPermission } from "@/record-management/routes/routes";
|
||||
import { useReportByHooks } from "../hooks/useReportByHooks";
|
||||
import { useMyCollaborations } from "../hooks/useMyCollaborations";
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export interface INavTabs {
|
||||
icon: LucideIcon;
|
||||
isVissible?: boolean;
|
||||
label?: string;
|
||||
title?: string;
|
||||
href?: string;
|
||||
url?: string;
|
||||
isActive?: boolean;
|
||||
count?: boolean;
|
||||
isPrimary?: boolean;
|
||||
countBadge?: number;
|
||||
isUrgent?: boolean;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const baseParams = {
|
||||
skip: 0,
|
||||
take: 10,
|
||||
orderBy: "employeePosition.createdAt:DESC",
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { hasDelegated } = useDelegations(baseParams);
|
||||
const { userDetails, selectedPositionPermissionKeys, selectedPosition } =
|
||||
useAuthUser();
|
||||
const { permissionKeys } = useUserDetail(userDetails);
|
||||
const useParentCounts =
|
||||
permissionKeys.includes("can:viewParentPositionRecord") &&
|
||||
new URLSearchParams(location.search).get("view") === "parent";
|
||||
const currentPosition = Cookies.get("current-position-id");
|
||||
const canViewApprovalTab = hasApprovalPermission(
|
||||
selectedPositionPermissionKeys,
|
||||
);
|
||||
|
||||
// Reuse the auth layer's already-normalized active position instead of
|
||||
// re-deriving it here. useAuthUser resolves it by matching BOTH `id` and
|
||||
// `employeePositionId` (and honors the delegated-position cookie), so a
|
||||
// delegate's position is found and `isDelegate` is reliable. The previous
|
||||
// local lookup matched `pos.id === selectedPositionId` only — but
|
||||
// `selectedPositionId` is normalized to `employeePositionId`, so the find
|
||||
// returned undefined and `!undefined` left every tab visible for delegates.
|
||||
const canViewDelegationTab = !selectedPosition?.isDelegate;
|
||||
const userRoles =
|
||||
userDetails?.roles?.map((r: { key: string }) => r.key) || [];
|
||||
const showUserManagementShortcut =
|
||||
userRoles.includes("admin") ||
|
||||
userRoles.includes("unit_admin") ||
|
||||
userRoles.includes("super_admin");
|
||||
|
||||
const {
|
||||
dashboard,
|
||||
ROdashboard,
|
||||
TotalDraftExternal,
|
||||
TotalDraftInternal,
|
||||
TotalDraftCC,
|
||||
ROTotalDraftIncoming,
|
||||
ROPending,
|
||||
TotalUrgentDraftInternal,
|
||||
TotalUrgentDraftExternal,
|
||||
ROPendingUrgent,
|
||||
} = useReport("");
|
||||
const { breakdownCounts } = useReportByHooks({
|
||||
isSecretary: useParentCounts,
|
||||
});
|
||||
const { data: draftCollaborations } = useMyCollaborations({
|
||||
skip: 0,
|
||||
take: 1,
|
||||
signStatus: "draft",
|
||||
});
|
||||
const collaborationDraftCount =
|
||||
draftCollaborations?.count ?? dashboard?.collaborationCount?.draft ?? 0;
|
||||
const incomingHref = useParentCounts
|
||||
? "/record-management/userIncoming?view=parent"
|
||||
: "/record-management/userIncoming";
|
||||
|
||||
const navigationTabs = useMemo(
|
||||
(): INavTabs[] => [
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
label: t("header.navigation.dashboard"),
|
||||
href: "/record-management/dashboard",
|
||||
isActive: location.pathname.includes("/record-management/dashboard"),
|
||||
isPrimary: true,
|
||||
// Delegates see only Outgoing, Incoming, Approval — hide Dashboard.
|
||||
isVissible: canViewDelegationTab,
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
label: "Reports",
|
||||
href: "/record-management/sector-reports",
|
||||
isActive: location.pathname.startsWith(
|
||||
"/record-management/sector-reports",
|
||||
),
|
||||
isPrimary: false,
|
||||
isVissible: false,
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
label: t("header.navigation.outgoing"),
|
||||
href: "/record-management/userRecords",
|
||||
isActive:
|
||||
location.pathname.startsWith("/record-management/userRecords") &&
|
||||
new URLSearchParams(location.search).get("from") !== "collaborations",
|
||||
isPrimary: true,
|
||||
isVissible: true,
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
label: t("header.navigation.incoming"),
|
||||
href: incomingHref,
|
||||
isActive:
|
||||
location.pathname.startsWith("/record-management/userIncoming") ||
|
||||
location.pathname.startsWith(
|
||||
"/record-management/viewIncoming/incoming/",
|
||||
) ||
|
||||
location.pathname.startsWith(
|
||||
"/record-management/viewIncoming/internal",
|
||||
) ||
|
||||
location.pathname.startsWith("/record-management/viewIncoming/cc"),
|
||||
isPrimary: true,
|
||||
isVissible: true,
|
||||
countBadge:
|
||||
breakdownCounts.external +
|
||||
breakdownCounts.externalSmart +
|
||||
breakdownCounts.internal +
|
||||
breakdownCounts.ccUnseen +
|
||||
breakdownCounts.forYourReference,
|
||||
isUrgent: TotalUrgentDraftInternal > 0 || TotalUrgentDraftExternal > 0,
|
||||
},
|
||||
{
|
||||
icon: CheckSquare,
|
||||
label: t("header.navigation.approval"),
|
||||
href: "/record-management/approval",
|
||||
isActive:
|
||||
location.pathname.startsWith("/record-management/approval") ||
|
||||
location.pathname.startsWith("/record-management/viewApproval"),
|
||||
isPrimary: false,
|
||||
isVissible: canViewApprovalTab,
|
||||
countBadge: breakdownCounts?.approval || 0,
|
||||
isUrgent:
|
||||
(dashboard?.activeApprovalCount?.myUrgentWorkflowCount || 0) > 0,
|
||||
},
|
||||
{
|
||||
icon: UserCheck,
|
||||
label: t("header.navigation.delegation"),
|
||||
href: "/record-management/delegation",
|
||||
isActive: location.pathname.startsWith("/record-management/delegation"),
|
||||
isPrimary: false,
|
||||
isVissible: canViewDelegationTab,
|
||||
},
|
||||
{
|
||||
icon: Handshake,
|
||||
label: t("header.navigation.collaborations"),
|
||||
href: "/record-management/collaborations",
|
||||
isActive:
|
||||
location.pathname.startsWith("/record-management/collaborations") ||
|
||||
new URLSearchParams(location.search).get("from") === "collaborations",
|
||||
isPrimary: false,
|
||||
isVissible: canViewDelegationTab,
|
||||
countBadge: collaborationDraftCount,
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
label: t("header.navigation.settings"),
|
||||
href: "/record-management/uploadTeeterandSignature",
|
||||
isActive: location.pathname.startsWith(
|
||||
"/record-management/uploadTeeterandSignature",
|
||||
),
|
||||
isPrimary: false,
|
||||
isVissible: canViewDelegationTab,
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
location.pathname,
|
||||
location.search,
|
||||
TotalDraftCC,
|
||||
TotalDraftInternal,
|
||||
TotalDraftExternal,
|
||||
TotalUrgentDraftInternal,
|
||||
TotalUrgentDraftExternal,
|
||||
canViewApprovalTab,
|
||||
canViewDelegationTab,
|
||||
useParentCounts,
|
||||
incomingHref,
|
||||
breakdownCounts.approval,
|
||||
breakdownCounts.ccUnseen,
|
||||
breakdownCounts.external,
|
||||
breakdownCounts.externalSmart,
|
||||
breakdownCounts.forYourReference,
|
||||
breakdownCounts.internal,
|
||||
dashboard?.activeApprovalCount?.myNotUrgentWorkflowsCount,
|
||||
dashboard?.activeApprovalCount?.myUrgentWorkflowCount,
|
||||
collaborationDraftCount,
|
||||
],
|
||||
);
|
||||
|
||||
const recordOfficerNavs = useMemo(
|
||||
(): INavTabs[] => [
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
label: t("header.navigation.dashboard"),
|
||||
href: "/record-management/dashboard",
|
||||
isActive: location.pathname.includes("/dashboard"),
|
||||
isVissible: true,
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
label: t("header.navigation.outgoing"),
|
||||
href: "/record-management/recordOfficer/outgoing",
|
||||
isActive:
|
||||
location.pathname.startsWith(
|
||||
"/record-management/recordOfficer/outgoing",
|
||||
) ||
|
||||
location.pathname.includes("/record-management/view/") ||
|
||||
location.pathname.includes("/record-management/outgoingview/"),
|
||||
isVissible: true,
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
label: t("header.navigation.incoming"),
|
||||
href: "/record-management/recordOfficer/incoming",
|
||||
isActive:
|
||||
location.pathname.startsWith(
|
||||
"/record-management/recordOfficer/incoming",
|
||||
) ||
|
||||
location.pathname.includes("/record-management/viewIncoming/") ||
|
||||
location.pathname.includes("/record-management/recordViewIncoming/"),
|
||||
isVissible: true,
|
||||
countBadge: ROTotalDraftIncoming,
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: t("header.navigation.pending"),
|
||||
href: "/record-management/pending",
|
||||
isActive:
|
||||
location.pathname.startsWith("/record-management/pending") ||
|
||||
location.pathname.includes("/record-management/viewPending/"),
|
||||
isVissible: true,
|
||||
countBadge: ROPending,
|
||||
isUrgent: ROPendingUrgent > 0,
|
||||
},
|
||||
],
|
||||
[t, location.pathname, ROTotalDraftIncoming, ROPending, ROPendingUrgent],
|
||||
);
|
||||
|
||||
const activeNavigationTabs = useMemo(() => {
|
||||
if (permissionKeys.includes("can:dispatchRecords")) {
|
||||
return recordOfficerNavs;
|
||||
}
|
||||
return navigationTabs.filter((tab) => tab.isVissible);
|
||||
}, [permissionKeys, navigationTabs, recordOfficerNavs]);
|
||||
|
||||
// const { user } = useAuth();
|
||||
// console.log("User Info:", user);
|
||||
// const organizationId =
|
||||
// user?.employee && user.employee.length > 0
|
||||
// ? user.employee[0].unitId
|
||||
// : undefined;
|
||||
|
||||
// const unitId = organizationId;
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Top
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
showUserManagementShortcut={showUserManagementShortcut}
|
||||
/>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
{currentPosition ? (
|
||||
<div className="fixed top-16 left-0 right-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-800 overflow-hidden pt-1 pb-1 px-2 sm:pt-1.5 sm:pb-1.5 sm:px-4 md:px-8">
|
||||
{/* Mobile layout: Simple horizontal scrollable (up to sm) */}
|
||||
<div className="sm:hidden">
|
||||
<div className="flex items-center overflow-x-auto scrollbar-none">
|
||||
{activeNavigationTabs.map((tab, index) => (
|
||||
<NavLink
|
||||
key={`${tab.href}-${index}`}
|
||||
to={tab.href || "#"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-1 px-3 py-2 mr-2 text-xs font-medium transition-colors whitespace-nowrap relative",
|
||||
isActive
|
||||
? "text-primary-800 dark:text-white border-b-2 border-primary-600 dark:border-primary-400"
|
||||
: "text-gray-700 dark:text-gray-300 hover:text-primary-800 dark:hover:text-white",
|
||||
)
|
||||
}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="max-w-[80px] truncate">{tab.label}</span>
|
||||
|
||||
{/* Count Badge - positioned on tab */}
|
||||
{typeof tab?.countBadge === "number" &&
|
||||
tab.countBadge > 0 && (
|
||||
<span className="bg-red-500 text-white text-[10px] font-bold rounded-full px-1.5 py-0.5 ml-1">
|
||||
{tab.countBadge > 99 ? "99+" : tab.countBadge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Urgent Bell - positioned on tab */}
|
||||
{typeof tab?.isUrgent === "boolean" && tab.isUrgent && (
|
||||
<span className="relative flex h-3 w-3 ml-1">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Live indicator for Delegation */}
|
||||
{hasDelegated &&
|
||||
(tab.label?.toLowerCase() === "delegation" ||
|
||||
tab.label?.toLowerCase() === "ዉክልና") && (
|
||||
<span className="absolute -top-1 -right-1 flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop layout: All tabs in one row (sm and up) */}
|
||||
<div className="hidden sm:flex overflow-x-auto scrollbar-none w-full bg-gray-200 dark:bg-gray-800 rounded-md py-1.5">
|
||||
<div className="flex w-full mx-1.5 gap-1.5">
|
||||
{activeNavigationTabs.map((tab, index) => (
|
||||
<NavLink
|
||||
key={`${tab.href}-${index}`}
|
||||
to={tab.href || "#"}
|
||||
className={() =>
|
||||
cn(
|
||||
"flex items-center justify-center px-2.5 py-2.5 rounded-sm text-sm whitespace-nowrap flex-1 font-Urbanist relative",
|
||||
tab.isActive
|
||||
? "bg-primary-50 dark:bg-primary-900/40 text-primary-800 dark:text-white font-medium"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700",
|
||||
)
|
||||
}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-1.5 flex-shrink-0" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
{tab.label}
|
||||
|
||||
{/* Count Badge (inline) */}
|
||||
{typeof tab?.countBadge === "number" &&
|
||||
tab.countBadge > 0 && (
|
||||
<span className="bg-red-500 text-white text-xs font-semibold rounded-full px-1.5 py-0.5">
|
||||
{tab.countBadge > 99 ? "99+" : tab.countBadge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Urgent Bell (pulsing) */}
|
||||
{typeof tab?.isUrgent === "boolean" && tab.isUrgent && (
|
||||
<span className="relative flex h-4 w-4">
|
||||
{/* Pulse effect */}
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
{/* Bell icon */}
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<BellRing className="h-4 w-4 text-red-500" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Live indicator for Delegation */}
|
||||
{hasDelegated &&
|
||||
(tab.label?.toLowerCase() === "delegation" ||
|
||||
tab.label?.toLowerCase() === "ዉክልና") && (
|
||||
<span className="relative -top-0.5 -right-0.5 flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@ import {
|
||||
ClipboardList,
|
||||
Clock,
|
||||
FileText,
|
||||
Home,
|
||||
Languages,
|
||||
Key,
|
||||
LogOut,
|
||||
@@ -104,9 +103,9 @@ const Top: React.FC<HeaderProps> = ({
|
||||
const activePositionName =
|
||||
currentLanguage === "am"
|
||||
? userDetails?.employee?.[0]?.positions?.[0]?.name?.am ||
|
||||
userDetails?.employee?.[0]?.positions?.[0]?.name?.en
|
||||
userDetails?.employee?.[0]?.positions?.[0]?.name?.en
|
||||
: userDetails?.employee?.[0]?.positions?.[0]?.name?.en ||
|
||||
userDetails?.employee?.[0]?.positions?.[0]?.name?.am;
|
||||
userDetails?.employee?.[0]?.positions?.[0]?.name?.am;
|
||||
|
||||
const normalizedUserType = userDetails?.userType?.trim().toLowerCase() || "";
|
||||
const userTypeLabel =
|
||||
@@ -114,8 +113,8 @@ const Top: React.FC<HeaderProps> = ({
|
||||
? t("header.user")
|
||||
: normalizedUserType
|
||||
? normalizedUserType
|
||||
.replace(/[_-]/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
.replace(/[_-]/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
: "";
|
||||
|
||||
const roleLabel =
|
||||
@@ -123,8 +122,8 @@ const Top: React.FC<HeaderProps> = ({
|
||||
userTypeLabel ||
|
||||
(normalizedUserType
|
||||
? userDetails?.roles?.[0]?.key
|
||||
?.replace(/[:_]/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
?.replace(/[:_]/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
: "");
|
||||
|
||||
const { permissionKeys } = useUserDetail(userDetails as MeDto);
|
||||
@@ -159,57 +158,57 @@ const Top: React.FC<HeaderProps> = ({
|
||||
},
|
||||
...(moduleConfig.recordManagement
|
||||
? [
|
||||
{
|
||||
id: "recordManagement",
|
||||
label: t("nav.Record Management", "Record Management"),
|
||||
path: "/record-management/dashboard",
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "recordManagement",
|
||||
label: t("nav.Record Management", "Record Management"),
|
||||
path: "/record-management/dashboard",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(moduleConfig.performance
|
||||
? [
|
||||
{
|
||||
id: "performanceManagement",
|
||||
label: t("nav.PerformanceManagement", "Performance Management"),
|
||||
path: "/performance-management/plan-years",
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "performanceManagement",
|
||||
label: t("nav.PerformanceManagement", "Performance Management"),
|
||||
path: "/performance-management/plan-years",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(moduleConfig.objective
|
||||
? [
|
||||
{
|
||||
id: "objectiveManagement",
|
||||
label: t("nav.objectiveManagement", "Objective Management"),
|
||||
path: "/objective-management/plan-years",
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "objectiveManagement",
|
||||
label: t("nav.objectiveManagement", "Objective Management"),
|
||||
path: "/objective-management/plan-years",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(moduleConfig.dms
|
||||
? [
|
||||
{
|
||||
id: "documentManagement",
|
||||
label: t("nav.DocumentManagement", "Document Management"),
|
||||
path: "/dms/dashboard",
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "documentManagement",
|
||||
label: t("nav.DocumentManagement", "Document Management"),
|
||||
path: "/dms/dashboard",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isOrgAdmin && moduleConfig.siteManagement
|
||||
? [
|
||||
{
|
||||
id: "orgAdmin",
|
||||
label: t("nav.admin", "Admin"),
|
||||
path: "/user-management/user_management-dashboard",
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "orgAdmin",
|
||||
label: t("nav.admin", "Admin"),
|
||||
path: "/user-management/user_management-dashboard",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSuperAdmin
|
||||
? [
|
||||
{
|
||||
id: "superAdmin",
|
||||
label: t("OrganizationAdmin", "Super Admin"),
|
||||
path: "/user-management/dashboard",
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "superAdmin",
|
||||
label: t("OrganizationAdmin", "Super Admin"),
|
||||
path: "/user-management/dashboard",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -256,10 +255,28 @@ const Top: React.FC<HeaderProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-24 flex-col">
|
||||
<header className="fixed left-0 right-0 top-0 z-40 h-16 border-b border-primary-100/70 bg-white/90 shadow-sm backdrop-blur-md dark:border-gray-800 dark:bg-gray-900/90">
|
||||
<div className="flex h-full items-center justify-between px-2 sm:px-4 md:px-6">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 md:gap-3">
|
||||
<header className="sticky top-0 z-40 h-14 shrink-0 border-b border-primary-100/70 bg-white/90 shadow-sm backdrop-blur-md dark:border-gray-800 dark:bg-gray-900/90">
|
||||
<div className="flex h-full items-center justify-between px-2 sm:px-4 md:px-6">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 md:gap-3">
|
||||
{onToggleSidebar ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleSidebar}
|
||||
className="h-9 w-9 flex-shrink-0 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40"
|
||||
aria-label="Toggle sidebar"
|
||||
title="Toggle sidebar"
|
||||
>
|
||||
<FiMenu className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Toggle sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -320,191 +337,183 @@ const Top: React.FC<HeaderProps> = ({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<div className="hidden min-w-0 flex-1 sm:block sm:max-w-[34vw] lg:max-w-[41vw] xl:max-w-[48vw]">
|
||||
<DynamicBreadcrumb />
|
||||
</div>
|
||||
|
||||
{showUserManagementShortcut && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex h-9 w-9 sm:w-auto items-center justify-center sm:justify-start gap-1.5 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 px-0 sm:px-3"
|
||||
onClick={() => navigate("/user-management")}
|
||||
>
|
||||
<User className="h-4 w-4 shrink-0 text-primary-500" />
|
||||
<span className="hidden sm:inline text-xs font-semibold">
|
||||
{t("dashboard.userManagement", "User Management")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="hidden min-w-0 flex-1 sm:block sm:max-w-[34vw] lg:max-w-[41vw] xl:max-w-[48vw]">
|
||||
<DynamicBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-[56vw] flex-shrink-0 items-center space-x-1.5 sm:max-w-none sm:space-x-2.5">
|
||||
{showUserManagementShortcut && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex h-9 w-9 sm:w-auto items-center justify-center sm:justify-start gap-1.5 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 px-0 sm:px-3"
|
||||
onClick={() => navigate("/user-management")}
|
||||
>
|
||||
<User className="h-4 w-4 shrink-0 text-primary-500" />
|
||||
<span className="hidden sm:inline text-xs font-semibold">
|
||||
{t("dashboard.userManagement", "User Management")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-[56vw] flex-shrink-0 items-center space-x-1.5 sm:max-w-none sm:space-x-2.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
|
||||
aria-label={isDarkMode ? "Light mode" : "Dark mode"}
|
||||
onClick={toggleDarkMode}
|
||||
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{canActivateUsers && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
|
||||
aria-label={t("header.notifications")}
|
||||
onClick={() => setPendingUsers((prev) => !prev)}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{pendingUsersCount > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 rounded-full bg-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow">
|
||||
{pendingUsersCount > 99 ? "99+" : pendingUsersCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{openPendingUsers && (
|
||||
<div
|
||||
ref={pendingUsersRef}
|
||||
className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-y-auto rounded-2xl bg-white p-3 shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:max-h-96 sm:w-80 sm:p-4"
|
||||
>
|
||||
<UserApprovalDropdown />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
|
||||
aria-label={isDarkMode ? "Light mode" : "Dark mode"}
|
||||
onClick={toggleDarkMode}
|
||||
title={
|
||||
isDarkMode ? "Switch to light mode" : "Switch to dark mode"
|
||||
className={cn(
|
||||
"relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10",
|
||||
openNotifications &&
|
||||
"bg-primary-50 text-primary-700 ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60",
|
||||
)}
|
||||
aria-label={t("header.notifications")}
|
||||
onClick={() =>
|
||||
setOpenNotifications((prev) => {
|
||||
const next = !prev;
|
||||
if (next) refresh();
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
<FiBell
|
||||
className={cn(
|
||||
"h-4 w-4 transition-colors",
|
||||
openNotifications &&
|
||||
"fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300",
|
||||
)}
|
||||
/>
|
||||
{unseenCount > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 min-w-[18px] rounded-full bg-red-500 px-1.5 py-0.5 text-center text-[10px] font-bold text-white shadow">
|
||||
{unseenCount > 99 ? "99+" : unseenCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{canActivateUsers && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
|
||||
aria-label={t("header.notifications")}
|
||||
onClick={() => setPendingUsers((prev) => !prev)}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{pendingUsersCount > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 rounded-full bg-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow">
|
||||
{pendingUsersCount > 99 ? "99+" : pendingUsersCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{openPendingUsers && (
|
||||
<div
|
||||
ref={pendingUsersRef}
|
||||
className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-y-auto rounded-2xl bg-white p-3 shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:max-h-96 sm:w-80 sm:p-4"
|
||||
>
|
||||
<UserApprovalDropdown />
|
||||
</div>
|
||||
)}
|
||||
{openNotifications && (
|
||||
<div className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:w-[24rem]">
|
||||
<NotificationList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10",
|
||||
openNotifications &&
|
||||
"bg-primary-50 text-primary-700 ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60",
|
||||
)}
|
||||
aria-label={t("header.notifications")}
|
||||
onClick={() =>
|
||||
setOpenNotifications((prev) => {
|
||||
const next = !prev;
|
||||
if (next) refresh();
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<FiBell
|
||||
{/* Reminders */}
|
||||
<div className="relative" ref={remindersRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary/10 hover:text-primary hover:ring-primary/30 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary/20 dark:hover:text-primary-300 dark:hover:ring-primary/40 sm:h-10 sm:w-10"
|
||||
aria-label="Reminders"
|
||||
onClick={() => setOpenReminders((prev) => !prev)}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{pendingCount > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 min-w-[18px] rounded-full bg-primary px-1.5 py-0.5 text-center text-[10px] font-bold text-primary-foreground shadow">
|
||||
{pendingCount > 99 ? "99+" : pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{openReminders && (
|
||||
<div className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:w-[24rem]">
|
||||
<ReminderList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Languages className="h-4 w-4 text-primary-500" />
|
||||
<span className="hidden sm:inline text-xs">
|
||||
{getUiLanguageShortLabel(currentLanguage, t)}
|
||||
</span>
|
||||
<ChevronDown className="hidden h-3.5 w-3.5 text-gray-400 sm:block" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="w-52 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
{UI_LANGUAGE_OPTIONS.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.value}
|
||||
onClick={() => changeLanguage(lang.value)}
|
||||
className={cn(
|
||||
"h-4 w-4 transition-colors",
|
||||
openNotifications &&
|
||||
"fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300",
|
||||
"flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30",
|
||||
currentLanguage === lang.value &&
|
||||
"bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white",
|
||||
)}
|
||||
/>
|
||||
{unseenCount > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 min-w-[18px] rounded-full bg-red-500 px-1.5 py-0.5 text-center text-[10px] font-bold text-white shadow">
|
||||
{unseenCount > 99 ? "99+" : unseenCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gray-100 text-[11px] font-bold text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
{getUiLanguageShortLabel(lang.value, t)}
|
||||
</span>
|
||||
<span>{getUiLanguageLabel(lang.value, t)}</span>
|
||||
</div>
|
||||
{currentLanguage === lang.value && (
|
||||
<Check className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{openNotifications && (
|
||||
<div className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:w-[24rem]">
|
||||
<NotificationList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reminders */}
|
||||
<div className="relative" ref={remindersRef}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary/10 hover:text-primary hover:ring-primary/30 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary/20 dark:hover:text-primary-300 dark:hover:ring-primary/40 sm:h-10 sm:w-10"
|
||||
aria-label="Reminders"
|
||||
onClick={() => setOpenReminders((prev) => !prev)}
|
||||
className="group h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-r from-white to-primary-50/80 p-0 text-gray-700 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:from-primary-50 hover:to-primary-100 hover:ring-primary-200 dark:from-gray-900 dark:to-gray-800 dark:text-gray-200 dark:ring-white/10 dark:hover:from-gray-800 dark:hover:to-primary-900/20 dark:hover:ring-primary-700/40 sm:h-10 sm:w-auto sm:max-w-[170px] sm:justify-start sm:gap-2 sm:rounded-2xl sm:px-1.5 sm:pr-2 md:max-w-[200px]"
|
||||
aria-label={t("header.userMenu")}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{pendingCount > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 min-w-[18px] rounded-full bg-primary px-1.5 py-0.5 text-center text-[10px] font-bold text-primary-foreground shadow">
|
||||
{pendingCount > 99 ? "99+" : pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{openReminders && (
|
||||
<div className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:w-[24rem]">
|
||||
<ReminderList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 justify-center rounded-xl bg-white/90 px-0 text-xs font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-auto sm:gap-2 sm:px-3"
|
||||
>
|
||||
<Languages className="h-4 w-4 text-primary-500" />
|
||||
<span className="hidden md:inline">
|
||||
{getUiLanguageLabel(currentLanguage, t)}
|
||||
</span>
|
||||
<span className="hidden sm:inline md:hidden">
|
||||
{getUiLanguageShortLabel(currentLanguage, t)}
|
||||
</span>
|
||||
<ChevronDown className="hidden h-3.5 w-3.5 text-gray-400 sm:block" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="w-52 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
{UI_LANGUAGE_OPTIONS.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.value}
|
||||
onClick={() => changeLanguage(lang.value)}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30",
|
||||
currentLanguage === lang.value &&
|
||||
"bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gray-100 text-[11px] font-bold text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
{getUiLanguageShortLabel(lang.value, t)}
|
||||
</span>
|
||||
<span>{getUiLanguageLabel(lang.value, t)}</span>
|
||||
</div>
|
||||
{currentLanguage === lang.value && (
|
||||
<Check className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-r from-white to-primary-50/80 p-0 text-gray-700 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:from-primary-50 hover:to-primary-100 hover:ring-primary-200 dark:from-gray-900 dark:to-gray-800 dark:text-gray-200 dark:ring-white/10 dark:hover:from-gray-800 dark:hover:to-primary-900/20 dark:hover:ring-primary-700/40 sm:h-10 sm:w-auto sm:max-w-[170px] sm:justify-start sm:gap-2 sm:rounded-2xl sm:px-1.5 sm:pr-2 md:max-w-[200px]"
|
||||
aria-label={t("header.userMenu")}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
<div
|
||||
className="
|
||||
flex h-8 w-8 sm:h-9 sm:w-9
|
||||
items-center justify-center
|
||||
rounded-full
|
||||
@@ -517,93 +526,92 @@ const Top: React.FC<HeaderProps> = ({
|
||||
ring-2 ring-white dark:ring-gray-900
|
||||
shrink-0
|
||||
"
|
||||
>
|
||||
{initials.toUpperCase()}
|
||||
</div>
|
||||
<div className="hidden min-w-0 flex-col items-start text-left lg:flex">
|
||||
<span className="max-w-[118px] truncate text-xs font-semibold leading-none">
|
||||
{fullName}
|
||||
</span>
|
||||
<span className="max-w-[118px] truncate pt-1 text-[10px] font-medium leading-none text-gray-500 dark:text-gray-400">
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="hidden h-4 w-4 text-gray-400 transition group-hover:text-primary-600 dark:group-hover:text-primary-300 sm:block" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="w-64 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
|
||||
align="end"
|
||||
>
|
||||
<div className="mb-1 rounded-lg bg-gradient-to-r from-primary-500 to-primary-600 p-3 text-white">
|
||||
<p className="truncate text-sm font-semibold">{fullName}</p>
|
||||
<p className="truncate pt-1 text-xs text-white/90">
|
||||
{roleLabel}
|
||||
</p>
|
||||
>
|
||||
{initials.toUpperCase()}
|
||||
</div>
|
||||
<div className="hidden min-w-0 flex-col items-start text-left lg:flex">
|
||||
<span className="max-w-[118px] truncate text-xs font-semibold leading-none">
|
||||
{fullName}
|
||||
</span>
|
||||
<span className="max-w-[118px] truncate pt-1 text-[10px] font-medium leading-none text-gray-500 dark:text-gray-400">
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="hidden h-4 w-4 text-gray-400 transition group-hover:text-primary-600 dark:group-hover:text-primary-300 sm:block" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="w-64 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
|
||||
align="end"
|
||||
>
|
||||
<div className="mb-1 rounded-lg bg-gradient-to-r from-primary-500 to-primary-600 p-3 text-white">
|
||||
<p className="truncate text-sm font-semibold">{fullName}</p>
|
||||
<p className="truncate pt-1 text-xs text-white/90">
|
||||
{roleLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/profile")}
|
||||
>
|
||||
<User className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("header.viewProfile")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/update-profile")}
|
||||
>
|
||||
<UserPen className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("header.editProfile")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/change-password")}
|
||||
>
|
||||
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("header.changePassword")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{showRecordManagementShortcut && (
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/profile")}
|
||||
onClick={() => navigate("/record-management/dashboard")}
|
||||
>
|
||||
<User className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("header.viewProfile")}</span>
|
||||
<FileText className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("nav.Record Management")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{showUserManagementShortcut && (
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/update-profile")}
|
||||
onClick={() => navigate("/user-management")}
|
||||
>
|
||||
<UserPen className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("header.editProfile")}</span>
|
||||
<UsersRound className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>
|
||||
{t("dashboard.userManagement", "User Management")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/change-password")}
|
||||
>
|
||||
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("header.changePassword")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
{showRecordManagementShortcut && (
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/record-management/dashboard")}
|
||||
>
|
||||
<FileText className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("nav.Record Management")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{showUserManagementShortcut && (
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/user-management")}
|
||||
>
|
||||
<UsersRound className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>
|
||||
{t("dashboard.userManagement", "User Management")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator className="my-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2.5 h-4 w-4" />
|
||||
<span>{t("header.signOut")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2.5 h-4 w-4" />
|
||||
<span>{t("header.signOut")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ import {
|
||||
type UsedTrainNumbers,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
import { truckTypesService, type TruckType } from "./truck-types.service";
|
||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||
import {
|
||||
wagonService,
|
||||
@@ -2082,6 +2083,36 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
truckTypes: {
|
||||
list: endpoint<void, TruckType[]>("truck-types", "list", () =>
|
||||
truckTypesService.getTruckTypes(),
|
||||
),
|
||||
|
||||
create: endpoint<Partial<TruckType>, TruckType>(
|
||||
"truck-types",
|
||||
"create",
|
||||
(payload) => truckTypesService.create(payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
|
||||
update: endpoint<{ id: string; data: Partial<TruckType> }, TruckType>(
|
||||
"truck-types",
|
||||
"update",
|
||||
({ id, data }) => truckTypesService.update(id, data).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
|
||||
remove: endpoint<string, void>(
|
||||
"truck-types",
|
||||
"remove",
|
||||
(id) => truckTypesService.delete(id).then(() => undefined),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
},
|
||||
|
||||
wagonTypes: {
|
||||
list: endpoint<void, WagonType[]>("wagon-types", "list", () =>
|
||||
wagonTypesService.getWagonTypes(),
|
||||
|
||||
@@ -22,7 +22,10 @@ export interface FirstMileBooking {
|
||||
serviceType?: { id: string; label?: string } | null;
|
||||
originYard?: { id: string; label?: string } | null;
|
||||
destinationYard?: { id: string; label?: string } | null;
|
||||
cargoType?: { id: string; label?: string } | null;
|
||||
cargoType?: { id: string; label?: string; cargoTypeName?: string; name?: string } | null;
|
||||
freightType?: string | null;
|
||||
/** Attached server-side: the train schedule this booking rides. */
|
||||
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
|
||||
/** Container lines — total container count drives how many trucks are needed. */
|
||||
bookingContainers?: Array<{
|
||||
id: string;
|
||||
@@ -68,6 +71,8 @@ export interface FirstMileRecord {
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
distanceKm?: number | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
vehicle?: FirstMileVehicle | null;
|
||||
}>;
|
||||
/** Present only when an invoice has actually been generated (not on distance). */
|
||||
@@ -95,7 +100,12 @@ export const firstMileService = {
|
||||
api.delete<void>(FM.BY_ID(id)),
|
||||
setVehicles: (
|
||||
id: string,
|
||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
vehicles: Array<{
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
}>,
|
||||
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
|
||||
setDistances: (
|
||||
id: string,
|
||||
|
||||
@@ -28,6 +28,4 @@ export const interchangeDocumentsService = {
|
||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.ACKNOWLEDGE(id), payload),
|
||||
dispute: (id: string, payload: { remarks: string }) =>
|
||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.DISPUTE(id), payload),
|
||||
cancel: (id: string) =>
|
||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.CANCEL(id), {}),
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Vendor {
|
||||
|
||||
export interface AssetAcquisition {
|
||||
id: string;
|
||||
itemName?: string | null;
|
||||
vehicleId?: string | null;
|
||||
vendorId?: string | null;
|
||||
acquisitionType: AcquisitionType;
|
||||
|
||||
@@ -85,6 +85,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
|
||||
"truck-types": URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPES,
|
||||
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
|
||||
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
||||
@@ -103,6 +104,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
|
||||
case "wagon-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
|
||||
case "truck-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPE_BY_ID(id);
|
||||
case "priority-configs":
|
||||
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
|
||||
case "service-types":
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { api } from "../auth/http";
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
export interface TruckType {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
/** Pre-fills a vehicle's capacity — capacity belongs to the type, not each truck. */
|
||||
capacityTons: number | null;
|
||||
/** False for a rigid truck (e.g. Casoni), which has no trailer plate at all. */
|
||||
hasTrailer: boolean;
|
||||
description?: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const truckTypesService = {
|
||||
async getTruckTypes() {
|
||||
const response = await api.get<ListResponse<TruckType>>('/truck-types', {
|
||||
params: { isActive: 'all', pageSize: 500 },
|
||||
});
|
||||
return asList(response.data);
|
||||
},
|
||||
create: (data: Partial<TruckType>) => api.post('/truck-types', data),
|
||||
update: (id: string, data: Partial<TruckType>) => api.patch(`/truck-types/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/truck-types/${id}`),
|
||||
};
|
||||
@@ -20,7 +20,14 @@ export interface Vehicle {
|
||||
id: string;
|
||||
plateNumber: string;
|
||||
registrationNumber: string;
|
||||
/** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */
|
||||
vehicleType: VehicleType;
|
||||
/** Truck configuration from the managed truck types. */
|
||||
truckTypeId?: string | null;
|
||||
/** Vehicle Identification Number — unique across the fleet. */
|
||||
vin?: string | null;
|
||||
/** OWNED | OUTSOURCED. */
|
||||
ownership?: string | null;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
year: number;
|
||||
|
||||
@@ -37,7 +37,7 @@ export enum FilterEnum {
|
||||
}
|
||||
|
||||
export const getOrganizations = async (
|
||||
params?: OrgQueryParams
|
||||
params?: OrgQueryParams,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/filter", {
|
||||
...{ headers: withHeaders() },
|
||||
@@ -45,29 +45,27 @@ export const getOrganizations = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const getMyAdminOrganizations = async (): Promise<AxiosResponse> => {
|
||||
//my-admin-organizations
|
||||
return axiosInstance.get("/organizations/with-admin-flag", {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
export const getOrganizationsWithAdminFlag = async (
|
||||
params?: OrgQueryParams
|
||||
params?: OrgQueryParams,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/with-admin-flag", {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
params: {
|
||||
take: 100,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
};
|
||||
export const getMyAdminOrganizations = getOrganizationsWithAdminFlag;
|
||||
|
||||
export const getOrganizationById = async (
|
||||
id: string | number
|
||||
id: string | number,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/${id}`, { headers: withHeaders() });
|
||||
};
|
||||
|
||||
export const getChildren = async (
|
||||
id: string | number
|
||||
id: string | number,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/${id}/children`, {
|
||||
headers: withHeaders(),
|
||||
@@ -76,7 +74,7 @@ export const getChildren = async (
|
||||
|
||||
export const getEmployeesUnderOrg = async (
|
||||
id: string | number,
|
||||
params?: OrgQueryParams
|
||||
params?: OrgQueryParams,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/current/${id}/employees`, {
|
||||
headers: withHeaders(),
|
||||
@@ -89,7 +87,7 @@ export const getEmployeesUnderOrg = async (
|
||||
};
|
||||
|
||||
export const getEmployeeCountByOrgId = async (
|
||||
id: string | number
|
||||
id: string | number,
|
||||
): Promise<AxiosResponse> => {
|
||||
// Try the endpoint from your curl example
|
||||
return axiosInstance.get(`/organizations/${id}/employees/count`, {
|
||||
@@ -98,13 +96,13 @@ export const getEmployeeCountByOrgId = async (
|
||||
};
|
||||
|
||||
export const createOrganization = async (
|
||||
data: OrganizationPayload
|
||||
data: OrganizationPayload,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post("/organizations", data, { headers: withHeaders() });
|
||||
};
|
||||
|
||||
export const activateOrganization = async (
|
||||
id: string | number
|
||||
id: string | number,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/activate`, null, {
|
||||
headers: withHeaders(),
|
||||
@@ -112,7 +110,7 @@ export const activateOrganization = async (
|
||||
};
|
||||
|
||||
export const deActivateOrganization = async (
|
||||
id: string | number
|
||||
id: string | number,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/debar`, null, {
|
||||
headers: withHeaders(),
|
||||
@@ -121,7 +119,7 @@ export const deActivateOrganization = async (
|
||||
|
||||
export const updateOrganization = async (
|
||||
id: string | number,
|
||||
data: OrganizationPayload
|
||||
data: OrganizationPayload,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.put(`/organizations/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
@@ -129,7 +127,7 @@ export const updateOrganization = async (
|
||||
};
|
||||
|
||||
export const deleteOrganization = async (
|
||||
id: string | number
|
||||
id: string | number,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/organizations/${id}`, {
|
||||
headers: withHeaders(),
|
||||
@@ -137,7 +135,7 @@ export const deleteOrganization = async (
|
||||
};
|
||||
|
||||
export const softDeleteOrganization = async (
|
||||
id: string | number
|
||||
id: string | number,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.delete(`/organizations/${id}/soft`, {
|
||||
headers: withHeaders(),
|
||||
@@ -149,7 +147,7 @@ export const softDeleteOrganization = async (
|
||||
// GET /organizations/archived
|
||||
// PATCH /organizations/{id}/restore
|
||||
export const getArchivedOrganizations = async (
|
||||
params?: OrgQueryParams
|
||||
params?: OrgQueryParams,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/archived`, {
|
||||
headers: withHeaders(),
|
||||
@@ -158,7 +156,7 @@ export const getArchivedOrganizations = async (
|
||||
};
|
||||
|
||||
export const restoreOrganization = async (
|
||||
id: string | number
|
||||
id: string | number,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.patch(`/organizations/${id}/restore`, null, {
|
||||
headers: withHeaders(),
|
||||
@@ -171,20 +169,20 @@ export const getDocumentRequirements = async (): Promise<AxiosResponse> => {
|
||||
});
|
||||
};
|
||||
export const getDocumentRequirementsById = async (
|
||||
id: string
|
||||
id: string,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/documentary-requirements/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
export const getDocumentRequirementsByFilter = async (
|
||||
filter: FilterEnum
|
||||
filter: FilterEnum,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/documentary-requirements/${filter}/type`);
|
||||
};
|
||||
|
||||
export const postDocumentRequirements = async (
|
||||
data: DocumentRequirementDto
|
||||
data: DocumentRequirementDto,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`/documentary-requirements`, data, {
|
||||
headers: withHeaders(),
|
||||
@@ -193,7 +191,7 @@ export const postDocumentRequirements = async (
|
||||
|
||||
export const giveResponse = async (
|
||||
id: string,
|
||||
data: ResponseActionDto
|
||||
data: ResponseActionDto,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post(`user-documents/${id}/response`, data, {
|
||||
headers: withHeaders(),
|
||||
@@ -202,7 +200,7 @@ export const giveResponse = async (
|
||||
|
||||
export const getArchivedUserId = async (
|
||||
unitId: string,
|
||||
params?: OrgQueryParams
|
||||
params?: OrgQueryParams,
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/employees/archived/${unitId}/with-unit`, {
|
||||
headers: withHeaders(),
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useEffect } from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins";
|
||||
|
||||
const adminSchema = z.object({
|
||||
name: z.object({
|
||||
en: z.string().min(1, t("organization.englishNameRequired")),
|
||||
am: z.string().min(1, t("organization.amharicNameRequired")),
|
||||
}),
|
||||
username: z.string().min(3, t("organization.usernameMinLength")),
|
||||
email: z.string().email(t("organization.invalidEmail")),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")),
|
||||
organizationId: z.string().min(1, t("organization.organizationRequired")),
|
||||
/** required when the org has units (unit admin); empty only when the org
|
||||
* has no units → org admin. Enforced at submit, not in the schema. */
|
||||
unitId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AdminFormValues = z.infer<typeof adminSchema>;
|
||||
|
||||
const EMPTY_VALUES: AdminFormValues = {
|
||||
name: { en: "", am: "" },
|
||||
username: "",
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
organizationId: "",
|
||||
unitId: "",
|
||||
};
|
||||
|
||||
const RequiredMark = () => <span className="text-red-500"> *</span>;
|
||||
|
||||
interface AdminFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** null → add a new admin; set → edit this admin's profile */
|
||||
admin: OrgAdminUser | null;
|
||||
/** org whose units feed the unit-admin scope picker */
|
||||
organizationId?: string;
|
||||
/** server-side error from the last submit, shown inline */
|
||||
apiError: string | null;
|
||||
onSubmit: (values: AdminFormValues) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
/** Add-admin (org or unit scope) / edit-admin-profile modal (one form). */
|
||||
export default function AdminFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
admin,
|
||||
organizationId,
|
||||
apiError,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: AdminFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const isEdit = !!admin;
|
||||
|
||||
const form = useForm<AdminFormValues>({
|
||||
resolver: zodResolver(adminSchema),
|
||||
defaultValues: EMPTY_VALUES,
|
||||
});
|
||||
|
||||
const { organizationsResponse } = useOrganizations("Org", { take: 3000 });
|
||||
const activeOrgs = (organizationsResponse?.items ?? []).filter(
|
||||
(org) => org.status === "Active",
|
||||
);
|
||||
|
||||
const selectedOrgId = form.watch("organizationId");
|
||||
const { data: unitsResponse, isLoading: isLoadingUnits } =
|
||||
useUnit().getList(
|
||||
selectedOrgId || "",
|
||||
{ take: 300, skip: 0 },
|
||||
isOpen && !isEdit,
|
||||
);
|
||||
const units = unitsResponse?.data?.items ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
form.reset(
|
||||
admin
|
||||
? {
|
||||
...EMPTY_VALUES,
|
||||
name: {
|
||||
en: admin.name?.en ?? "",
|
||||
am: admin.name?.am ?? "",
|
||||
},
|
||||
username: admin.username ?? "",
|
||||
email: admin.email ?? "",
|
||||
phoneNumber: admin.phoneNumber ?? "",
|
||||
}
|
||||
: { ...EMPTY_VALUES, organizationId: organizationId ?? "" },
|
||||
);
|
||||
}
|
||||
}, [isOpen, admin, organizationId, form]);
|
||||
|
||||
// an org with units gets a unit admin — unit is mandatory then; only a
|
||||
// unit-less org falls through to an org admin
|
||||
const submit = form.handleSubmit((values) => {
|
||||
if (!isEdit && units.length > 0 && !values.unitId) {
|
||||
form.setError("unitId", {
|
||||
message: t("orgAdmins.form.unitRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onSubmit(values);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (isSubmitting) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.add.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? t("orgAdmins.edit.description")
|
||||
: t("orgAdmins.add.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name.en"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("orgAdmins.form.nameEn")}
|
||||
<RequiredMark />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("organization.enterEnglishName")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name.am"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("orgAdmins.form.nameAm")}
|
||||
<RequiredMark />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("organization.enterAmharicName")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("orgAdmins.form.username")}
|
||||
<RequiredMark />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("organization.enterUsername")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("orgAdmins.form.email")}
|
||||
<RequiredMark />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t("organization.emailExample")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phoneNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("orgAdmins.form.phoneNumber")}
|
||||
<RequiredMark />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder={t("organization.phoneNumberExample")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isEdit && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organizationId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("organization.organization")}
|
||||
<RequiredMark />
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue("unitId", ""); // reset unit when org changes
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"organization.selectOrganization",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{activeOrgs.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{localizedName(org.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{(isLoadingUnits || units.length > 0 || !selectedOrgId) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="unitId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("orgAdmins.form.unit")}
|
||||
<RequiredMark />
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={!selectedOrgId || isLoadingUnits}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingUnits
|
||||
? t("orgAdmins.form.loadingUnits")
|
||||
: t("orgAdmins.form.selectUnit")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{units.map((unit: any) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
{localizedName(unit.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{!isLoadingUnits && selectedOrgId && units.length === 0
|
||||
? t("orgAdmins.add.noUnitsOrgAdmin")
|
||||
: t("orgAdmins.add.inviteNote")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{apiError && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || (!isEdit && isLoadingUnits)}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isEdit
|
||||
? t("orgAdmins.edit.submit")
|
||||
: t("orgAdmins.add.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, Loader2, UserPlus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { cn } from "@/super-admin/lib/utils";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
|
||||
interface AssignExistingAdminModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** org preselected in the panel (the page's current org) */
|
||||
organizationId: string;
|
||||
/** user ids that are already admins of the page's org — shown disabled */
|
||||
existingAdminIds: string[];
|
||||
/** server-side error from the last assign attempt, shown inline */
|
||||
apiError: string | null;
|
||||
/** unitId set → grant unit-admin of that unit instead of org-admin */
|
||||
onAssign: (userId: string, organizationId: string, unitId?: string) => void;
|
||||
isAssigning: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote an existing employee to admin. Three panels like the old
|
||||
* AssignAdminDialog: pick an org (page org preselected), pick a unit (or
|
||||
* none → org admin), then pick a user — unit selection also filters the
|
||||
* employee list to that unit.
|
||||
*/
|
||||
export default function AssignExistingAdminModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
organizationId,
|
||||
existingAdminIds,
|
||||
apiError,
|
||||
onAssign,
|
||||
isAssigning,
|
||||
}: AssignExistingAdminModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [orgId, setOrgId] = useState(organizationId);
|
||||
// "" → org admin (all org users listed); set → unit admin of that unit
|
||||
const [unitId, setUnitId] = useState("");
|
||||
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
{ take: 300 },
|
||||
);
|
||||
const orgs = organizationsResponse?.items ?? [];
|
||||
|
||||
const { data: unitsResponse, isLoading: isLoadingUnits } =
|
||||
useUnit().getList(orgId, { take: 300, skip: 0 }, isOpen);
|
||||
const units = unitsResponse?.data?.items ?? [];
|
||||
|
||||
const {
|
||||
employeesResponseByOrg,
|
||||
isLoadingEmployeesByOrg,
|
||||
isErrorEmployeesByOrg,
|
||||
refetchEmployeesByOrg,
|
||||
} = useEmployees({
|
||||
organizationId: isOpen ? orgId : undefined,
|
||||
unitId: unitId || undefined,
|
||||
params: { take: 3000, skip: 0 },
|
||||
});
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const employees = employeesResponseByOrg?.items ?? [];
|
||||
if (!query) return employees;
|
||||
return employees.filter((employee: any) => {
|
||||
const name = localizedName(employee.user?.name).toLowerCase();
|
||||
const email = employee.user?.email?.toLowerCase() ?? "";
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
}, [employeesResponseByOrg, localizedName, search]);
|
||||
|
||||
const selectOrg = (id: string) => {
|
||||
setOrgId(id);
|
||||
setUnitId("");
|
||||
setSelectedUserId("");
|
||||
};
|
||||
|
||||
const selectUnit = (id: string) => {
|
||||
setUnitId(id);
|
||||
setSelectedUserId("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isAssigning) return;
|
||||
setSelectedUserId("");
|
||||
setSearch("");
|
||||
setOrgId(organizationId);
|
||||
setUnitId("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
// already-admin info only covers the page's org
|
||||
const knownAdminIds = orgId === organizationId ? existingAdminIds : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setOrgId(organizationId);
|
||||
setUnitId("");
|
||||
setSelectedUserId("");
|
||||
setSearch("");
|
||||
}
|
||||
}, [isOpen, organizationId]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("orgAdmins.assign.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("orgAdmins.assign.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{/* Step 1: organization (page org preselected) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("organization.organizations")}
|
||||
</Label>
|
||||
{isLoadingOrgs ? (
|
||||
<div className="flex h-[320px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[320px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
{orgs.map((org) => (
|
||||
<button
|
||||
key={org.id}
|
||||
type="button"
|
||||
onClick={() => selectOrg(org.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
orgId === org.id
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{localizedName(org.name)}
|
||||
</span>
|
||||
{orgId === org.id && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: unit (or none → org admin) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("orgAdmins.form.unit")}
|
||||
</Label>
|
||||
{isLoadingUnits ? (
|
||||
<div className="flex h-[320px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[320px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectUnit("")}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
!unitId
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
|
||||
)}
|
||||
>
|
||||
<span>{t("orgAdmins.form.noUnit")}</span>
|
||||
{!unitId && <Check className="ml-2 h-4 w-4 shrink-0" />}
|
||||
</button>
|
||||
{units.map((unit: any) => (
|
||||
<button
|
||||
key={unit.id}
|
||||
type="button"
|
||||
onClick={() => selectUnit(unit.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
unitId === unit.id
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{localizedName(unit.name)}
|
||||
</span>
|
||||
{unitId === unit.id && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 3: user */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("orgAdmins.assign.users")}
|
||||
</Label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("orgAdmins.assign.searchUsers")}
|
||||
/>
|
||||
{isLoadingEmployeesByOrg ? (
|
||||
<div className="flex h-[272px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : isErrorEmployeesByOrg ? (
|
||||
<div className="flex h-[272px] flex-col items-center justify-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
{t("orgAdmins.assign.loadError")}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchEmployeesByOrg()}
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[272px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
{filteredEmployees.map((employee: any) => {
|
||||
const userId = employee.user?.id;
|
||||
if (!userId) return null;
|
||||
const isAlreadyAdmin = knownAdminIds.includes(userId);
|
||||
return (
|
||||
<button
|
||||
key={userId}
|
||||
type="button"
|
||||
disabled={isAlreadyAdmin}
|
||||
onClick={() => setSelectedUserId(userId)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
isAlreadyAdmin
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: selectedUserId === userId
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{localizedName(employee.user?.name) ||
|
||||
employee.user?.email}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{employee.user?.email}
|
||||
</p>
|
||||
</div>
|
||||
{isAlreadyAdmin ? (
|
||||
<Badge variant="outline" className="ml-2 shrink-0">
|
||||
{t("orgAdmins.assign.alreadyAdmin")}
|
||||
</Badge>
|
||||
) : (
|
||||
selectedUserId === userId && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredEmployees.length === 0 && (
|
||||
<p className="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("orgAdmins.assign.noUsersFound")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isAssigning || !selectedUserId || !orgId}
|
||||
onClick={() =>
|
||||
onAssign(selectedUserId, orgId, unitId || undefined)
|
||||
}
|
||||
>
|
||||
{isAssigning ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("orgAdmins.assign.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Send,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserX,
|
||||
} from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins";
|
||||
|
||||
export const ORG_ADMIN_ROLE_KEY = "organization_admin";
|
||||
export const UNIT_ADMIN_ROLE_KEY = "unit_admin";
|
||||
|
||||
export interface AdminRoleInfo {
|
||||
isOrgAdmin: boolean;
|
||||
isUnitAdmin: boolean;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* all-admins/:id returns users who are org admins of the org OR unit admins of
|
||||
* one of its units; userRoles carries every role of the user, so match the org
|
||||
* explicitly for the org-admin grant.
|
||||
*/
|
||||
// ponytail: unit relation isn't loaded, so a unit_admin grant from another org
|
||||
// can't be told apart — acceptable, the server only returns admins of this org.
|
||||
export function getAdminRoleInfo(
|
||||
admin: OrgAdminUser,
|
||||
selectedOrgId: string,
|
||||
): AdminRoleInfo {
|
||||
const roles = admin.userRoles ?? [];
|
||||
const isOrgAdmin = roles.some(
|
||||
(r) =>
|
||||
r.role?.key === ORG_ADMIN_ROLE_KEY &&
|
||||
r.organizationId === selectedOrgId,
|
||||
);
|
||||
const unitRole = roles.find(
|
||||
(r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId,
|
||||
);
|
||||
return {
|
||||
isOrgAdmin,
|
||||
isUnitAdmin: !!unitRole,
|
||||
unitId: unitRole?.unitId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface ColumnCallbacks {
|
||||
selectedOrgId: string;
|
||||
localizedName: (name?: { am?: string; en?: string }) => string;
|
||||
onEdit: (admin: OrgAdminUser) => void;
|
||||
onResend: (admin: OrgAdminUser) => void;
|
||||
onToggleActive: (admin: OrgAdminUser) => void;
|
||||
onRemove: (admin: OrgAdminUser, roleInfo: AdminRoleInfo) => void;
|
||||
}
|
||||
|
||||
export function getOrgAdminsColumnDefn({
|
||||
selectedOrgId,
|
||||
localizedName,
|
||||
onEdit,
|
||||
onResend,
|
||||
onToggleActive,
|
||||
onRemove,
|
||||
}: ColumnCallbacks): ColumnDef<OrgAdminUser>[] {
|
||||
return [
|
||||
{
|
||||
id: "name",
|
||||
accessorFn: (row) => localizedName(row.name),
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-0 hover:bg-transparent"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{t("orgAdmins.columns.name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium text-slate-800 dark:text-slate-100">
|
||||
{localizedName(row.original.name) || "—"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.original.username}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
accessorFn: (row) => row.email ?? "",
|
||||
header: () => t("orgAdmins.columns.email"),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">{row.original.email || "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "phoneNumber",
|
||||
accessorFn: (row) => row.phoneNumber ?? "",
|
||||
header: () => t("orgAdmins.columns.phone"),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">{row.original.phoneNumber || "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
header: () => t("orgAdmins.columns.role"),
|
||||
cell: ({ row }) => {
|
||||
const info = getAdminRoleInfo(row.original, selectedOrgId);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.isOrgAdmin && (
|
||||
<Badge className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-950 dark:text-indigo-300">
|
||||
{t("orgAdmins.roleOrgAdmin")}
|
||||
</Badge>
|
||||
)}
|
||||
{info.isUnitAdmin && (
|
||||
<Badge className="bg-sky-100 text-sky-700 hover:bg-sky-100 dark:bg-sky-950 dark:text-sky-300">
|
||||
{t("orgAdmins.roleUnitAdmin")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (row) =>
|
||||
!row.hasSetPassword
|
||||
? "invited"
|
||||
: row.isActive
|
||||
? "active"
|
||||
: "inactive",
|
||||
header: () => t("orgAdmins.columns.status"),
|
||||
cell: ({ row }) => {
|
||||
const admin = row.original;
|
||||
if (!admin.hasSetPassword) {
|
||||
return (
|
||||
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 dark:bg-amber-950 dark:text-amber-300">
|
||||
{t("orgAdmins.statusInvited")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return admin.isActive ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
{t("orgAdmins.statusActive")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300">
|
||||
{t("orgAdmins.statusInactive")}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorFn: (row) => row.createdAt ?? "",
|
||||
header: () => t("orgAdmins.columns.addedOn"),
|
||||
cell: ({ row }) =>
|
||||
row.original.createdAt
|
||||
? new Date(row.original.createdAt).toLocaleDateString()
|
||||
: "—",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("orgAdmins.columns.actions"),
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const admin = row.original;
|
||||
const roleInfo = getAdminRoleInfo(admin, selectedOrgId);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(admin)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("orgAdmins.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
{!admin.hasSetPassword && (
|
||||
<DropdownMenuItem onClick={() => onResend(admin)}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{t("orgAdmins.actions.resend")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onToggleActive(admin)}>
|
||||
{admin.isActive ? (
|
||||
<>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
{t("orgAdmins.actions.deactivate")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
{t("orgAdmins.actions.activate")}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600"
|
||||
onClick={() => onRemove(admin, roleInfo)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("orgAdmins.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import {
|
||||
OrgAdminUser,
|
||||
useOrgAdmins,
|
||||
} from "@/super-admin/hooks/useOrgAdmins";
|
||||
import { OrgPicker } from "./OrgPicker";
|
||||
import {
|
||||
AdminRoleInfo,
|
||||
getOrgAdminsColumnDefn,
|
||||
} from "./OrgAdminsColumnDefn";
|
||||
import AdminFormModal, { AdminFormValues } from "./AdminFormModal";
|
||||
import AssignExistingAdminModal from "./AssignExistingAdminModal";
|
||||
|
||||
interface RemoveTarget {
|
||||
admin: OrgAdminUser;
|
||||
roleInfo: AdminRoleInfo;
|
||||
}
|
||||
|
||||
export default function OrgAdminsPage() {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const [selectedOrg, setSelectedOrg] = useState<OrganizationDto | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// modals & confirms
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editAdmin, setEditAdmin] = useState<OrgAdminUser | null>(null);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [removeTarget, setRemoveTarget] = useState<RemoveTarget | null>(null);
|
||||
const [toggleTarget, setToggleTarget] = useState<OrgAdminUser | null>(null);
|
||||
|
||||
const {
|
||||
adminsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
addAdmin,
|
||||
isAdding,
|
||||
assignAdmin,
|
||||
isAssigning,
|
||||
removeAdmin,
|
||||
isRemoving,
|
||||
resendInvite,
|
||||
toggleActive,
|
||||
isToggling,
|
||||
updateAdminProfile,
|
||||
isUpdatingProfile,
|
||||
formError,
|
||||
assignError,
|
||||
removeError,
|
||||
toggleError,
|
||||
clearErrors,
|
||||
} = useOrgAdmins(selectedOrg?.id, {
|
||||
take: pageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [selectedOrg?.id, pageSize]);
|
||||
|
||||
const admins = adminsResponse?.items ?? [];
|
||||
const adminCount = adminsResponse?.count ?? 0;
|
||||
const existingAdminIds = useMemo(
|
||||
() => admins.map((admin) => admin.id),
|
||||
[admins],
|
||||
);
|
||||
|
||||
const handleFormSubmit = (values: AdminFormValues) => {
|
||||
if (!selectedOrg) return;
|
||||
const person = {
|
||||
name: values.name,
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
phoneNumber: values.phoneNumber,
|
||||
};
|
||||
if (editAdmin) {
|
||||
updateAdminProfile(
|
||||
{ id: editAdmin.id, payload: person },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditAdmin(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
addAdmin(
|
||||
{
|
||||
organizationId: values.organizationId,
|
||||
unitId: values.unitId || undefined,
|
||||
...person,
|
||||
},
|
||||
{ onSuccess: () => setFormOpen(false) },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
unitId?: string,
|
||||
) => {
|
||||
assignAdmin(
|
||||
{ organizationId, userId, unitId },
|
||||
{ onSuccess: () => setAssignOpen(false) },
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = () => {
|
||||
if (!removeTarget || !selectedOrg) return;
|
||||
const { admin, roleInfo } = removeTarget;
|
||||
removeAdmin(
|
||||
// unit-admin-only rows go through the unit endpoint; everything else
|
||||
// defaults to org removal so a role anomaly never sends unitId: undefined
|
||||
!roleInfo.isOrgAdmin && roleInfo.unitId
|
||||
? { userId: admin.id, unitId: roleInfo.unitId }
|
||||
: { userId: admin.id, organizationId: selectedOrg.id },
|
||||
{ onSuccess: () => setRemoveTarget(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const handleResend = (admin: OrgAdminUser) => {
|
||||
if (!admin.email || !admin.phoneNumber) {
|
||||
toast.error(t("orgAdmins.toasts.missingContact"));
|
||||
return;
|
||||
}
|
||||
const toastId = toast.loading(t("orgAdmins.toasts.resending"));
|
||||
resendInvite(
|
||||
{ email: admin.email, phoneNumber: admin.phoneNumber },
|
||||
{ onSettled: () => toast.dismiss(toastId) },
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleConfirm = () => {
|
||||
if (!toggleTarget) return;
|
||||
toggleActive(
|
||||
{ id: toggleTarget.id, activate: !toggleTarget.isActive },
|
||||
{ onSuccess: () => setToggleTarget(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getOrgAdminsColumnDefn({
|
||||
selectedOrgId: selectedOrg?.id ?? "",
|
||||
localizedName: localizedName as (name?: {
|
||||
am?: string;
|
||||
en?: string;
|
||||
}) => string,
|
||||
onEdit: (admin) => {
|
||||
setEditAdmin(admin);
|
||||
setFormOpen(true);
|
||||
},
|
||||
onResend: handleResend,
|
||||
onToggleActive: setToggleTarget,
|
||||
onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedOrg?.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="px-0 space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("orgAdmins.title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("orgAdmins.subtitle")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 space-y-4">
|
||||
{/* Org selector + summary */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<OrgPicker value={selectedOrg} onChange={setSelectedOrg} />
|
||||
{selectedOrg && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950 dark:text-indigo-300"
|
||||
>
|
||||
<Users2 className="h-3.5 w-3.5" />
|
||||
{t("orgAdmins.adminsCount", { count: adminCount })}
|
||||
</Badge>
|
||||
{selectedOrg.activeEmployeeCount !== undefined && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
{t("orgAdmins.activeEmployees", {
|
||||
count: selectedOrg.activeEmployeeCount,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
selectedOrg.status === "Active"
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||
}
|
||||
>
|
||||
{selectedOrg.status}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedOrg ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-gray-300 dark:border-gray-700">
|
||||
<Building2 className="h-10 w-10 text-muted-foreground" />
|
||||
<p className="font-medium text-slate-700 dark:text-slate-200">
|
||||
{t("orgAdmins.selectOrgPrompt")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("orgAdmins.selectOrgPromptHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isError && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||
{t("orgAdmins.loadError")}
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !isError && adminCount === 0 && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300">
|
||||
{t("orgAdmins.noAdminsHint", {
|
||||
name: localizedName(selectedOrg.name),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={admins}
|
||||
tableName={t("orgAdmins.tableName")}
|
||||
toolBarPosition="right"
|
||||
itemCount={adminCount}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
nextFunction={() => setPageIndex(pageIndex + 1)}
|
||||
prevFunction={() => setPageIndex(Math.max(pageIndex - 1, 0))}
|
||||
refresh={refetch}
|
||||
isLoading={isLoading}
|
||||
extraToolbar={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAssignOpen(true)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("orgAdmins.assignExisting")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditAdmin(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("orgAdmins.addAdmin")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add / Edit modal */}
|
||||
<AdminFormModal
|
||||
isOpen={formOpen}
|
||||
onClose={() => {
|
||||
setFormOpen(false);
|
||||
setEditAdmin(null);
|
||||
clearErrors();
|
||||
}}
|
||||
admin={editAdmin}
|
||||
organizationId={selectedOrg?.id}
|
||||
apiError={formError}
|
||||
onSubmit={handleFormSubmit}
|
||||
isSubmitting={isAdding || isUpdatingProfile}
|
||||
/>
|
||||
|
||||
{/* Assign existing employee modal */}
|
||||
{selectedOrg && (
|
||||
<AssignExistingAdminModal
|
||||
isOpen={assignOpen}
|
||||
onClose={() => {
|
||||
setAssignOpen(false);
|
||||
clearErrors();
|
||||
}}
|
||||
organizationId={selectedOrg.id}
|
||||
existingAdminIds={existingAdminIds}
|
||||
apiError={assignError}
|
||||
onAssign={handleAssign}
|
||||
isAssigning={isAssigning}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remove admin confirm */}
|
||||
<AlertDialog
|
||||
open={!!removeTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRemoveTarget(null);
|
||||
clearErrors();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-lg font-bold text-red-600">
|
||||
{t("orgAdmins.confirmRemove.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("orgAdmins.confirmRemove.description", {
|
||||
name:
|
||||
localizedName(removeTarget?.admin.name) ||
|
||||
removeTarget?.admin.email,
|
||||
org: localizedName(selectedOrg?.name),
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{removeError && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||
{removeError}
|
||||
</div>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRemoving}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isRemoving}
|
||||
onClick={handleRemoveConfirm}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
{isRemoving
|
||||
? t("orgAdmins.confirmRemove.removing")
|
||||
: t("orgAdmins.actions.remove")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Activate / Deactivate confirm */}
|
||||
<AlertDialog
|
||||
open={!!toggleTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setToggleTarget(null);
|
||||
clearErrors();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-lg font-bold">
|
||||
{toggleTarget?.isActive
|
||||
? t("orgAdmins.confirmToggle.deactivateTitle")
|
||||
: t("orgAdmins.confirmToggle.activateTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("orgAdmins.confirmToggle.description", {
|
||||
name:
|
||||
localizedName(toggleTarget?.name) || toggleTarget?.email,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{toggleError && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||
{toggleError}
|
||||
</div>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isToggling}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isToggling}
|
||||
onClick={handleToggleConfirm}
|
||||
className={
|
||||
toggleTarget?.isActive
|
||||
? "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
: "bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
}
|
||||
>
|
||||
{isToggling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{toggleTarget?.isActive
|
||||
? t("orgAdmins.actions.deactivate")
|
||||
: t("orgAdmins.actions.activate")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/common/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/common/ui/command";
|
||||
import { cn } from "@/super-admin/lib/utils";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { getOrganizationsWithAdminFlag } from "@/shared/services/organizationsService";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { ORG_PICKER_KEY } from "@/super-admin/hooks/useOrgAdmins";
|
||||
|
||||
interface OrgPickerProps {
|
||||
value: OrganizationDto | null;
|
||||
onChange: (org: OrganizationDto) => void;
|
||||
}
|
||||
|
||||
/** Searchable organization combobox — server-side name search, shows admin counts. */
|
||||
export function OrgPicker({ value, onChange }: OrgPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
|
||||
const {
|
||||
data: orgsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: [ORG_PICKER_KEY, debouncedSearch],
|
||||
queryFn: async () => {
|
||||
const { data } = await getOrganizationsWithAdminFlag({
|
||||
take: 50,
|
||||
skip: 0,
|
||||
name: debouncedSearch || undefined,
|
||||
});
|
||||
return {
|
||||
count: (data?.count ?? 0) as number,
|
||||
items: (data?.items ?? []) as OrganizationDto[],
|
||||
};
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const orgs = orgsResponse?.items ?? [];
|
||||
|
||||
// default to the first org that already has admins (initial load only,
|
||||
// never while the user is searching)
|
||||
useEffect(() => {
|
||||
if (value || debouncedSearch || !orgs.length) return;
|
||||
const firstWithAdmins = orgs.find((org) => (org.adminsCount ?? 0) > 0);
|
||||
if (firstWithAdmins) onChange(firstWithAdmins);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orgsResponse]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full sm:w-[360px] justify-between font-normal"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<Building2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{value ? (
|
||||
<span className="truncate">{localizedName(value.name)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("orgAdmins.selectOrg")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[360px] p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("orgAdmins.searchOrgs")}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-sm text-red-600 dark:text-red-400">
|
||||
{t("orgAdmins.pickerError")}
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty>{t("orgAdmins.noOrgsFound")}</CommandEmpty>
|
||||
{orgs.map((org) => (
|
||||
<CommandItem
|
||||
key={org.id}
|
||||
value={org.id}
|
||||
onSelect={() => {
|
||||
onChange(org);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
value?.id === org.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{localizedName(org.name)}
|
||||
</span>
|
||||
</span>
|
||||
{(org.adminsCount ?? 0) > 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
>
|
||||
{t("orgAdmins.adminsCount", {
|
||||
count: org.adminsCount ?? 0,
|
||||
})}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||
>
|
||||
{t("orgAdmins.noAdmins")}
|
||||
</Badge>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { cn } from "@/super-admin/lib/utils";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import {
|
||||
assignOrgAdminRole,
|
||||
RemoveOrAssignOrgAdminPayload,
|
||||
} from "@/super-admin/services/api/userRoleService";
|
||||
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
||||
|
||||
interface AssignOrgAdminDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AssignOrgAdminDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AssignOrgAdminDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const [selectedOrg, setSelectedOrg] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
{ take: 300 }
|
||||
);
|
||||
|
||||
const {
|
||||
employeesResponseByOrg,
|
||||
isLoadingEmployeesByOrg: isLoadingEmployees,
|
||||
} = useEmployees({
|
||||
organizationId: selectedOrg || undefined,
|
||||
params: { take: 3000, skip: 0 },
|
||||
});
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const employees = employeesResponseByOrg?.items ?? [];
|
||||
|
||||
if (!query) return employees;
|
||||
|
||||
return employees.filter((employee) => {
|
||||
const name = localizedName(employee.user.name).toLowerCase();
|
||||
const email = employee.user.email?.toLowerCase() ?? "";
|
||||
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
}, [employeesResponseByOrg, localizedName, search]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedOrg("");
|
||||
setSelectedUser("");
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isAssigning) return;
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
setSelectedOrg(organizationId);
|
||||
setSelectedUser("");
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedOrg || !selectedUser) {
|
||||
toast.error(t("organization.allFieldsRequired", "All fields are required"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: RemoveOrAssignOrgAdminPayload = {
|
||||
organizationId: selectedOrg,
|
||||
userId: selectedUser,
|
||||
};
|
||||
|
||||
setIsAssigning(true);
|
||||
|
||||
try {
|
||||
await assignOrgAdminRole(payload);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["organizationAdmins"],
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
toast.error(t("organization.userAssignFailed"), {
|
||||
description: error?.response?.data?.message,
|
||||
});
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[750px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"organization.assignAdminToOrganization",
|
||||
"Assign admin to organization"
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"organization.assignOrgAdminInstructions",
|
||||
"Select an organization and a user to assign as its administrator."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("organization.organizations")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{isLoadingOrgs ? (
|
||||
<div className="flex h-[350px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[350px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
{organizationsResponse?.items?.map((organization) => (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleOrganizationChange(organization.id)
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
selectedOrg === organization.id
|
||||
? "bg-green-200 text-green-800 dark:bg-green-900/40 dark:text-green-300"
|
||||
: "text-gray-700 hover:bg-green-100 dark:text-gray-300 dark:hover:bg-green-900/30"
|
||||
)}
|
||||
>
|
||||
<span>{localizedName(organization.name)}</span>
|
||||
{selectedOrg === organization.id && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("organization.users")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{!selectedOrg ? (
|
||||
<div className="flex h-[350px] items-center justify-center rounded-md border border-gray-200 p-4 text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<p className="text-center text-sm">
|
||||
{t("organization.selectOrganizationFirst")}
|
||||
</p>
|
||||
</div>
|
||||
) : isLoadingEmployees ? (
|
||||
<div className="flex h-[350px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t(
|
||||
"organization.searchOrganizationUsers",
|
||||
"Search organization users"
|
||||
)}
|
||||
/>
|
||||
<ScrollArea className="h-[308px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
{filteredEmployees.map((employee) => (
|
||||
<button
|
||||
key={employee.user.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedUser(employee.user.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
selectedUser === employee.user.id
|
||||
? "bg-purple-200 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
|
||||
: "text-gray-700 hover:bg-purple-100 dark:text-gray-300 dark:hover:bg-purple-900/30"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{localizedName(employee.user.name) ||
|
||||
employee.user.email}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{employee.user.email}
|
||||
</p>
|
||||
</div>
|
||||
{selectedUser === employee.user.id && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filteredEmployees.length === 0 && (
|
||||
<p className="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("organization.noUsersFound")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isAssigning || !selectedOrg || !selectedUser}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{isAssigning && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isAssigning
|
||||
? t("organization.assigning")
|
||||
: t("organization.assignUser")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Plus, Loader2, UserPlus } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/common/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
|
||||
import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
export default function OrganizationAdmins() {
|
||||
const [pageIndex, setPageIndex] = useState(0); // starts at 0
|
||||
const pageSize = 10;
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { organizationsAdminsResponse, isLoading, isError, refetch } =
|
||||
useOrganizations("Admin", {
|
||||
take: pageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
orderBy: "createdAt",
|
||||
order: "createdAt:Desc",
|
||||
name: searchTerm || undefined,
|
||||
});
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
setPageIndex(0); // Reset to first page when search term changes
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("organization.loadingAdmins")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="text-red-500 font-medium">
|
||||
{t("organization.errorLoadingAdmins")}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
{t("organization.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-6 space-y-6 dark:bg-gray-900">
|
||||
<Card className="col-span-2 shadow-none border-none bg-transparent dark:bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-xl font-semibold dark:text-white">
|
||||
{t("organization.organizationAdmins")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 dark:bg-transparent">
|
||||
<AdvancedTable
|
||||
columns={OrganizationAdminsColumnDefn(
|
||||
localizedName as (name?: { am?: string; en?: string }) => string
|
||||
)}
|
||||
data={organizationsAdminsResponse?.items || []}
|
||||
tableName="Organization Admins"
|
||||
toolBarPosition="right"
|
||||
refresh={handleRefresh}
|
||||
onGlobalFilterChange={handleSearchChange}
|
||||
disableClientFiltering={true}
|
||||
extraToolbar={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setIsAssignDialogOpen(true)}
|
||||
className="bg-[#4A6CF7] hover:bg-[#3a5ad4] text-white px-5 py-2 rounded-md text-sm font-medium shadow-md"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
{t("organization.assignAdmin")}
|
||||
</Button>
|
||||
<Link to="/user-management/add_admin">
|
||||
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("organization.addAdmin")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
itemCount={organizationsAdminsResponse?.count || 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Assign Admin Dialog */}
|
||||
<AssignOrgAdminDialog
|
||||
isOpen={isAssignDialogOpen}
|
||||
onClose={() => setIsAssignDialogOpen(false)}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
toast.success(t("organization.adminAssignedSuccess"));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../shared/common/ui/dropdown-menu";
|
||||
import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react";
|
||||
import { Button } from "../../../shared/common/ui/button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface OrganizationAdminsActionsProps {
|
||||
rowData: OrganizationAdmin;
|
||||
}
|
||||
const OrganizationAdminsActions: React.FC<OrganizationAdminsActionsProps> = ({
|
||||
rowData,
|
||||
}) => {
|
||||
const handleDeleteClick = () => {
|
||||
toast.warning("Delete functionality not implemented yet");
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
toast.info(`Admin status change to ${status} not implemented yet`);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to={`/add_admin?id=${rowData.id}`}
|
||||
className="flex items-center w-full"
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{rowData.status !== "active" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange("active")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
Activate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{rowData.status === "active" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange("inactive")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
Deactivate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick()}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationAdminsActions;
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "../../../shared/common/ui/button";
|
||||
import { ArrowUpDown, Eye } from "lucide-react";
|
||||
import { OrganizationAdminsDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { StatusCell } from "./StatusCell";
|
||||
import { t } from "i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { Link } from "react-router-dom";
|
||||
import OrganizationAdminsActions from "./OrganizationAdminsActions";
|
||||
|
||||
export const OrganizationAdminsColumnDefn = (
|
||||
localizedName: (name?: { am?: string; en?: string }) => string,
|
||||
): ColumnDef<OrganizationAdminsDto>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="p-0 hover:bg-transparent text-gray-700 dark:text-gray-300"
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{t("organization.name")}
|
||||
<ArrowUpDown className="p-0 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{localizedName(row.original.name)}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="p-0 hover:bg-transparent text-gray-700 dark:text-gray-300"
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{t("dashboard.Status")}
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id;
|
||||
return (
|
||||
<StatusCell
|
||||
id={id}
|
||||
// isAssigned={row.original.isAssigned}
|
||||
adminsCount={row.original.adminsCount}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: t("organization.actions") || "Actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<Link to={`/super-admin/organizations/${row.original.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<OrganizationAdminsActions rowData={row.original as any} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import {
|
||||
assignOrganizationAdmin,
|
||||
assignUnitAdmin,
|
||||
fetchAllUnitAdminById,
|
||||
} from "@/super-admin/services/api/organizationAdminService";
|
||||
import {
|
||||
assignOrgAdminRole,
|
||||
assignUnitAdminRole,
|
||||
removeOrgAdminRole,
|
||||
removeUnitAdminRole,
|
||||
} from "@/super-admin/services/api/userRoleService";
|
||||
import {
|
||||
activateUser,
|
||||
deactivateUser,
|
||||
} from "@/super-admin/services/api/userService";
|
||||
import { resendVerificationCode } from "@/shared/services/authService";
|
||||
import {
|
||||
updateProfile,
|
||||
UpdateProfilePayload,
|
||||
} from "@/user-management/services/api/employeePositionsService";
|
||||
|
||||
export interface OrgAdminUserRole {
|
||||
id: string;
|
||||
organizationId?: string | null;
|
||||
unitId?: string | null;
|
||||
role?: { key?: string } | null;
|
||||
}
|
||||
|
||||
/** User row returned by GET /organizations/all-admins/:id (userRoles.role relation included). */
|
||||
export interface OrgAdminUser {
|
||||
id: string;
|
||||
name?: { am?: string; en?: string };
|
||||
username?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
isActive: boolean;
|
||||
hasSetPassword: boolean;
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
userRoles?: OrgAdminUserRole[];
|
||||
}
|
||||
|
||||
export const ORG_ADMINS_KEY = "orgAdmins";
|
||||
export const ORG_PICKER_KEY = "orgAdminsOrgPicker";
|
||||
|
||||
export interface RemoveAdminInput {
|
||||
userId: string;
|
||||
/** set for org-admin removal */
|
||||
organizationId?: string;
|
||||
/** set for unit-admin removal (wins over organizationId) */
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface AddAdminInput {
|
||||
organizationId: string;
|
||||
/** set → create as unit admin of this unit instead of org admin */
|
||||
unitId?: string;
|
||||
name: { am: string; en: string };
|
||||
username: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export const useOrgAdmins = (
|
||||
orgId?: string,
|
||||
params?: { take?: number; skip?: number },
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError, getErrorMessage } = useErrorHandler(t);
|
||||
|
||||
// dialog mutations surface their API errors inline, not via toast
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [assignError, setAssignError] = useState<string | null>(null);
|
||||
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||
const [toggleError, setToggleError] = useState<string | null>(null);
|
||||
|
||||
const inlineError =
|
||||
(set: (message: string | null) => void) => async (err: unknown) => {
|
||||
console.error(err);
|
||||
set(await getErrorMessage(err));
|
||||
};
|
||||
|
||||
const clearErrors = () => {
|
||||
setFormError(null);
|
||||
setAssignError(null);
|
||||
setRemoveError(null);
|
||||
setToggleError(null);
|
||||
};
|
||||
|
||||
const {
|
||||
data: adminsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: [ORG_ADMINS_KEY, orgId, params],
|
||||
queryFn: async () => {
|
||||
const { data } = await fetchAllUnitAdminById(orgId as string, params);
|
||||
return {
|
||||
count: (data?.count ?? 0) as number,
|
||||
items: (data?.items ?? []) as OrgAdminUser[],
|
||||
};
|
||||
},
|
||||
enabled: !!orgId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [ORG_ADMINS_KEY] });
|
||||
// picker + org tables show adminsCount — keep them fresh
|
||||
queryClient.invalidateQueries({ queryKey: [ORG_PICKER_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
|
||||
};
|
||||
|
||||
const { mutate: addAdmin, isPending: isAdding } = useMutation({
|
||||
mutationFn: async ({
|
||||
organizationId,
|
||||
unitId,
|
||||
...person
|
||||
}: AddAdminInput) => {
|
||||
// both iam invite endpoints create the user (employee + role +
|
||||
// SET_PASSWORD OTP in one tx); unitId decides the admin scope
|
||||
const { data } = unitId
|
||||
? await assignUnitAdmin({ unitId, ...person })
|
||||
: await assignOrganizationAdmin({ organizationId, ...person });
|
||||
return data;
|
||||
},
|
||||
onMutate: () => setFormError(null),
|
||||
onSuccess: () => {
|
||||
toast.success(t("orgAdmins.toasts.added"));
|
||||
invalidate();
|
||||
},
|
||||
onError: inlineError(setFormError),
|
||||
});
|
||||
|
||||
const { mutate: assignAdmin, isPending: isAssigning } = useMutation({
|
||||
mutationFn: async ({
|
||||
organizationId,
|
||||
userId,
|
||||
unitId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
/** set → grant unit-admin of this unit instead of org-admin */
|
||||
unitId?: string;
|
||||
}) => {
|
||||
const { data } = unitId
|
||||
? await assignUnitAdminRole({ unitId, userId })
|
||||
: await assignOrgAdminRole({ organizationId, userId });
|
||||
return data;
|
||||
},
|
||||
onMutate: () => setAssignError(null),
|
||||
onSuccess: () => {
|
||||
toast.success(t("orgAdmins.toasts.assigned"));
|
||||
invalidate();
|
||||
},
|
||||
onError: inlineError(setAssignError),
|
||||
});
|
||||
|
||||
const { mutate: removeAdmin, isPending: isRemoving } = useMutation({
|
||||
mutationFn: async ({ userId, organizationId, unitId }: RemoveAdminInput) => {
|
||||
const { data } = unitId
|
||||
? await removeUnitAdminRole({ unitId, userId })
|
||||
: await removeOrgAdminRole({
|
||||
organizationId: organizationId as string,
|
||||
userId,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onMutate: () => setRemoveError(null),
|
||||
onSuccess: () => {
|
||||
toast.success(t("orgAdmins.toasts.removed"));
|
||||
invalidate();
|
||||
},
|
||||
onError: inlineError(setRemoveError),
|
||||
});
|
||||
|
||||
const { mutate: resendInvite, isPending: isResending } = useMutation({
|
||||
mutationFn: async (payload: { email: string; phoneNumber: string }) => {
|
||||
const { data } = await resendVerificationCode(payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("orgAdmins.toasts.resent"));
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const { mutate: toggleActive, isPending: isToggling } = useMutation({
|
||||
mutationFn: async ({ id, activate }: { id: string; activate: boolean }) => {
|
||||
const { data } = activate
|
||||
? await activateUser(id)
|
||||
: await deactivateUser(id);
|
||||
return data;
|
||||
},
|
||||
onMutate: () => setToggleError(null),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success(
|
||||
variables.activate
|
||||
? t("orgAdmins.toasts.activated")
|
||||
: t("orgAdmins.toasts.deactivated"),
|
||||
);
|
||||
invalidate();
|
||||
},
|
||||
onError: inlineError(setToggleError),
|
||||
});
|
||||
|
||||
const { mutate: updateAdminProfile, isPending: isUpdatingProfile } =
|
||||
useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: string;
|
||||
payload: UpdateProfilePayload;
|
||||
}) => {
|
||||
const { data } = await updateProfile(payload, id);
|
||||
return data;
|
||||
},
|
||||
onMutate: () => setFormError(null),
|
||||
onSuccess: () => {
|
||||
toast.success(t("orgAdmins.toasts.profileUpdated"));
|
||||
invalidate();
|
||||
},
|
||||
onError: inlineError(setFormError),
|
||||
});
|
||||
|
||||
return {
|
||||
adminsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
addAdmin,
|
||||
isAdding,
|
||||
assignAdmin,
|
||||
isAssigning,
|
||||
removeAdmin,
|
||||
isRemoving,
|
||||
resendInvite,
|
||||
isResending,
|
||||
toggleActive,
|
||||
isToggling,
|
||||
updateAdminProfile,
|
||||
isUpdatingProfile,
|
||||
formError,
|
||||
assignError,
|
||||
removeError,
|
||||
toggleError,
|
||||
clearErrors,
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
|
||||
| "cargo-types"
|
||||
| "container-types"
|
||||
| "wagon-types"
|
||||
| "truck-types"
|
||||
| "priority-configs"
|
||||
| "service-types"
|
||||
| "weight-limit-rules"
|
||||
|
||||
@@ -756,8 +756,10 @@ export interface AllocationRule {
|
||||
targetZoneCode?: string | null;
|
||||
storageType?: string | null;
|
||||
isActive: boolean;
|
||||
/** Set by the API (BaseEntity); used for the list date filter. */
|
||||
createdAt?: string;
|
||||
}
|
||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
|
||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id' | 'createdAt'>;
|
||||
|
||||
export const FEE_RULE_TYPES = [
|
||||
'STORAGE_FEE',
|
||||
@@ -813,8 +815,10 @@ export interface FeeRule {
|
||||
tiers?: FeeRuleTier[];
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
/** Set by the API (BaseEntity); used for the list date filter. */
|
||||
createdAt?: string;
|
||||
}
|
||||
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
|
||||
export type SaveFeeRulePayload = Omit<FeeRule, 'id' | 'createdAt'>;
|
||||
|
||||
export interface FeeRuleTier {
|
||||
fromDay: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Archive,
|
||||
BarChart,
|
||||
@@ -8,28 +8,48 @@ import {
|
||||
FileText,
|
||||
Globe,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Users2,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { usePermissions } from "@/shared/context/PermissionContext";
|
||||
|
||||
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[];
|
||||
permissions?: string[];
|
||||
isPrimary?: boolean;
|
||||
displayLabel?: string;
|
||||
children?: MenuItem[];
|
||||
/** 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[] = [
|
||||
@@ -38,282 +58,193 @@ export const AppMenuTabs = () => {
|
||||
href: "/user-management/dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: true,
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "organizations",
|
||||
href: "/user-management/organizations",
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: true,
|
||||
group: "Organizations",
|
||||
},
|
||||
|
||||
{
|
||||
label: "organizationAdmins", // Shortened for mobile
|
||||
displayLabel: "organizationAdmins", // Full label for desktop
|
||||
label: "organizationAdmins",
|
||||
href: "/user-management/organization_admins",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: true,
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "externalUsers", // Shortened for mobile
|
||||
displayLabel: "externalUsers", // Full label for desktop
|
||||
label: "externalUsers",
|
||||
href: "/user-management/external_users",
|
||||
icon: <Users2 className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: true,
|
||||
group: "Organizations",
|
||||
},
|
||||
{
|
||||
label: "dashboard",
|
||||
href: "/user-management/user_management-dashboard",
|
||||
icon: <BarChart className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
isPrimary: true,
|
||||
group: "Overview",
|
||||
},
|
||||
{
|
||||
label: "userManagement",
|
||||
href: "/user-management/user_management",
|
||||
icon: <UsersRound className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
isPrimary: true,
|
||||
group: "Overview",
|
||||
},
|
||||
// {
|
||||
// label: "userPositionApproval",
|
||||
// href: "/user-management/user-position-approval",
|
||||
// icon: <UsersRound className="h-4 w-4" />,
|
||||
// roles: ["admin", "organization_admin", "unit_admin"],
|
||||
// permissions: ["can:activateEmployee"],
|
||||
// isPrimary: true,
|
||||
// },
|
||||
// {
|
||||
// label: "All Records",
|
||||
// displayLabel: "All Records",
|
||||
// href: "/user-management/all-records",
|
||||
// icon: <FileText className="h-4 w-4" />,
|
||||
// roles: ["admin", "organization_admin", "unit_admin"],
|
||||
// permissions: ["can:canViewAllRecords"],
|
||||
// isPrimary: true,
|
||||
// },
|
||||
{
|
||||
label: "contentManagement", // Shortened for mobile
|
||||
displayLabel: "contentManagement", // Full label for desktop
|
||||
label: "contentManagement",
|
||||
href: "/user-management/content-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
isPrimary: true,
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "webManagement",
|
||||
displayLabel: "webManagement",
|
||||
href: "/user-management/web-management",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: "Position",
|
||||
displayLabel: "positionTypes",
|
||||
href: "/user-management/position-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: "migratedRecords",
|
||||
displayLabel: "migratedRecords",
|
||||
href: "/user-management/migrated-records-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: "settings",
|
||||
displayLabel: "settings",
|
||||
href: "/user-management/organization-settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
isPrimary: true,
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Bulk",
|
||||
displayLabel: "bulkUpload",
|
||||
href: "/user-management/bulk-upload",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
isPrimary: true,
|
||||
group: "Content",
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
displayLabel: "Archive Users",
|
||||
href: "/user-management/archive-users",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: "Archived Organizations",
|
||||
displayLabel: "Archived Organizations",
|
||||
href: "/user-management/archived-organizations",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: "Archive Users",
|
||||
displayLabel: "Archive Users",
|
||||
href: "/user-management/archives",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: "Archived Units & Positions",
|
||||
displayLabel: "Archived Units & Positions",
|
||||
href: "/user-management/archived",
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
roles: ["admin", "organization_admin", "unit_admin"],
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: "Sector Reports",
|
||||
displayLabel: "Sector Reports",
|
||||
href: "/user-management/sector-reports",
|
||||
icon: <ChartAreaIcon className="h-5 w-5" />,
|
||||
roles: ["unit_admin", "admin", "organization_admin"],
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: "activityLog",
|
||||
href: "/user-management/activity_log",
|
||||
icon: <ClipboardList className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: false,
|
||||
},
|
||||
{
|
||||
label: "setting",
|
||||
href: "/user-management/settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: false,
|
||||
},
|
||||
{
|
||||
label: "Letter Template",
|
||||
href: "/user-management/templates",
|
||||
label: "Position",
|
||||
href: "/user-management/position-management",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
roles: ["super_admin"],
|
||||
isPrimary: false,
|
||||
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"],
|
||||
isPrimary: true,
|
||||
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",
|
||||
},
|
||||
|
||||
// {
|
||||
// label: "branding.title",
|
||||
// href: "/user-management/web-management/Branding/Branding",
|
||||
// icon: <ShieldAlert className="h-4 w-4" />,
|
||||
// roles: ["super_admin"],
|
||||
// isPrimary: true,
|
||||
// },
|
||||
];
|
||||
|
||||
const { permissions } = usePermissions();
|
||||
const { t } = useTranslation();
|
||||
const filteredMenu = menuItems.filter((item) =>
|
||||
item?.roles?.some((r) => userRoles.includes(r)),
|
||||
);
|
||||
const primaryMenuItems = filteredMenu.filter(
|
||||
(item) => item.isPrimary !== false,
|
||||
);
|
||||
const secondaryMenuItems = filteredMenu.filter(
|
||||
(item) => item.isPrimary === false,
|
||||
item.roles?.some((r) => userRoles.includes(r)),
|
||||
);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
// Sticky (not fixed) so it stays in flow: content below never needs a
|
||||
// magic offset matching this bar's responsive height. top-16 keeps it
|
||||
// pinned just below the fixed 64px <Top> header while scrolling.
|
||||
// -mt-8 cancels the excess of <Top>'s in-flow h-24 wrapper over its 64px
|
||||
// fixed header, so the bar sits flush under the header with no jump.
|
||||
// shrink-0 is load-bearing: as a flex item with overflow-hidden this bar
|
||||
// would otherwise be flex-squashed to zero height when the page overflows.
|
||||
<div className="sticky top-16 -mt-8 z-30 shrink-0 bg-white dark:bg-gray-900 border-b dark:border-gray-800 overflow-hidden flex flex-col">
|
||||
{/* Mobile View - Two separate rows */}
|
||||
<div className="md:hidden flex flex-col pt-5 pb-3 px-4 space-y-3 mt-2">
|
||||
{/* Primary items row */}
|
||||
<div className="flex items-center overflow-x-auto scrollbar-none">
|
||||
{primaryMenuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
isActive
|
||||
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
|
||||
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="max-w-[80px] truncate">
|
||||
{t(`organization.${item.label}`, item.label)}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{GROUP_ORDER.map((group) => {
|
||||
const items = filteredMenu.filter((item) => item.group === group);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
{/* Secondary items row (if any) */}
|
||||
{secondaryMenuItems.length > 0 && (
|
||||
<div className="flex items-center overflow-x-auto scrollbar-none border-t dark:border-gray-800 pt-3">
|
||||
{secondaryMenuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
isActive
|
||||
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
|
||||
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="max-w-[80px] truncate">
|
||||
{t(`organization.${item.label}`, item.label)}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop View - Single row with all items */}
|
||||
<div className="hidden md:flex pt-5 pb-3 px-8 mt-1">
|
||||
<div className="flex items-center gap-2 overflow-x-auto scrollbar-none">
|
||||
{filteredMenu.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
isActive
|
||||
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
|
||||
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="max-w-full truncate">
|
||||
{t(`organization.${item.label}`, item.label)}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,92 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { AppMenuTabs } from "./AppMenuTabs";
|
||||
import { useSidebar } from "@/shared/common/ui/sidebar";
|
||||
import Top from "@/record-management/components/common/Top";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
|
||||
export const AppLayout = () => {
|
||||
const { pathname } = useLocation();
|
||||
const isAuthPage = pathname === "/";
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { user } = useAuth();
|
||||
|
||||
const userRoles = user?.roles?.map((role) => role.key) || [];
|
||||
const isSuperAdmin = userRoles.includes("super_admin");
|
||||
|
||||
// html/body/#root are `overflow: hidden` (index.css) — the host chrome
|
||||
// scrolls inside FreightDashboardLayout. This subtree renders its own
|
||||
// full-page layout instead, so it must be its own scroll container or
|
||||
// nothing scrolls. Top/AppMenuTabs are `fixed`, unaffected by the scroller.
|
||||
if (isAuthPage) {
|
||||
return (
|
||||
<div className="h-dvh w-full overflow-y-auto bg-gray-50">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// <Top> renders its own in-flow h-24 wrapper around the fixed 64px header,
|
||||
// so flow already clears the header — no extra top padding here.
|
||||
// AppMenuTabs is sticky and in flow, so content starts right below it.
|
||||
<div className="w-full h-dvh overflow-y-auto flex flex-col bg-background text-foreground">
|
||||
<Top onToggleSidebar={toggleSidebar} showRecordManagementShortcut={!isSuperAdmin} />
|
||||
<AppMenuTabs />
|
||||
<div className="px-2 pb-2 pt-4 sm:px-4 sm:pb-4 sm:pt-6 flex-1">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { AppMenuTabs } from "./AppMenuTabs";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/shared/common/ui/sidebar";
|
||||
import Top from "@/record-management/components/common/Top";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
|
||||
export const AppLayout = () => {
|
||||
const { pathname } = useLocation();
|
||||
const isAuthPage = pathname === "/";
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { user } = useAuth();
|
||||
|
||||
const userRoles = user?.roles?.map((role) => role.key) || [];
|
||||
const isSuperAdmin = userRoles.includes("super_admin");
|
||||
|
||||
// Standalone full-page scroll container for the auth screen — the host
|
||||
// chrome is `overflow: hidden`, so this subtree must scroll itself.
|
||||
if (isAuthPage) {
|
||||
return (
|
||||
<div className="h-dvh w-full overflow-y-auto bg-gray-50">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Full-height sidebar owning the left column (back-to-home + brand at the
|
||||
top). <Top> lives inside <SidebarInset>, to the right of the sidebar,
|
||||
and is `sticky` — so it never overlaps the sidebar and re-flows when
|
||||
the sidebar collapses to its icon rail. Top's burger (onToggleSidebar)
|
||||
drives collapse on desktop and the Sheet drawer on mobile. */}
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="gap-1 border-b border-sidebar-border">
|
||||
<Link
|
||||
to="/dashboard/overview"
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4 shrink-0" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
Back to home
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/user-management"
|
||||
className="flex items-center gap-2 px-1 py-2.5"
|
||||
>
|
||||
<img
|
||||
src="/assets/logo.svg"
|
||||
alt="EDR"
|
||||
className="size-7 shrink-0 object-contain"
|
||||
/>
|
||||
<div className="flex flex-col group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-sm font-semibold leading-tight text-sidebar-foreground">
|
||||
User Management
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
EDR Freight
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="pb-10">
|
||||
<AppMenuTabs />
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
{/* Internal scroll container: the sidebar stays fixed and full-height
|
||||
while this column scrolls beneath the sticky <Top> bar. */}
|
||||
<SidebarInset className="h-svh min-w-0 overflow-y-auto">
|
||||
<Top
|
||||
onToggleSidebar={toggleSidebar}
|
||||
showRecordManagementShortcut={!isSuperAdmin}
|
||||
/>
|
||||
<div className="flex-1 pb-2 sm:px-4 sm:pb-4 ">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
||||
import { t } from "i18next";
|
||||
|
||||
type ActionsColumnProps = {
|
||||
row: PositionTypeDto;
|
||||
};
|
||||
|
||||
const ActionsColumn: React.FC<ActionsColumnProps> = ({ row }) => {
|
||||
const navigate = useNavigate();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const { deletePositionType } = usePositionTypes({ id: "" });
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setDeletingId(id);
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deletingId) return;
|
||||
await deletePositionType.mutateAsync(deletingId);
|
||||
setOpenDialog(false);
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(`/user-management/position-management/edit/${row.id}`)
|
||||
}
|
||||
>
|
||||
{t("common.Edit")}
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={openDialog} onOpenChange={setOpenDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick(row.id)}
|
||||
>
|
||||
{t("userRecord.Delete")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("contentManagement.delMsg")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("contentManagement.delMsg2")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => {
|
||||
setOpenDialog(false);
|
||||
setDeletingId(null);
|
||||
}}
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deletePositionType.isPending}
|
||||
>
|
||||
{deletePositionType.isPending
|
||||
? t("organization.deleting")
|
||||
: t("organization.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionsColumn;
|
||||
@@ -1,455 +1,529 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { toast } from "sonner";
|
||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { SingleSelect } from "@/shared/common/ui/single-select";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import i18n from "@/i18n";
|
||||
import { PermissionSearch } from "./PermissionSearch";
|
||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const formSchema = z.object({
|
||||
nameAm: z.string().min(2),
|
||||
nameEn: z.string().min(2),
|
||||
permissions: z.array(z.string()),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export interface CreatePositionFormProps {
|
||||
mode?: "create" | "edit";
|
||||
positionTypeId?: string;
|
||||
initialValues?: {
|
||||
nameAm: string;
|
||||
nameEn: string;
|
||||
unitId: string;
|
||||
key?: string;
|
||||
};
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const CreatePositionForm = ({
|
||||
mode = "create",
|
||||
positionTypeId,
|
||||
initialValues,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: CreatePositionFormProps = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
createPositionType,
|
||||
updatePositionType,
|
||||
positionTypes,
|
||||
isLoading: isLoadingPositionTypes,
|
||||
} = usePositionTypes();
|
||||
const { user } = useAuth();
|
||||
const { getList, getById } = useUnit();
|
||||
const localizedName = useLocalizedName();
|
||||
const userOrganizationId =
|
||||
user?.employee && user.employee.length > 0
|
||||
? user.employee[0].organizationId
|
||||
: undefined;
|
||||
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string>(
|
||||
userOrganizationId ?? "",
|
||||
);
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>(
|
||||
initialValues?.unitId ?? "",
|
||||
);
|
||||
const [selectedApplicationId, setSelectedApplicationId] =
|
||||
useState<string>("");
|
||||
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
|
||||
const hasLoadedEditData = useRef(false);
|
||||
const lang = i18n.language;
|
||||
const { applications, isLoading: isLoadingApplications } = useApplications();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
{ take: 3000 },
|
||||
);
|
||||
|
||||
const { data: unitsResponse, isLoading: isLoadingUnits } = getList(
|
||||
selectedOrganizationId,
|
||||
{ take: 3000, skip: 0 },
|
||||
);
|
||||
|
||||
const organizationOptions = useMemo(
|
||||
() =>
|
||||
(organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({
|
||||
value: org.id,
|
||||
label: localizedName(org.name) || org.id,
|
||||
})),
|
||||
[organizationsResponse, localizedName],
|
||||
);
|
||||
|
||||
const unitOptions = useMemo(
|
||||
() =>
|
||||
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({
|
||||
value: unit.id,
|
||||
label: localizedName(unit.name) || unit.id,
|
||||
})),
|
||||
[unitsResponse, localizedName],
|
||||
);
|
||||
|
||||
const {
|
||||
data: editUnitResponse,
|
||||
isSuccess: isUnitSuccess,
|
||||
isError: isUnitError,
|
||||
} = getById(initialValues?.unitId ?? "");
|
||||
|
||||
const {
|
||||
data: permissionsResponse,
|
||||
isSuccess: isPermissionsSuccess,
|
||||
isError: isPermissionsError,
|
||||
} = useQuery({
|
||||
queryKey: ["position-type-permissions", positionTypeId],
|
||||
queryFn: () =>
|
||||
positionTypePermissionService.getPermissionsByPositionTypeId(
|
||||
positionTypeId!,
|
||||
),
|
||||
enabled: mode === "edit" && !!positionTypeId,
|
||||
});
|
||||
// Reset the selected unit when the organization changes so a unit from a
|
||||
// different org can't be submitted by mistake.
|
||||
useEffect(() => {
|
||||
if (mode === "edit") return;
|
||||
setSelectedUnitId("");
|
||||
}, [selectedOrganizationId, mode]);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
nameAm: initialValues?.nameAm ?? "",
|
||||
nameEn: initialValues?.nameEn ?? "",
|
||||
permissions: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
||||
if (hasLoadedEditData.current) return;
|
||||
|
||||
const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError;
|
||||
const isPermissionsDone = isPermissionsSuccess || isPermissionsError;
|
||||
|
||||
if (isUnitDone && isPermissionsDone) {
|
||||
hasLoadedEditData.current = true;
|
||||
|
||||
const unit = editUnitResponse?.data;
|
||||
if (unit) {
|
||||
setSelectedOrganizationId(unit.organizationId);
|
||||
setSelectedUnitId(unit.id);
|
||||
} else if (initialValues.unitId) {
|
||||
setSelectedUnitId(initialValues.unitId);
|
||||
}
|
||||
|
||||
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
|
||||
form.reset({
|
||||
nameAm: initialValues.nameAm,
|
||||
nameEn: initialValues.nameEn,
|
||||
permissions: ids,
|
||||
});
|
||||
|
||||
setIsLoadingEditData(false);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
initialValues,
|
||||
positionTypeId,
|
||||
isUnitSuccess,
|
||||
isUnitError,
|
||||
isPermissionsSuccess,
|
||||
isPermissionsError,
|
||||
editUnitResponse,
|
||||
permissionsResponse,
|
||||
form,
|
||||
]);
|
||||
|
||||
const handlePermissionChange = (permissionId: string, checked: boolean) => {
|
||||
const currentPermissions = form.getValues("permissions");
|
||||
if (checked) {
|
||||
form.setValue("permissions", [...currentPermissions, permissionId]);
|
||||
} else {
|
||||
form.setValue(
|
||||
"permissions",
|
||||
currentPermissions.filter((id) => id !== permissionId),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyFrom = async (positionTypeId: string) => {
|
||||
setCopyFromPositionId(positionTypeId);
|
||||
if (!positionTypeId) {
|
||||
form.setValue("permissions", []);
|
||||
return;
|
||||
}
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response =
|
||||
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
||||
positionTypeId,
|
||||
);
|
||||
const ids = response.data.items?.map((p) => p.id) ?? [];
|
||||
form.setValue("permissions", ids);
|
||||
} catch {
|
||||
toast.error(t("contentManagement.copyPermissionsFailed"));
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
if (!selectedUnitId) {
|
||||
toast.error(t("organization.selectUnit"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: {
|
||||
am: values.nameAm,
|
||||
en: values.nameEn,
|
||||
},
|
||||
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
|
||||
unitId: selectedUnitId,
|
||||
};
|
||||
|
||||
let targetId = positionTypeId;
|
||||
|
||||
if (mode === "edit" && positionTypeId) {
|
||||
await updatePositionType.mutateAsync({
|
||||
id: positionTypeId,
|
||||
data: payload,
|
||||
});
|
||||
} else {
|
||||
const response = await createPositionType.mutateAsync(payload);
|
||||
targetId = response.data.id;
|
||||
}
|
||||
|
||||
if (targetId && values.permissions.length > 0) {
|
||||
await positionTypePermissionService.assignPermissionsToPositionType({
|
||||
firstId: targetId,
|
||||
secondIds: values.permissions,
|
||||
});
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["position-type"],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["position-type-permissions"],
|
||||
});
|
||||
toast.success(t("contentManagement.permissionSuccess"));
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
navigate("/user-management/position-management");
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("contentManagement.permissionSuccess"));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingEditData) {
|
||||
return (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameEn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("contentManagement.englishName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. HR Coordinator" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameAm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("contentManagement.amharicName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. ሰብል አያት" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ✅ Organization (searchable, all orgs) */}
|
||||
<div className="mb-4 w-full">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("organization.organization") || "Organization"}
|
||||
</label>
|
||||
<SingleSelect
|
||||
options={organizationOptions}
|
||||
value={selectedOrganizationId}
|
||||
onValueChange={setSelectedOrganizationId}
|
||||
placeholder={
|
||||
isLoadingOrgs
|
||||
? t("common.loading")
|
||||
: t("organization.selectOrganization") ||
|
||||
"Select an organization"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ✅ Unit Selector — searchable, scoped to picked org */}
|
||||
<div className="mb-4 w-full">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("organization.selectUnit")}
|
||||
</label>
|
||||
<SingleSelect
|
||||
options={unitOptions}
|
||||
value={selectedUnitId}
|
||||
onValueChange={setSelectedUnitId}
|
||||
placeholder={
|
||||
!selectedOrganizationId
|
||||
? t("organization.selectOrganizationFirst") ||
|
||||
"Select an organization first"
|
||||
: isLoadingUnits
|
||||
? t("common.loading")
|
||||
: t("organization.selectUnit")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("contentManagement.selectApplication")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedApplicationId}
|
||||
onValueChange={(value) => setSelectedApplicationId(value)}
|
||||
disabled={isLoadingApplications}>
|
||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||
<SelectValue placeholder="Select an Application" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{applications?.map((app) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{lang === "en" ? app.name.en : app.name.am}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("contentManagement.copyPermissionsFrom")}
|
||||
</label>
|
||||
<Select
|
||||
value={copyFromPositionId}
|
||||
onValueChange={handleCopyFrom}
|
||||
disabled={isLoadingPositionTypes || isCopying}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isCopying
|
||||
? t("common.loading")
|
||||
: t("contentManagement.selectPositionToCopy")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{positionTypes.map((p: PositionTypeDto) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{localizedName(p.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("contentManagement.copyPermissionsHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permissions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("contentManagement.permission")}</FormLabel>
|
||||
<PermissionSearch
|
||||
selectedPermissions={field.value}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
applicationId={selectedApplicationId}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
}}>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
createPositionType.isPending || updatePositionType.isPending
|
||||
}>
|
||||
{mode === "edit" ? t("delegation.update") : t("delegation.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
invalidatePositionTypeQueries,
|
||||
usePositionTypes,
|
||||
} from "@/user-management/hooks/usePositionTypes";
|
||||
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { SingleSelect } from "@/shared/common/ui/single-select";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { PermissionSearch } from "./PermissionSearch";
|
||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export interface CreatePositionFormProps {
|
||||
mode?: "create" | "edit";
|
||||
positionTypeId?: string;
|
||||
initialValues?: {
|
||||
nameAm: string;
|
||||
nameEn: string;
|
||||
unitId: string;
|
||||
key?: string;
|
||||
};
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const CreatePositionForm = ({
|
||||
mode = "create",
|
||||
positionTypeId,
|
||||
initialValues,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: CreatePositionFormProps = {}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const {
|
||||
createPositionType,
|
||||
updatePositionType,
|
||||
positionTypes,
|
||||
isLoading: isLoadingPositionTypes,
|
||||
isError: isErrorPositionTypes,
|
||||
} = usePositionTypes();
|
||||
const { user } = useAuth();
|
||||
const { getList, getById } = useUnit();
|
||||
const localizedName = useLocalizedName();
|
||||
const userOrganizationId =
|
||||
user?.employee && user.employee.length > 0
|
||||
? user.employee[0].organizationId
|
||||
: undefined;
|
||||
const [selectedApplicationId, setSelectedApplicationId] =
|
||||
useState<string>("");
|
||||
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
|
||||
const hasLoadedEditData = useRef(false);
|
||||
// Permissions the position type had when the form opened. Needed because the
|
||||
// API cannot represent "no permissions" (see onSubmit).
|
||||
const loadedPermissionCount = useRef(0);
|
||||
const { applications, isLoading: isLoadingApplications } = useApplications();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
nameEn: z.string().trim().min(2, t("organization.englishNameRequired")),
|
||||
nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")),
|
||||
organizationId: z.string().min(1, t("organization.organizationRequired")),
|
||||
unitId: z.string().min(1, t("contentManagement.unitRequired")),
|
||||
permissions: z.array(z.string()),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
nameAm: initialValues?.nameAm ?? "",
|
||||
nameEn: initialValues?.nameEn ?? "",
|
||||
organizationId: userOrganizationId ?? "",
|
||||
unitId: initialValues?.unitId ?? "",
|
||||
permissions: [],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedOrganizationId = form.watch("organizationId");
|
||||
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
{ take: 3000 },
|
||||
);
|
||||
|
||||
const { data: unitsResponse, isLoading: isLoadingUnits } = getList(
|
||||
selectedOrganizationId,
|
||||
{ take: 3000, skip: 0 },
|
||||
);
|
||||
|
||||
const organizationOptions = useMemo(
|
||||
() =>
|
||||
(organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({
|
||||
value: org.id,
|
||||
label: localizedName(org.name) || org.id,
|
||||
})),
|
||||
[organizationsResponse, localizedName],
|
||||
);
|
||||
|
||||
const unitOptions = useMemo(
|
||||
() =>
|
||||
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({
|
||||
value: unit.id,
|
||||
label: localizedName(unit.name) || unit.id,
|
||||
})),
|
||||
[unitsResponse, localizedName],
|
||||
);
|
||||
|
||||
const {
|
||||
data: editUnitResponse,
|
||||
isSuccess: isUnitSuccess,
|
||||
isError: isUnitError,
|
||||
} = getById(initialValues?.unitId ?? "");
|
||||
|
||||
const {
|
||||
data: permissionsResponse,
|
||||
isSuccess: isPermissionsSuccess,
|
||||
isError: isPermissionsError,
|
||||
} = useQuery({
|
||||
queryKey: ["position-type-permissions", positionTypeId],
|
||||
queryFn: () =>
|
||||
positionTypePermissionService.getPermissionsByPositionTypeId(
|
||||
positionTypeId!,
|
||||
),
|
||||
enabled: mode === "edit" && !!positionTypeId,
|
||||
});
|
||||
|
||||
// A position type belongs to a unit, and a unit to an organization — IAM has
|
||||
// no organizationId on the type itself and no organization-scoped route, so
|
||||
// the picked org narrows the list through its units. isSystem types are the
|
||||
// shared "commons" and stay available to every organization.
|
||||
const orgUnitIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||
),
|
||||
[unitsResponse],
|
||||
);
|
||||
|
||||
const copyFromOptions = useMemo(() => {
|
||||
if (!selectedOrganizationId) return [];
|
||||
return positionTypes.filter(
|
||||
(type: PositionTypeDto) =>
|
||||
type.id !== positionTypeId &&
|
||||
(type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
|
||||
);
|
||||
}, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
|
||||
|
||||
// Reset the selected unit when the organization changes so a unit from a
|
||||
// different org can't be submitted by mistake. The copy source is cleared
|
||||
// too — it is scoped to the old organization.
|
||||
useEffect(() => {
|
||||
if (mode === "edit") return;
|
||||
form.setValue("unitId", "");
|
||||
setCopyFromPositionId("");
|
||||
}, [selectedOrganizationId, mode, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
||||
if (hasLoadedEditData.current) return;
|
||||
|
||||
const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError;
|
||||
const isPermissionsDone = isPermissionsSuccess || isPermissionsError;
|
||||
|
||||
if (isUnitDone && isPermissionsDone) {
|
||||
hasLoadedEditData.current = true;
|
||||
|
||||
const unit = editUnitResponse?.data;
|
||||
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
|
||||
loadedPermissionCount.current = ids.length;
|
||||
|
||||
form.reset({
|
||||
nameAm: initialValues.nameAm,
|
||||
nameEn: initialValues.nameEn,
|
||||
organizationId: unit?.organizationId ?? userOrganizationId ?? "",
|
||||
unitId: unit?.id ?? initialValues.unitId ?? "",
|
||||
permissions: ids,
|
||||
});
|
||||
|
||||
setIsLoadingEditData(false);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
initialValues,
|
||||
positionTypeId,
|
||||
isUnitSuccess,
|
||||
isUnitError,
|
||||
isPermissionsSuccess,
|
||||
isPermissionsError,
|
||||
editUnitResponse,
|
||||
permissionsResponse,
|
||||
userOrganizationId,
|
||||
form,
|
||||
]);
|
||||
|
||||
const handlePermissionChange = (permissionId: string, checked: boolean) => {
|
||||
const currentPermissions = form.getValues("permissions");
|
||||
form.setValue(
|
||||
"permissions",
|
||||
checked
|
||||
? [...currentPermissions, permissionId]
|
||||
: currentPermissions.filter((id) => id !== permissionId),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyFrom = async (sourcePositionTypeId: string) => {
|
||||
setCopyFromPositionId(sourcePositionTypeId);
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response =
|
||||
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
||||
sourcePositionTypeId,
|
||||
);
|
||||
const ids = response.data.items?.map((p) => p.id) ?? [];
|
||||
form.setValue("permissions", ids);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
toast.error(t("contentManagement.copyPermissionsFailed"));
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const payload = {
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
|
||||
unitId: values.unitId,
|
||||
};
|
||||
|
||||
// Save the position type first. If this fails nothing else runs, and the
|
||||
// mutation's own onError surfaces the reason (403 for built-in types,
|
||||
// conflict on the globally-unique key, ...).
|
||||
let targetId = positionTypeId;
|
||||
try {
|
||||
if (mode === "edit" && positionTypeId) {
|
||||
await updatePositionType.mutateAsync({
|
||||
id: positionTypeId,
|
||||
data: payload,
|
||||
});
|
||||
} else {
|
||||
const response = await createPositionType.mutateAsync(payload);
|
||||
targetId = response.data.id;
|
||||
}
|
||||
} catch {
|
||||
return; // already reported by the mutation's onError
|
||||
}
|
||||
|
||||
// assign-seconds-for-first replaces the whole set, but an empty secondIds
|
||||
// fails server-side — so "unassign everything" is not expressible. Keep the
|
||||
// save and tell the user their permissions were left alone.
|
||||
const mustClearAll =
|
||||
values.permissions.length === 0 && loadedPermissionCount.current > 0;
|
||||
|
||||
if (targetId && values.permissions.length > 0) {
|
||||
try {
|
||||
await positionTypePermissionService.assignPermissionsToPositionType({
|
||||
firstId: targetId,
|
||||
secondIds: values.permissions,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
invalidatePositionTypeQueries(queryClient);
|
||||
toast.error(t("contentManagement.permissionsAssignFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
invalidatePositionTypeQueries(queryClient);
|
||||
queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] });
|
||||
|
||||
if (mustClearAll) {
|
||||
toast.warning(t("contentManagement.cannotClearAllPermissions"));
|
||||
} else {
|
||||
toast.success(t("contentManagement.permissionSuccess"));
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
navigate("/user-management/position-management");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingEditData) {
|
||||
return (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedPermissionCount = form.watch("permissions").length;
|
||||
// form.formState.isSubmitting stays true for the whole async handler, so it
|
||||
// also covers the permission-assignment call that follows the save.
|
||||
const isBusy = form.formState.isSubmitting || isCopying;
|
||||
|
||||
const copyFromPlaceholder = !selectedOrganizationId
|
||||
? t("contentManagement.selectOrganizationToCopy")
|
||||
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||
? t("common.loading")
|
||||
: isErrorPositionTypes
|
||||
? t("contentManagement.failedToLoadPositionTypes")
|
||||
: t("contentManagement.selectPositionToCopy");
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameEn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("contentManagement.englishName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. HR Coordinator" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameAm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("contentManagement.amharicName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. ሰብል አያት" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Organization (searchable, all orgs) */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organizationId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("organization.organization")}</FormLabel>
|
||||
<SingleSelect
|
||||
options={organizationOptions}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={
|
||||
isLoadingOrgs
|
||||
? t("common.loading")
|
||||
: t("organization.selectOrganization")
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Unit Selector — searchable, scoped to picked org */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="unitId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("organization.selectUnit")}</FormLabel>
|
||||
<SingleSelect
|
||||
options={unitOptions}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={
|
||||
!selectedOrganizationId
|
||||
? t("organization.selectOrganizationFirst")
|
||||
: isLoadingUnits
|
||||
? t("common.loading")
|
||||
: t("organization.selectUnit")
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-4 w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("contentManagement.selectApplication")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedApplicationId}
|
||||
onValueChange={setSelectedApplicationId}
|
||||
disabled={isLoadingApplications}>
|
||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingApplications
|
||||
? t("common.loading")
|
||||
: t("contentManagement.selectApplication")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{applications?.map((app) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{localizedName(app.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("contentManagement.copyPermissionsFrom")}
|
||||
</label>
|
||||
<Select
|
||||
value={copyFromPositionId}
|
||||
onValueChange={handleCopyFrom}
|
||||
disabled={
|
||||
!selectedOrganizationId ||
|
||||
isLoadingPositionTypes ||
|
||||
isLoadingUnits ||
|
||||
isCopying
|
||||
}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={copyFromPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{copyFromOptions.map((p: PositionTypeDto) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{localizedName(p.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("contentManagement.copyPermissionsHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permissions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("contentManagement.permission")}
|
||||
{selectedPermissionCount > 0 && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
(
|
||||
{t("contentManagement.permissionsSelected", {
|
||||
count: selectedPermissionCount,
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</FormLabel>
|
||||
<PermissionSearch
|
||||
selectedPermissions={field.value}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
applicationId={selectedApplicationId}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
}}>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isBusy}>
|
||||
{form.formState.isSubmitting
|
||||
? t("common.saving")
|
||||
: mode === "edit"
|
||||
? t("delegation.update")
|
||||
: t("delegation.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,160 +1,193 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
|
||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||
import { PermissionSearch } from "./PermissionSearch";
|
||||
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const EditPositionForm = ({ id }: { id: string }) => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { getList } = useUnit();
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const { positionType, isLoadingSingle } = usePositionTypes({ id });
|
||||
|
||||
const organizationId =
|
||||
user?.employee && user.employee.length > 0
|
||||
? user.employee[0].organizationId
|
||||
: undefined;
|
||||
|
||||
const { applications, isLoading: isLoadingApplications } = useApplications();
|
||||
|
||||
const { data: unitsResponse } = getList(organizationId || "", {
|
||||
take: 300,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
const [selectedApplicationId, setSelectedApplicationId] =
|
||||
useState<string>("");
|
||||
const [assignedPermissions, setAssignedPermissions] = useState<
|
||||
PermissionDto[]
|
||||
>([]);
|
||||
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!positionType) return;
|
||||
setIsLoadingPermissions(true);
|
||||
try {
|
||||
const assigned =
|
||||
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
||||
positionType.id,
|
||||
);
|
||||
setAssignedPermissions(assigned.data.items ?? []);
|
||||
} finally {
|
||||
setIsLoadingPermissions(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [positionType]);
|
||||
|
||||
if (isLoadingSingle) return <p>Loading...</p>;
|
||||
if (!positionType) return null;
|
||||
|
||||
const unit = unitsResponse?.data?.items?.find(
|
||||
(u: UnitDto) => u.id === positionType.unitId,
|
||||
);
|
||||
const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.englishName")}</Label>
|
||||
<Input value={positionType.name.en} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.amharicName")}</Label>
|
||||
<Input value={positionType.name.am} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Key</Label>
|
||||
<Input value={positionType.key} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.selectApplication")}</Label>
|
||||
<Select
|
||||
value={selectedApplicationId}
|
||||
onValueChange={(value) => setSelectedApplicationId(value)}
|
||||
disabled={isLoadingApplications}
|
||||
>
|
||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||
<SelectValue placeholder="Select an Application" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{applications?.map((app: any) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{localizedName(app.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.permission")}</Label>
|
||||
{selectedApplicationId ? (
|
||||
<PermissionSearch
|
||||
selectedPermissions={assignedPermissions.map((perm) => perm.id)}
|
||||
onPermissionChange={() => {
|
||||
// view-only mode in edit form
|
||||
}}
|
||||
applicationId={selectedApplicationId}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
|
||||
{isLoadingPermissions ? (
|
||||
<div className="text-center py-4 text-gray-500">Loading...</div>
|
||||
) : assignedPermissions.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{t("contentManagement.noPermissionsAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{assignedPermissions.map((perm) => (
|
||||
<li
|
||||
key={perm.id}
|
||||
className="capitalize text-sm py-1 px-2 rounded bg-muted/40"
|
||||
>
|
||||
{localizedName(perm.name)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/user-management/position-management")}
|
||||
>
|
||||
{t("common.Back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
|
||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||
import { PermissionSearch } from "./PermissionSearch";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const EditPositionForm = ({ id }: { id: string }) => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { getList } = useUnit();
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({
|
||||
id,
|
||||
});
|
||||
|
||||
const organizationId =
|
||||
user?.employee && user.employee.length > 0
|
||||
? user.employee[0].organizationId
|
||||
: undefined;
|
||||
|
||||
const { applications, isLoading: isLoadingApplications } = useApplications();
|
||||
|
||||
const { data: unitsResponse } = getList(organizationId || "", {
|
||||
take: 300,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
const [selectedApplicationId, setSelectedApplicationId] =
|
||||
useState<string>("");
|
||||
|
||||
// Shares the cache key CreatePositionForm writes under, so editing a position
|
||||
// type's permissions refreshes this view too.
|
||||
const {
|
||||
data: assignedResponse,
|
||||
isLoading: isLoadingPermissions,
|
||||
isError: isErrorPermissions,
|
||||
} = useQuery({
|
||||
queryKey: ["position-type-permissions", id],
|
||||
queryFn: () =>
|
||||
positionTypePermissionService.getPermissionsByPositionTypeId(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const assignedPermissions = assignedResponse?.data?.items ?? [];
|
||||
|
||||
if (isLoadingSingle) {
|
||||
return (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorSingle || !positionType) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="py-8 text-center text-red-500">
|
||||
{t("contentManagement.positionTypeNotFound")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/user-management/position-management")}>
|
||||
{t("common.Back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unit = unitsResponse?.data?.items?.find(
|
||||
(u: UnitDto) => u.id === positionType.unitId,
|
||||
);
|
||||
const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.englishName")}</Label>
|
||||
<Input value={positionType.name.en} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.amharicName")}</Label>
|
||||
<Input value={positionType.name.am} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.key")}</Label>
|
||||
<Input value={positionType.key} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("organization.selectUnit")}</Label>
|
||||
<Input value={unitName ?? ""} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.selectApplication")}</Label>
|
||||
<Select
|
||||
value={selectedApplicationId}
|
||||
onValueChange={setSelectedApplicationId}
|
||||
disabled={isLoadingApplications}>
|
||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingApplications
|
||||
? t("common.loading")
|
||||
: t("contentManagement.selectApplication")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{applications?.map((app) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{localizedName(app.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("contentManagement.permission")}</Label>
|
||||
{selectedApplicationId ? (
|
||||
<PermissionSearch
|
||||
selectedPermissions={assignedPermissions.map((perm) => perm.id)}
|
||||
onPermissionChange={() => {
|
||||
// view-only mode in edit form
|
||||
}}
|
||||
applicationId={selectedApplicationId}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
|
||||
{isLoadingPermissions ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : isErrorPermissions ? (
|
||||
<div className="text-center py-4 text-red-500">
|
||||
{t("contentManagement.failedToLoadPermissions")}
|
||||
</div>
|
||||
) : assignedPermissions.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{t("contentManagement.noPermissionsAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{assignedPermissions.map((perm) => (
|
||||
<li
|
||||
key={perm.id}
|
||||
className="capitalize text-sm py-1 px-2 rounded bg-muted/40">
|
||||
{localizedName(perm.name)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/user-management/position-management")}>
|
||||
{t("common.Back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,152 +1,120 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Checkbox } from "@/shared/common/ui/checkbox";
|
||||
import { usePermissionManager } from "@/user-management/hooks/usePermissionManager";
|
||||
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { t } from "i18next";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
|
||||
interface PermissionSearchProps {
|
||||
selectedPermissions: string[];
|
||||
onPermissionChange: (permissionId: string, checked: boolean) => void;
|
||||
applicationId?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_TAKE = 50; // Initial number of items to fetch
|
||||
|
||||
export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
||||
selectedPermissions,
|
||||
onPermissionChange,
|
||||
applicationId,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [take, setTake] = useState(INITIAL_TAKE); // Start with 50
|
||||
const hasSetTotalCount = useRef(false); // Track if we've set the total count
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
/** ------------------ 1. Debounce Search ------------------ */
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
setTake(INITIAL_TAKE); // Reset to 50
|
||||
hasSetTotalCount.current = false; // Reset the flag
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
/** ------------------ 2. Fetch Permissions ------------------ */
|
||||
const { permissions, isPermissionsLoading } = usePermissionManager({
|
||||
params: applicationId
|
||||
? {
|
||||
take,
|
||||
skip: 0, // Always skip 0, we fetch everything at once
|
||||
search: debouncedSearchTerm || undefined,
|
||||
applicationId,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
/** ------------------ 3. Update take to total count after first fetch ------------------ */
|
||||
useEffect(() => {
|
||||
if (
|
||||
permissions?.count &&
|
||||
!hasSetTotalCount.current &&
|
||||
take !== permissions.count
|
||||
) {
|
||||
hasSetTotalCount.current = true;
|
||||
setTake(permissions.count); // Fetch all items
|
||||
}
|
||||
}, [permissions?.count, take]);
|
||||
|
||||
/** ------------------ 4. Client-side Filtering (Optional) ------------------ */
|
||||
const filteredPermissions = useMemo(() => {
|
||||
if (!permissions?.items?.length) return [];
|
||||
if (!searchTerm.trim()) return permissions.items;
|
||||
|
||||
return permissions.items.filter((perm: PermissionDto) => {
|
||||
const name = localizedName(perm.name).toLowerCase();
|
||||
const key = perm.key.toLowerCase();
|
||||
const search = searchTerm.toLowerCase();
|
||||
return name.includes(search) || key.includes(search);
|
||||
});
|
||||
}, [permissions?.items, searchTerm, localizedName]);
|
||||
|
||||
/** ------------------ Render ------------------ */
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t("contentManagement.searchPermissions")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission List Container */}
|
||||
{!applicationId ? (
|
||||
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
|
||||
{t("contentManagement.selectApplicationToLoadPermissions") ||
|
||||
"Select an application to load permissions."}
|
||||
</div>
|
||||
) : isPermissionsLoading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background"
|
||||
>
|
||||
{filteredPermissions.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{searchTerm
|
||||
? t("contentManagement.noPermissionsFound")
|
||||
: t("contentManagement.noPermissionsAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredPermissions.map((perm: PermissionDto) => (
|
||||
<div
|
||||
key={perm.id}
|
||||
className="flex flex-row items-start space-x-3 space-y-0"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(perm.id)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!disabled) onPermissionChange(perm.id, !!checked);
|
||||
}}
|
||||
/>
|
||||
<label className="capitalize cursor-pointer font-normal text-sm">
|
||||
{localizedName(perm.name)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Info */}
|
||||
{filteredPermissions.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground px-1">
|
||||
{t("contentManagement.showingPermissions", {
|
||||
count: filteredPermissions.length,
|
||||
total: permissions?.count || 0,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Checkbox } from "@/shared/common/ui/checkbox";
|
||||
import { usePermissionManager } from "@/user-management/hooks/usePermissionManager";
|
||||
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { t } from "i18next";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
|
||||
interface PermissionSearchProps {
|
||||
selectedPermissions: string[];
|
||||
onPermissionChange: (permissionId: string, checked: boolean) => void;
|
||||
applicationId?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// One request per application. This used to fetch 50, read `count` off the
|
||||
// response and immediately refetch with take = count — two round trips on every
|
||||
// mount for the same list.
|
||||
const TAKE = 1000;
|
||||
|
||||
export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
||||
selectedPermissions,
|
||||
onPermissionChange,
|
||||
applicationId,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
/** ------------------ 1. Debounce Search ------------------ */
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
/** ------------------ 2. Fetch Permissions ------------------ */
|
||||
// The API does the filtering. Filtering the result again on the *undebounced*
|
||||
// term used to blank the list for 300ms on every keystroke.
|
||||
const { permissions, isPermissionsLoading } = usePermissionManager({
|
||||
params: applicationId
|
||||
? {
|
||||
take: TAKE,
|
||||
skip: 0,
|
||||
search: debouncedSearchTerm || undefined,
|
||||
applicationId,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const items = permissions?.items ?? [];
|
||||
|
||||
/** ------------------ Render ------------------ */
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t("contentManagement.searchPermissions")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission List Container */}
|
||||
{!applicationId ? (
|
||||
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
|
||||
{t("contentManagement.selectApplicationToLoadPermissions")}
|
||||
</div>
|
||||
) : isPermissionsLoading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{debouncedSearchTerm
|
||||
? t("contentManagement.noPermissionsFound")
|
||||
: t("contentManagement.noPermissionsAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{items.map((perm: PermissionDto) => (
|
||||
<div
|
||||
key={perm.id}
|
||||
className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<Checkbox
|
||||
checked={selectedPermissions.includes(perm.id)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!disabled) onPermissionChange(perm.id, !!checked);
|
||||
}}
|
||||
/>
|
||||
<label className="capitalize cursor-pointer font-normal text-sm">
|
||||
{localizedName(perm.name)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Info */}
|
||||
{items.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground px-1">
|
||||
{t("contentManagement.showingPermissions", {
|
||||
count: items.length,
|
||||
total: permissions?.count || 0,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,276 +1,253 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||
import { createPositionTypeColumns } from "./PositionTypeColumnDefn";
|
||||
import { positionTypeService } from "@/user-management/services/api/positionTypesService";
|
||||
import { t } from "i18next";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType";
|
||||
|
||||
export default function PositionManagement() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const pageSize = 10;
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { createConfiguration } = usePositionTypeConfiguration();
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const { getAccessibleList } = useUnit();
|
||||
|
||||
const organizationId = user?.employee?.[0]?.organizationId;
|
||||
|
||||
const { data: unitsResponse } = getAccessibleList(organizationId ?? "", {
|
||||
take: 300,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
// Add state for selected unitId
|
||||
// Default: if super_admin => "All", otherwise wait for units
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
|
||||
|
||||
useEffect(() => {
|
||||
// If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All"
|
||||
if (!selectedUnitId) {
|
||||
if (unitsResponse?.data?.items?.length) {
|
||||
setSelectedUnitId(unitsResponse.data.items[0].id);
|
||||
} else {
|
||||
setSelectedUnitId("All");
|
||||
}
|
||||
}
|
||||
}, [unitsResponse, selectedUnitId]);
|
||||
|
||||
// Reset to first page whenever the search term or unit changes so users
|
||||
// land on the first page of matches instead of an empty later page.
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [searchTerm, selectedUnitId]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
const {
|
||||
positionTypeResponse,
|
||||
isLoading,
|
||||
positionTypeByUnitId,
|
||||
refetch,
|
||||
refetchPosition,
|
||||
} = usePositionTypes({
|
||||
params: {
|
||||
take: 1000,
|
||||
skip: 0,
|
||||
orderBy: "updatedAt:DESC",
|
||||
},
|
||||
unitId: selectedUnitId === "All" ? undefined : selectedUnitId,
|
||||
});
|
||||
|
||||
// Fetch position types without unitId for migration options
|
||||
const {
|
||||
positionTypeResponse: globalPositionTypes,
|
||||
refetch: refetchGlobalPositionTypes,
|
||||
} = usePositionTypes({
|
||||
params: {
|
||||
take: 1000, // Get all global position types
|
||||
skip: 0,
|
||||
orderBy: "updatedAt:DESC",
|
||||
},
|
||||
unitId: undefined, // Explicitly fetch position types without unitId
|
||||
});
|
||||
|
||||
// Create a combined refetch function for the onDelete callback
|
||||
const handlePositionTypeDeleted = async () => {
|
||||
await Promise.all([
|
||||
selectedUnitId === "All" ? refetch() : refetchPosition(),
|
||||
refetchGlobalPositionTypes(),
|
||||
]);
|
||||
};
|
||||
const handleToggle = async (
|
||||
positionTypeId: string,
|
||||
checked: boolean,
|
||||
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
|
||||
) => {
|
||||
if (!selectedUnitId || selectedUnitId === "All") return;
|
||||
|
||||
await createConfiguration({
|
||||
positionTypeId,
|
||||
timeframe: "yearly",
|
||||
organizationId: organizationId!,
|
||||
canReceiveRecord: field === "canReceiveRecord" ? checked : false,
|
||||
canAssignRecord: field === "canAssignRecord" ? checked : false,
|
||||
canCreateBankRecord: field === "canCreateBankRecord" ? checked : false,
|
||||
});
|
||||
|
||||
await handlePositionTypeDeleted();
|
||||
};
|
||||
|
||||
// Create columns with positionTypeResponse
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createPositionTypeColumns(
|
||||
selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId,
|
||||
globalPositionTypes,
|
||||
handlePositionTypeDeleted,
|
||||
handlePositionTypeDeleted,
|
||||
handleToggle, // ← pass toggle handler
|
||||
selectedUnitId === "All", // ← isGlobal: hide toggle when "All"
|
||||
),
|
||||
[
|
||||
selectedUnitId,
|
||||
positionTypeResponse,
|
||||
positionTypeByUnitId,
|
||||
globalPositionTypes,
|
||||
],
|
||||
);
|
||||
const allItems = useMemo(
|
||||
() =>
|
||||
(selectedUnitId === "All"
|
||||
? positionTypeResponse?.items
|
||||
: positionTypeByUnitId?.items) || [],
|
||||
[selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const trimmed = searchTerm.trim().toLowerCase();
|
||||
if (!trimmed) return allItems;
|
||||
return allItems.filter((item: any) => {
|
||||
const en = (item?.name?.en || "").toLowerCase();
|
||||
const am = (item?.name?.am || "").toLowerCase();
|
||||
const key = (item?.key || "").toLowerCase();
|
||||
return (
|
||||
en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed)
|
||||
);
|
||||
});
|
||||
}, [allItems, searchTerm]);
|
||||
|
||||
const paginatedItems = useMemo(() => {
|
||||
const start = pageIndex * pageSize;
|
||||
return filteredItems.slice(start, start + pageSize);
|
||||
}, [filteredItems, pageIndex, pageSize]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>{t("contentManagement.addUser")}</div>;
|
||||
}
|
||||
|
||||
const exportTypes = () => {
|
||||
setIsExporting(true);
|
||||
positionTypeService
|
||||
.getAll({
|
||||
take: 3000,
|
||||
})
|
||||
.then((allPositionKeys) => {
|
||||
// Get the position type keys
|
||||
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
|
||||
|
||||
if (positionTypeKeys && positionTypeKeys.length > 0) {
|
||||
// Convert the array of keys into a string, with each key on a new line
|
||||
const fileContent = positionTypeKeys.join("\n");
|
||||
|
||||
// Create a Blob from the string content
|
||||
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||
|
||||
// Create a link element to trigger the download
|
||||
const link = document.createElement("a");
|
||||
|
||||
// Create an object URL for the Blob
|
||||
link.href = URL.createObjectURL(blob);
|
||||
|
||||
// Set the download attribute with a file name
|
||||
link.download = "position_keys.txt";
|
||||
|
||||
// Programmatically trigger a click on the link to start the download
|
||||
link.click();
|
||||
|
||||
// Clean up by revoking the object URL
|
||||
URL.revokeObjectURL(link.href);
|
||||
} else {
|
||||
console.error("No position type keys found.");
|
||||
}
|
||||
setIsExporting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-xl font-semibold ">
|
||||
{t("contentManagement.permissionType")}
|
||||
</CardTitle>
|
||||
<Button onClick={exportTypes}>
|
||||
{isExporting
|
||||
? t("contentManagement.exporting")
|
||||
: t("contentManagement.exportTypes")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{unitsResponse?.data?.items?.length > 0 && (
|
||||
<div className="mb-4 w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Select Unit
|
||||
</label>
|
||||
<Select
|
||||
value={selectedUnitId}
|
||||
onValueChange={(value) => setSelectedUnitId(value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [&>span]:truncate">
|
||||
<SelectValue placeholder="Select a Unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="all" value="All">
|
||||
All
|
||||
</SelectItem>
|
||||
{unitsResponse?.data.items.map((unit: UnitDto) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
<span className="block truncate max-w-70">
|
||||
{unit.name.en || unit.name.am}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="px-0">
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={paginatedItems}
|
||||
tableName="Positions"
|
||||
toolBarPosition="right"
|
||||
itemCount={filteredItems.length}
|
||||
onGlobalFilterChange={setSearchTerm}
|
||||
extraToolbar={
|
||||
<Link to="/user-management/position-management/new">
|
||||
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("contentManagement.newPermission")}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||
import { createPositionTypeColumns } from "./PositionTypeColumnDefn";
|
||||
import { positionTypeService } from "@/user-management/services/api/positionTypesService";
|
||||
import { t } from "i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
||||
|
||||
export default function PositionManagement() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const pageSize = 10;
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const { getAccessibleList } = useUnit();
|
||||
|
||||
const organizationId = user?.employee?.[0]?.organizationId;
|
||||
|
||||
const { data: unitsResponse, isError: isUnitsError } = getAccessibleList(
|
||||
organizationId ?? "",
|
||||
{
|
||||
take: 300,
|
||||
skip: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Add state for selected unitId
|
||||
// Default: if super_admin => "All", otherwise wait for units
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
|
||||
|
||||
useEffect(() => {
|
||||
// If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All"
|
||||
if (!selectedUnitId) {
|
||||
if (unitsResponse?.data?.items?.length) {
|
||||
setSelectedUnitId(unitsResponse.data.items[0].id);
|
||||
} else {
|
||||
setSelectedUnitId("All");
|
||||
}
|
||||
}
|
||||
}, [unitsResponse, selectedUnitId]);
|
||||
|
||||
// Reset to first page whenever the search term or unit changes so users
|
||||
// land on the first page of matches instead of an empty later page.
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [searchTerm, selectedUnitId]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
const showingAllUnits = selectedUnitId === "All";
|
||||
|
||||
const {
|
||||
positionTypeResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
positionTypeByUnitId,
|
||||
isLoadingPosition,
|
||||
isErrorPosition,
|
||||
refetch,
|
||||
refetchPosition,
|
||||
} = usePositionTypes({
|
||||
params: {
|
||||
take: 1000,
|
||||
skip: 0,
|
||||
orderBy: "updatedAt:DESC",
|
||||
},
|
||||
unitId: showingAllUnits ? undefined : selectedUnitId,
|
||||
});
|
||||
|
||||
// Refresh whichever list is on screen. `positionTypeResponse` is the
|
||||
// unscoped fetch, so it doubles as the migration-target source — no second
|
||||
// usePositionTypes() call needed (its cache key ignores unitId, so a second
|
||||
// call returned the very same query).
|
||||
const handlePositionTypeChanged = useCallback(async () => {
|
||||
await (showingAllUnits ? refetch() : refetchPosition());
|
||||
}, [showingAllUnits, refetch, refetchPosition]);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createPositionTypeColumns(
|
||||
positionTypeResponse,
|
||||
handlePositionTypeChanged,
|
||||
handlePositionTypeChanged,
|
||||
),
|
||||
[positionTypeResponse, handlePositionTypeChanged],
|
||||
);
|
||||
|
||||
const allItems = useMemo(
|
||||
() =>
|
||||
(showingAllUnits
|
||||
? positionTypeResponse?.items
|
||||
: positionTypeByUnitId?.items) || [],
|
||||
[showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const trimmed = searchTerm.trim().toLowerCase();
|
||||
if (!trimmed) return allItems;
|
||||
return allItems.filter((item: PositionTypeDto) => {
|
||||
const en = (item?.name?.en || "").toLowerCase();
|
||||
const am = (item?.name?.am || "").toLowerCase();
|
||||
const key = (item?.key || "").toLowerCase();
|
||||
return (
|
||||
en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed)
|
||||
);
|
||||
});
|
||||
}, [allItems, searchTerm]);
|
||||
|
||||
const paginatedItems = useMemo(() => {
|
||||
const start = pageIndex * pageSize;
|
||||
return filteredItems.slice(start, start + pageSize);
|
||||
}, [filteredItems, pageIndex, pageSize]);
|
||||
|
||||
// Track whichever query is actually feeding the table — picking a unit used
|
||||
// to leave the previous unit's rows on screen with no loading state.
|
||||
const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition;
|
||||
const isErrorList = showingAllUnits ? isError : isErrorPosition;
|
||||
|
||||
const exportTypes = () => {
|
||||
setIsExporting(true);
|
||||
positionTypeService
|
||||
.getAll({ take: 3000 })
|
||||
.then((allPositionKeys) => {
|
||||
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
|
||||
|
||||
if (!positionTypeKeys?.length) {
|
||||
toast.error(t("contentManagement.exportFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// One key per line, downloaded as a plain text file.
|
||||
const blob = new Blob([positionTypeKeys.join("\n")], {
|
||||
type: "text/plain",
|
||||
});
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = "position_keys.txt";
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("contentManagement.exportFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsExporting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="py-0 col-span-2 shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-xl font-semibold ">
|
||||
{t("contentManagement.permissionType")}
|
||||
</CardTitle>
|
||||
<Button onClick={exportTypes} disabled={isExporting}>
|
||||
{isExporting
|
||||
? t("contentManagement.exporting")
|
||||
: t("contentManagement.exportTypes")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{!!unitsResponse?.data?.items?.length && (
|
||||
<div className="mb-4 w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("organization.selectUnit")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedUnitId}
|
||||
onValueChange={(value) => setSelectedUnitId(value)}>
|
||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [&>span]:truncate">
|
||||
<SelectValue placeholder={t("organization.selectUnit")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="all" value="All">
|
||||
{t("common.all")}
|
||||
</SelectItem>
|
||||
{unitsResponse.data.items.map((unit: UnitDto) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
<span className="block truncate max-w-70">
|
||||
{unit.name.en || unit.name.am}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isUnitsError && (
|
||||
<p className="mb-4 text-sm text-red-500">
|
||||
{t("organization.errorLoadingUnits")}
|
||||
</p>
|
||||
)}
|
||||
<CardContent className="px-0">
|
||||
{isLoadingList ? (
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : isErrorList ? (
|
||||
<div className="py-10 text-center text-red-500">
|
||||
{t("contentManagement.failedToLoadPositionTypes")}
|
||||
</div>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={paginatedItems}
|
||||
tableName="Positions"
|
||||
toolBarPosition="right"
|
||||
itemCount={filteredItems.length}
|
||||
onGlobalFilterChange={setSearchTerm}
|
||||
extraToolbar={
|
||||
<Link to="/user-management/position-management/new">
|
||||
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("contentManagement.newPermission")}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,393 +1,251 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import PositionTypeMigrationModal from "./PostionTypeMigration";
|
||||
import { CreatePositionForm } from "./CreatePositionForm";
|
||||
import { toast } from "sonner";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { positionTypeService } from "@/user-management/services/api/positionTypesService";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
|
||||
interface PositionTypeResponse {
|
||||
items: PositionTypeDto[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
type ActionsCellProps = {
|
||||
row: PositionTypeDto | PositionTypeConfigurationDto;
|
||||
globalPositionTypes?: PositionTypeResponse;
|
||||
onDelete?: () => void | Promise<void>;
|
||||
onEdit?: () => void | Promise<void>;
|
||||
onToggle?: (
|
||||
positionTypeId: string,
|
||||
checked: boolean,
|
||||
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
|
||||
) => void | Promise<void>;
|
||||
isGlobal?: boolean; // true when viewing "All" units — hide toggle
|
||||
};
|
||||
|
||||
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
||||
row,
|
||||
globalPositionTypes,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onToggle,
|
||||
isGlobal = false,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [showMigrateDialog, setShowMigrateDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const localizedName = useLocalizedName();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const queryClient = useQueryClient();
|
||||
// Use row.id as the positionTypeId for the configuration lookup
|
||||
|
||||
const {
|
||||
configurations,
|
||||
isLoadingConfigurations,
|
||||
updateConfiguration,
|
||||
isUpdatingConfiguration,
|
||||
} = usePositionTypeConfiguration(
|
||||
row?.id ?? null, // 👈 pass row.id as unitId
|
||||
);
|
||||
|
||||
const configItem = configurations[0];
|
||||
const isCanReceiveRecord = configItem?.canReceiveRecord ?? false;
|
||||
const isCanAssignRecord = configItem?.canAssignRecord ?? false;
|
||||
const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false;
|
||||
|
||||
const invalidateConfig = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["positionTypeConfigurations", row.id],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["positionTypeConfiguration", row.id],
|
||||
});
|
||||
};
|
||||
|
||||
// Create position type options from globalPositionTypes - only those WITHOUT unitId
|
||||
const positionTypeOptions =
|
||||
globalPositionTypes?.items
|
||||
.filter((item) => !item.unitId)
|
||||
.map((item) => ({
|
||||
label: localizedName(item.name),
|
||||
value: item.id,
|
||||
})) || [];
|
||||
|
||||
// Only show migrate/delete actions if current row has a unitId
|
||||
const canBeModified = !!row.unitId;
|
||||
|
||||
const handleView = () => {
|
||||
navigate(`/user-management/position-management/edit/${row.id}`);
|
||||
};
|
||||
|
||||
const handleMigrate = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setShowMigrateDialog(true);
|
||||
};
|
||||
|
||||
const handleEdit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setShowEditDialog(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await positionTypeService.delete(row.id);
|
||||
toast.success(t("common.DeletedSuccessfully"));
|
||||
setShowDeleteDialog(false);
|
||||
if (onDelete) {
|
||||
await onDelete();
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
toast.error(t("common.FailedToDelete"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleChange = async (checked: boolean) => {
|
||||
if (isGlobal) return;
|
||||
try {
|
||||
if (configItem?.id) {
|
||||
await updateConfiguration({
|
||||
id: configItem.id,
|
||||
payload: {
|
||||
organizationId: configItem.organizationId,
|
||||
positionTypeId: configItem.positionTypeId,
|
||||
timeframe: configItem.timeframe,
|
||||
canReceiveRecord: checked,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await onToggle?.(row.id, checked, "canReceiveRecord");
|
||||
}
|
||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
||||
invalidateConfig();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignToggleChange = async (checked: boolean) => {
|
||||
if (isGlobal) return;
|
||||
try {
|
||||
if (configItem?.id) {
|
||||
await updateConfiguration({
|
||||
id: configItem.id,
|
||||
payload: {
|
||||
organizationId: configItem.organizationId,
|
||||
positionTypeId: configItem.positionTypeId,
|
||||
timeframe: configItem.timeframe,
|
||||
canAssignRecord: checked,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await onToggle?.(row.id, checked, "canAssignRecord");
|
||||
}
|
||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
||||
invalidateConfig();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBankRecordToggleChange = async (checked: boolean) => {
|
||||
if (isGlobal) return;
|
||||
try {
|
||||
if (configItem?.id) {
|
||||
await updateConfiguration({
|
||||
id: configItem.id,
|
||||
payload: {
|
||||
organizationId: configItem.organizationId,
|
||||
positionTypeId: configItem.positionTypeId,
|
||||
timeframe: configItem.timeframe,
|
||||
canCreateBankRecord: checked,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await onToggle?.(row.id, checked, "canCreateBankRecord");
|
||||
}
|
||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
||||
invalidateConfig();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
||||
}
|
||||
};
|
||||
const rowName = "name" in row ? row.name : { am: "", en: "" };
|
||||
const isPositionType = "name" in row && "key" in row;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Open actions menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('[role="dialog"]')) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}}>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
|
||||
{canBeModified && (
|
||||
<DropdownMenuItem
|
||||
onSelect={handleMigrate}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span> {t("common.Migrate")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canBeModified && isPositionType && (
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span>{t("common.Edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={handleView}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span>{t("common.View")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canBeModified && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
|
||||
<Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span>{t("common.Delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Toggle moved here from ToggleCell */}
|
||||
{!isGlobal && (
|
||||
<div className="px-2 py-2 border-t mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("contentManagement.CanReceiveRecord")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isCanReceiveRecord}
|
||||
disabled={isLoadingConfigurations || isUpdatingConfiguration}
|
||||
onCheckedChange={handleToggleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isGlobal && (
|
||||
<div className="px-2 py-2 border-t mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("contentManagement.CanAssignRecord")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isCanAssignRecord}
|
||||
disabled={isLoadingConfigurations || isUpdatingConfiguration}
|
||||
onCheckedChange={handleAssignToggleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isGlobal && (
|
||||
<div className="px-2 py-2 border-t mt-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm">
|
||||
{t("contentManagement.CanCreateBankRecord")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isCanCreateBankRecord}
|
||||
disabled={isLoadingConfigurations || isUpdatingConfiguration}
|
||||
onCheckedChange={handleCreateBankRecordToggleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{showMigrateDialog && (
|
||||
<PositionTypeMigrationModal
|
||||
isOpen={showMigrateDialog}
|
||||
onClose={() => {
|
||||
setShowMigrateDialog(false);
|
||||
}}
|
||||
toId={row.id}
|
||||
toName={localizedName(rowName)}
|
||||
positionTypeOptions={positionTypeOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle>{t("common.Edit")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
|
||||
{isPositionType && showEditDialog && (
|
||||
<CreatePositionForm
|
||||
key={row.id + "-edit"}
|
||||
mode="edit"
|
||||
positionTypeId={row.id}
|
||||
initialValues={{
|
||||
nameAm: row.name.am,
|
||||
nameEn: row.name.en,
|
||||
unitId: row.unitId,
|
||||
key: row.key,
|
||||
}}
|
||||
onSuccess={async () => {
|
||||
setShowEditDialog(false);
|
||||
if (onEdit) {
|
||||
await onEdit();
|
||||
}
|
||||
}}
|
||||
onCancel={() => setShowEditDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("common.DeleteConfirmationMessage", {
|
||||
defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-500 hover:bg-red-600">
|
||||
{isDeleting ? t("common.Deleting") : t("common.Delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PositionTypeActionsCell;
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import PositionTypeMigrationModal from "./PostionTypeMigration";
|
||||
import { CreatePositionForm } from "./CreatePositionForm";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
|
||||
interface PositionTypeResponse {
|
||||
items: PositionTypeDto[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
type ActionsCellProps = {
|
||||
row: PositionTypeDto;
|
||||
globalPositionTypes?: PositionTypeResponse;
|
||||
onDelete?: () => void | Promise<void>;
|
||||
onEdit?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
/*
|
||||
* TODO(record-toggles): this menu used to carry CanReceiveRecord /
|
||||
* CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's
|
||||
* PositionTypeConfiguration entity only has { id, organizationId,
|
||||
* positionTypeId, timeframe } — verified against every local build (0.7.4
|
||||
* through 0.7.12) and the live swagger. canAssignRecord and
|
||||
* canCreateBankRecord do not exist anywhere in the IAM package, and the global
|
||||
* ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The
|
||||
* reads were broken too: the list route filters on organizationId (the repo is
|
||||
* built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a
|
||||
* positionTypeId, so it always came back empty.
|
||||
*
|
||||
* The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by
|
||||
* positionId — a per-position setting served by /api/position-configurations,
|
||||
* not a per-position-type one. Restoring this needs either that endpoint and a
|
||||
* position-level UI, or new columns on PositionTypeConfiguration in IAM.
|
||||
*/
|
||||
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
||||
row,
|
||||
globalPositionTypes,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [showMigrateDialog, setShowMigrateDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const localizedName = useLocalizedName();
|
||||
const { deletePositionType } = usePositionTypes();
|
||||
|
||||
// Create position type options from globalPositionTypes - only those WITHOUT unitId
|
||||
const positionTypeOptions =
|
||||
globalPositionTypes?.items
|
||||
.filter((item) => !item.unitId)
|
||||
.map((item) => ({
|
||||
label: localizedName(item.name),
|
||||
value: item.id,
|
||||
})) || [];
|
||||
|
||||
// Only show migrate/delete actions if current row has a unitId
|
||||
const canBeModified = !!row.unitId;
|
||||
|
||||
const handleView = () => {
|
||||
navigate(`/user-management/position-management/edit/${row.id}`);
|
||||
};
|
||||
|
||||
const handleMigrate = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setShowMigrateDialog(true);
|
||||
};
|
||||
|
||||
const handleEdit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setShowEditDialog(true);
|
||||
};
|
||||
|
||||
// Goes through the mutation rather than the service directly, so the cache is
|
||||
// invalidated and IAM's 403 for built-in types reaches the user.
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deletePositionType.mutateAsync(row.id);
|
||||
setShowDeleteDialog(false);
|
||||
await onDelete?.();
|
||||
} catch {
|
||||
// reported by the mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Open actions menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('[role="dialog"]')) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}}>
|
||||
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
|
||||
|
||||
{canBeModified && (
|
||||
<DropdownMenuItem
|
||||
onSelect={handleMigrate}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span> {t("common.Migrate")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canBeModified && (
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<Pencil className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span>{t("common.Edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={handleView}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<Eye className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span>{t("common.View")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canBeModified && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200">
|
||||
<Trash2 className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span>{t("common.Delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{showMigrateDialog && (
|
||||
<PositionTypeMigrationModal
|
||||
isOpen={showMigrateDialog}
|
||||
onClose={() => {
|
||||
setShowMigrateDialog(false);
|
||||
}}
|
||||
toId={row.id}
|
||||
toName={localizedName(row.name)}
|
||||
positionTypeOptions={positionTypeOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col p-6">
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle>{t("common.Edit")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
|
||||
{showEditDialog && (
|
||||
<CreatePositionForm
|
||||
key={row.id + "-edit"}
|
||||
mode="edit"
|
||||
positionTypeId={row.id}
|
||||
initialValues={{
|
||||
nameAm: row.name.am,
|
||||
nameEn: row.name.en,
|
||||
unitId: row.unitId ?? "",
|
||||
key: row.key,
|
||||
}}
|
||||
onSuccess={async () => {
|
||||
setShowEditDialog(false);
|
||||
if (onEdit) {
|
||||
await onEdit();
|
||||
}
|
||||
}}
|
||||
onCancel={() => setShowEditDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("common.DeleteConfirmationMessage", {
|
||||
name: localizedName(row.name),
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deletePositionType.isPending}>
|
||||
{t("common.Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deletePositionType.isPending}
|
||||
className="bg-red-500 hover:bg-red-600">
|
||||
{deletePositionType.isPending
|
||||
? t("common.Deleting")
|
||||
: t("common.Delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PositionTypeActionsCell;
|
||||
|
||||
@@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => {
|
||||
};
|
||||
|
||||
export const createPositionTypeColumns = (
|
||||
_positionTypeResponse?: PositionTypeResponse,
|
||||
globalPositionTypes?: PositionTypeResponse,
|
||||
onDelete?: () => void | Promise<void>,
|
||||
onEdit?: () => void | Promise<void>,
|
||||
onToggle?: (
|
||||
positionTypeId: string,
|
||||
checked: boolean,
|
||||
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
|
||||
) => void | Promise<void>,
|
||||
isGlobal?: boolean,
|
||||
): ColumnDef<PositionTypeDto>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
@@ -64,8 +57,6 @@ export const createPositionTypeColumns = (
|
||||
globalPositionTypes={globalPositionTypes}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onToggle={onToggle}
|
||||
isGlobal={isGlobal}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -5,10 +5,14 @@ export interface PositionTypeDto {
|
||||
en: string;
|
||||
};
|
||||
key: string;
|
||||
unitId: string;
|
||||
canReceiveRecord: boolean;
|
||||
canCreateBankRecord?: boolean;
|
||||
canAssignRecord: boolean;
|
||||
/**
|
||||
* Null for the built-in ("common") types, which `isSystem` marks and which
|
||||
* every unit can use. IAM has no organizationId on a position type — the
|
||||
* owning organization is only reachable via unit -> organizationId.
|
||||
*/
|
||||
unitId: string | null;
|
||||
/** Built-in type. IAM rejects update/delete on these with a 403. */
|
||||
isSystem?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
QueryClient,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
CreatePositionTypePayload,
|
||||
PositionRequest,
|
||||
@@ -23,10 +28,22 @@ interface positionParams {
|
||||
interface UsePositionTypeManagerProps {
|
||||
id?: string;
|
||||
unitId?: string;
|
||||
organizationId?: string;
|
||||
params?: positionParams; // 👈 we expected query params to be passed like this
|
||||
}
|
||||
|
||||
/**
|
||||
* Every cache key this hook writes under. React Query matches key prefixes
|
||||
* element by element, so `["position-type"]` does NOT reach
|
||||
* `["position-types-common", ...]` — each root has to be listed. Anything that
|
||||
* mutates a position type should call this rather than hand-picking keys, or
|
||||
* the department pickers (which read the "-common" queries) go stale.
|
||||
*/
|
||||
export const invalidatePositionTypeQueries = (queryClient: QueryClient) => {
|
||||
["position-types", "position-type", "position-types-common"].forEach(
|
||||
(root) => queryClient.invalidateQueries({ queryKey: [root] }),
|
||||
);
|
||||
};
|
||||
|
||||
export const usePositionTypes = ({
|
||||
id,
|
||||
params = {
|
||||
@@ -35,11 +52,11 @@ export const usePositionTypes = ({
|
||||
orderBy: "createdAt:Desc",
|
||||
},
|
||||
unitId,
|
||||
organizationId,
|
||||
}: UsePositionTypeManagerProps = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const invalidateAll = () => invalidatePositionTypeQueries(queryClient);
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["position-types", params],
|
||||
queryFn: () => positionTypeService.getAll(params).then((res) => res.data),
|
||||
@@ -70,38 +87,6 @@ export const usePositionTypes = ({
|
||||
enabled: !!unitId,
|
||||
});
|
||||
|
||||
// Position types by organization ID
|
||||
const {
|
||||
data: positionTypeByOrgId,
|
||||
isLoading: isLoadingOrgPosition,
|
||||
isError: isErrorOrgPosition,
|
||||
refetch: refetchOrgPosition,
|
||||
} = useQuery<PositionTypesListResponse | undefined>({
|
||||
queryKey: ["position-type-org", organizationId, params],
|
||||
queryFn: async () => {
|
||||
if (!organizationId) return undefined;
|
||||
const res = await positionTypeService.getByOrganizationId(organizationId, params);
|
||||
return res.data as PositionTypesListResponse | undefined;
|
||||
},
|
||||
enabled: !!organizationId,
|
||||
});
|
||||
|
||||
// Common types with organization ID (includes both org-specific and common types)
|
||||
const {
|
||||
data: commonPositionTypesByOrgId,
|
||||
isLoading: isLoadingCommonOrgTypes,
|
||||
isError: isErrorCommonOrgTypes,
|
||||
refetch: refetchCommonOrgTypes,
|
||||
} = useQuery<PositionTypesListResponse | undefined>({
|
||||
queryKey: ["position-types-common-org", organizationId, params],
|
||||
queryFn: async () => {
|
||||
if (!organizationId) return undefined;
|
||||
const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params);
|
||||
return res.data as PositionTypesListResponse | undefined;
|
||||
},
|
||||
enabled: !!organizationId,
|
||||
});
|
||||
|
||||
// Common types with unit ID (includes both unit-specific and common types)
|
||||
const {
|
||||
data: commonPositionTypes,
|
||||
@@ -123,15 +108,16 @@ export const usePositionTypes = ({
|
||||
mutationFn: (payload: CreatePositionTypePayload) =>
|
||||
positionTypeService.create(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Position type created");
|
||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
||||
toast.success(t("contentManagement.positionTypeCreated"));
|
||||
invalidateAll();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Update
|
||||
// Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in
|
||||
// (isSystem) types, so the error has to reach the user.
|
||||
const updatePositionType = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
@@ -141,11 +127,12 @@ export const usePositionTypes = ({
|
||||
data: UpdatePositionTypePayload;
|
||||
}) => positionTypeService.update(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("Position type updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
|
||||
toast.success(t("contentManagement.positionTypeUpdated"));
|
||||
invalidateAll();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
//update positon from to
|
||||
@@ -153,30 +140,32 @@ export const usePositionTypes = ({
|
||||
mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) =>
|
||||
positionTypeService.updateFromto(toId, fromId),
|
||||
onSuccess: () => {
|
||||
toast.success("Position type migration updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["position-types-to"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
|
||||
toast.success(t("contentManagement.positionTypeMigrated"));
|
||||
invalidateAll();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
//update all postions
|
||||
const migratePositionsByPositions = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: PositionRequest }) =>
|
||||
positionTypeService.updateByPostion(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("Position type migration updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["position-types-migration"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
|
||||
toast.success(t("contentManagement.positionTypeMigrated"));
|
||||
invalidateAll();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
// Delete
|
||||
// Delete. Also 403s for built-in types.
|
||||
const deletePositionType = useMutation({
|
||||
mutationFn: (id: string) => positionTypeService.delete(id),
|
||||
onSuccess: () => {
|
||||
toast.success("Position type deleted");
|
||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
||||
toast.success(t("contentManagement.positionTypeDeleted"));
|
||||
invalidateAll();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
@@ -205,16 +194,6 @@ export const usePositionTypes = ({
|
||||
refetchPosition,
|
||||
isErrorPosition,
|
||||
isLoadingPosition,
|
||||
// organization-based position types
|
||||
positionTypeByOrgId,
|
||||
refetchOrgPosition,
|
||||
isErrorOrgPosition,
|
||||
isLoadingOrgPosition,
|
||||
// common types with organization ID
|
||||
commonPositionTypesByOrgId,
|
||||
refetchCommonOrgTypes,
|
||||
isErrorCommonOrgTypes,
|
||||
isLoadingCommonOrgTypes,
|
||||
// common types with unit ID
|
||||
commonPositionTypes: commonPositionTypes?.items ?? [],
|
||||
isLoadingCommonTypes,
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import PositionManagement from "@/user-management/components/position-management/PositionLists";
|
||||
import { t } from "i18next";
|
||||
|
||||
const PositionManagementPage = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">{t("contentManagement.permissionManagement")}</h2>
|
||||
<PositionManagement />
|
||||
</div>
|
||||
);
|
||||
return <PositionManagement />;
|
||||
};
|
||||
|
||||
export default PositionManagementPage;
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface PositionPayload {
|
||||
organizationId: string;
|
||||
parentPositionId?: string;
|
||||
projectId?: string;
|
||||
positionTypeId: string;
|
||||
positionTypeId?: string;
|
||||
}
|
||||
export interface PositionQueryParams {
|
||||
orderBy?: string;
|
||||
|
||||
@@ -40,38 +40,25 @@ export const positionTypeService = {
|
||||
getById: (id: string): Promise<AxiosResponse<PositionTypeDto>> =>
|
||||
axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }),
|
||||
|
||||
// Types owned by one unit. IAM has no organization-scoped route — position
|
||||
// types carry a unitId only, so scoping to an org means filtering by that
|
||||
// org's units client-side.
|
||||
getByUnitId: (
|
||||
id: string,
|
||||
unitId: string,
|
||||
params?: Params,
|
||||
): Promise<AxiosResponse<PositionTypesListResponse>> =>
|
||||
axiosInstance.get(`/position-types/list/${id}`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
}),
|
||||
|
||||
getByOrganizationId: (
|
||||
id: string,
|
||||
params?: Params,
|
||||
): Promise<AxiosResponse<PositionTypesListResponse>> =>
|
||||
axiosInstance.get(`/position-types/list/${id}`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
}),
|
||||
|
||||
getCommonTypesByOrganizationId: (
|
||||
id: string,
|
||||
params?: Params,
|
||||
): Promise<AxiosResponse<PositionTypesListResponse>> =>
|
||||
axiosInstance.get(`/position-types/list-with-commons/${id}`, {
|
||||
axiosInstance.get(`/position-types/list/${unitId}`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
}),
|
||||
|
||||
// WHERE isSystem = true OR unitId = :unitId — "commons" means the built-in
|
||||
// types, not the ones with a null unitId.
|
||||
getCommonTypesById: (
|
||||
id: string,
|
||||
unitId: string,
|
||||
params: Params,
|
||||
): Promise<AxiosResponse<PositionTypesListResponse>> =>
|
||||
axiosInstance.get(`/position-types/list-with-commons/${id}`, {
|
||||
axiosInstance.get(`/position-types/list-with-commons/${unitId}`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
}),
|
||||
|
||||
@@ -48,8 +48,6 @@ export function AddDepartmentForm({
|
||||
if (!nameAm.trim())
|
||||
newErrors.nameAm = t("organization.amharicNameRequired");
|
||||
if (!key.trim()) newErrors.key = t("contentManagement.keyRequired");
|
||||
if (!positionTypeId)
|
||||
newErrors.positionTypeId = t("contentManagement.selectPosType");
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
@@ -71,7 +69,7 @@ export function AddDepartmentForm({
|
||||
key: key.trim().toLowerCase().replace(/\s+/g, "-"),
|
||||
unitId,
|
||||
organizationId,
|
||||
positionTypeId,
|
||||
...(positionTypeId ? { positionTypeId } : {}),
|
||||
};
|
||||
|
||||
createPosition({
|
||||
@@ -90,12 +88,11 @@ export function AddDepartmentForm({
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="positionType">{t("organization.positionTypes")}</Label>
|
||||
<Label htmlFor="positionType">
|
||||
{t("organization.positionTypes")} ({t("common.optional")})
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setPositionTypeId(value);
|
||||
setErrors((prev) => ({ ...prev, positionTypeId: "" }));
|
||||
}}
|
||||
onValueChange={setPositionTypeId}
|
||||
value={positionTypeId}
|
||||
disabled={isLoadingTypes}
|
||||
>
|
||||
@@ -110,9 +107,6 @@ export function AddDepartmentForm({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.positionTypeId && (
|
||||
<p className="text-red-500 text-sm">{errors.positionTypeId}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user