This commit is contained in:
Marshal
2026-07-25 06:05:20 +00:00
254 changed files with 15542 additions and 5375 deletions

View File

@@ -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={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

@@ -67,10 +67,3 @@ export function useDisputeInterchangeDocument() {
});
}
export function useCancelInterchangeDocument() {
const onSuccess = useInterchangeInvalidation();
return useMutation({
mutationFn: (id: string) => interchangeDocumentsService.cancel(id),
onSuccess,
});
}

View 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,
},
},
};
};

View File

@@ -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: {

View 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;
};

View File

@@ -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": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
}
}
}

View File

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

View File

@@ -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 lautorisation",
"perSuccess": "Type dautorisation créé et autorisations assignées",
"updatePerSuccess": "Autorisation mise à jour avec succès",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: "",

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ export interface Vendor {
export interface AssetAcquisition {
id: string;
itemName?: string | null;
vehicleId?: string | null;
vendorId?: string | null;
acquisitionType: AcquisitionType;

View File

@@ -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":

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
| "cargo-types"
| "container-types"
| "wagon-types"
| "truck-types"
| "priority-configs"
| "service-types"
| "weight-limit-rules"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>
),
},

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ export interface PositionPayload {
organizationId: string;
parentPositionId?: string;
projectId?: string;
positionTypeId: string;
positionTypeId?: string;
}
export interface PositionQueryParams {
orderBy?: string;

View File

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

View File

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