mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
train
This commit is contained in:
268
apps/edr-freight-web/backoffice/src/pages/ActivityLogPage.tsx
Normal file
268
apps/edr-freight-web/backoffice/src/pages/ActivityLogPage.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@/shared/common/ui/table";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { listAuditLogExtensions } from "@/shared/services/audit/audit.api";
|
||||
|
||||
interface ActivityLog {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
entityName: string;
|
||||
queryMethod: "INSERT" | "UPDATE" | "DELETE" | string;
|
||||
user: {
|
||||
id: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
email: string;
|
||||
username?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export default function ActivityLogPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [allLogs, setAllLogs] = useState<ActivityLog[]>([]);
|
||||
const [filteredLogs, setFilteredLogs] = useState<ActivityLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const fetchAuditLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch all 1000 records from super admin endpoint
|
||||
const data = await listAuditLogExtensions(
|
||||
"/audit-log-extensions/audit/superAdmin",
|
||||
{
|
||||
skip: 0,
|
||||
take: 1000,
|
||||
orderBy: "createdAt:DESC",
|
||||
}
|
||||
);
|
||||
|
||||
const logs = (data.items || []) as ActivityLog[];
|
||||
setAllLogs(logs);
|
||||
setCurrentPage(1);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching audit logs:", error);
|
||||
setAllLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchAuditLogs, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchAuditLogs]);
|
||||
|
||||
// Paginate the logs whenever currentPage changes
|
||||
useEffect(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
let filtered = allLogs.slice(startIndex, endIndex);
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(
|
||||
(log) =>
|
||||
log.entityName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.user.name.am.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.user.name.en.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.user.email.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredLogs(filtered);
|
||||
}, [allLogs, currentPage, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(allLogs.length / ITEMS_PER_PAGE);
|
||||
const hasNextPage = currentPage < totalPages;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{t("activityLogPage.title")}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={fetchAuditLogs}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
{t("common.refresh", "Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
placeholder={t("activityLogPage.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("activityLogPage.table.time")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[400px]">
|
||||
{t("activityLogPage.table.description")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("activityLogPage.table.performedBy")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span>{t("common.loading", "Loading...")}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log) => {
|
||||
const userName =
|
||||
i18n.language === "am" ? log.user.name.am : log.user.name.en;
|
||||
const entityLabel = log.entityName.replace(/_/g, " ");
|
||||
const actionVerb =
|
||||
log.queryMethod.toLowerCase() === "insert"
|
||||
? "created"
|
||||
: log.queryMethod.toLowerCase() === "update"
|
||||
? "updated"
|
||||
: log.queryMethod.toLowerCase() === "delete"
|
||||
? "deleted"
|
||||
: log.queryMethod.toLowerCase();
|
||||
const timestamp = new Date(log.createdAt).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-medium">{timestamp}</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="font-semibold">{userName}</span>
|
||||
{" "}
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{actionVerb}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
||||
{entityLabel}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{userName}</p>
|
||||
<p className="text-xs text-gray-500">{log.user.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
{t("activityLogPage.table.noLogs")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
{allLogs.length > 0 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t("common.showing", "Showing")} {(currentPage - 1) * ITEMS_PER_PAGE + 1}-
|
||||
{Math.min(currentPage * ITEMS_PER_PAGE, allLogs.length)}{" "}
|
||||
{t("common.of", "of")} {allLogs.length}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={!hasPrevPage || loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
|
||||
{currentPage} / {totalPages || 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={!hasNextPage || loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import SitesPage from "@/super-admin/components/sites/SitesPage";
|
||||
|
||||
const AddSitePage = () => {
|
||||
return <SitesPage />;
|
||||
};
|
||||
|
||||
export default AddSitePage;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import {
|
||||
PermittedDashboardItem,
|
||||
DashaboardParam,
|
||||
} from "@/record-management/services/api/advancedDashboardService";
|
||||
import { useAdvancedDashboardHook } from "@/shared/hooks/useAdvancedDashboardHook";
|
||||
import useSettings from "@/record-management/components/hooks/useSettings";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { getFormattedDashboardLabel } from "@/performance-management/utils/dashboardUtils";
|
||||
import { PositionDto } from "@/record-management/dto/userRecords/usersDto";
|
||||
|
||||
const AdvancedDashboardPage = () => {
|
||||
const [selectedDashboard, setSelectedDashboard] =
|
||||
useState<PermittedDashboardItem | null>(null);
|
||||
const [selectedParameters, setSelectedParameters] = useState<
|
||||
Record<string, any>
|
||||
>({});
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const {
|
||||
permittedDashboards,
|
||||
dashboardUrl,
|
||||
isLoadingPermitted,
|
||||
isLoadingUrl,
|
||||
} = useAdvancedDashboardHook(selectedDashboard?.id);
|
||||
|
||||
const { allScopedDepartments, allScopedEmployees } = useSettings();
|
||||
|
||||
const positionOptions = Array.isArray(allScopedDepartments)
|
||||
? allScopedDepartments.map((pos: PositionDto) => ({
|
||||
label: localizedName(pos.name) || "N/A",
|
||||
value: pos.id,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const employeeOptions = Array.isArray(allScopedEmployees)
|
||||
? allScopedEmployees.map((emp: any) => ({
|
||||
label: localizedName(emp.name) || "N/A",
|
||||
value: emp.id,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const handleParameterSelect = (key: string, value: string) => {
|
||||
setSelectedParameters((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Dashboard + Position + Employee in a row */}
|
||||
<div className="flex flex-wrap gap-4 items-start">
|
||||
{/* Dashboard Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center justify-between w-60"
|
||||
disabled={isLoadingPermitted}>
|
||||
{isLoadingPermitted
|
||||
? "Loading dashboards..."
|
||||
: getFormattedDashboardLabel(selectedDashboard?.name, "am") ||
|
||||
"Select Dashboard"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-60">
|
||||
<DropdownMenuLabel>Available Dashboards</DropdownMenuLabel>
|
||||
{permittedDashboards?.items.map((dashboard) => (
|
||||
<DropdownMenuItem
|
||||
key={dashboard.id}
|
||||
onClick={() => {
|
||||
setSelectedDashboard(dashboard);
|
||||
setSelectedParameters({}); // reset parameters
|
||||
}}>
|
||||
{getFormattedDashboardLabel(dashboard.name, "am")}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Position Dropdown */}
|
||||
{selectedDashboard?.params?.includes(DashaboardParam.POSITION) && (
|
||||
<select
|
||||
className="border rounded-md w-60 p-2"
|
||||
value={selectedParameters["position"] || ""}
|
||||
onChange={(e) => handleParameterSelect("position", e.target.value)}>
|
||||
<option value="">Select Position</option>
|
||||
{positionOptions.map((pos) => (
|
||||
<option key={pos.value} value={pos.value}>
|
||||
{pos.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Employee Dropdown */}
|
||||
{selectedDashboard?.params?.includes(DashaboardParam.EMPLOYEE) && (
|
||||
<select
|
||||
className="border rounded-md w-60 p-2"
|
||||
value={selectedParameters["employee"] || ""}
|
||||
onChange={(e) => handleParameterSelect("employee", e.target.value)}>
|
||||
<option value="">Select Employee</option>
|
||||
{employeeOptions.map((emp) => (
|
||||
<option key={emp.value} value={emp.value}>
|
||||
{emp.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dashboard iframe */}
|
||||
{isLoadingUrl ? (
|
||||
<p>Loading dashboard...</p>
|
||||
) : dashboardUrl ? (
|
||||
<iframe
|
||||
src={dashboardUrl?.iframeUrl}
|
||||
width="100%"
|
||||
height="800"
|
||||
className="rounded-xl border"
|
||||
title="Advanced Dashboard"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-500">Select a dashboard to view</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedDashboardPage;
|
||||
114
apps/edr-freight-web/backoffice/src/pages/ArchiveUsersPage.tsx
Normal file
114
apps/edr-freight-web/backoffice/src/pages/ArchiveUsersPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { useArchivedUsers } from "@/super-admin/hooks/useArchivedUsers";
|
||||
import { ArchivedUserColumnDefn } from "@/user-management/components/content/ArchivedUserColumnDefn";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { t } from "i18next";
|
||||
|
||||
const ArchiveUsersPage = () => {
|
||||
const { user } = useAuth();
|
||||
const { getAccessibleList } = useUnit();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const pageSize = 10;
|
||||
const localizedName = useLocalizedName();
|
||||
const organizationId =
|
||||
user?.employee && user.employee.length > 0
|
||||
? user.employee[0].organizationId
|
||||
: undefined;
|
||||
const { data: unitsResponse } = organizationId
|
||||
? getAccessibleList(organizationId, {
|
||||
take: 300,
|
||||
skip: 0,
|
||||
})
|
||||
: { data: undefined };
|
||||
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>(
|
||||
unitsResponse?.data?.items[0]?.id || "All",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (unitsResponse?.data?.items.length > 0) {
|
||||
setSelectedUnitId(unitsResponse?.data.items[0].id);
|
||||
}
|
||||
}, [unitsResponse?.data?.items]);
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
const { data, refetch } = useArchivedUsers(selectedUnitId, {
|
||||
take: pageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
});
|
||||
|
||||
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("setting.archivedUsers")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{unitsResponse?.data?.items?.length > 0 && (
|
||||
<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="Select a Unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitsResponse?.data.items.map((unit: UnitDto) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
<span className="block truncate max-w-70">{localizedName(unit.name)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="px-0">
|
||||
<AdvancedTable
|
||||
columns={ArchivedUserColumnDefn}
|
||||
data={data?.items || []}
|
||||
tableName="ArchivedUsers"
|
||||
toolBarPosition="right"
|
||||
itemCount={data?.count || 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={
|
||||
data?.count && data.count > (pageIndex + 1) * pageSize
|
||||
? () => handlePageChange(pageIndex + 1)
|
||||
: () => {}
|
||||
}
|
||||
prevFunction={
|
||||
pageIndex > 0
|
||||
? () => handlePageChange(Math.max(pageIndex - 1, 0))
|
||||
: () => {}
|
||||
}
|
||||
refresh={refetch}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchiveUsersPage;
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArchiveRestore, Building2, UserSquare2, Trash2, Search, Filter } from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import {
|
||||
useArchivedPositions,
|
||||
useArchivedUnits,
|
||||
useArchiveActions,
|
||||
} from "@/user-management/hooks/useArchived";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { deleteUnit } from "@/user-management/services/api/unitService";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMyAdminOrganizations } from "@/user-management/hooks/useOrgAdminOrganizations";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
|
||||
type Tab = "units" | "positions";
|
||||
|
||||
const ArchivedUnitsPositionsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { getAccessibleList } = useUnit();
|
||||
const { organizations: myAdminOrganizations } = useMyAdminOrganizations();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("units");
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>("");
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [unitToDelete, setUnitToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const { data: unitsResponse, isLoading: isLoadingUnits } = getAccessibleList(
|
||||
selectedOrgId || "",
|
||||
{ take: 300, skip: 0 },
|
||||
!!selectedOrgId
|
||||
);
|
||||
|
||||
const unitList: UnitDto[] = selectedOrgId ? (unitsResponse?.data?.items ?? []) : [];
|
||||
|
||||
const { data: archivedUnitsData, refetch: refetchArchivedUnits, isLoading: isLoadingArchivedUnits } =
|
||||
useArchivedUnits(selectedOrgId || undefined);
|
||||
const { data: archivedPositionsData, refetch: refetchArchivedPositions, isLoading: isLoadingArchivedPositions } =
|
||||
useArchivedPositions(selectedUnitId || undefined);
|
||||
|
||||
const { restoreUnit, isRestoringUnit, restorePosition, isRestoringPosition } =
|
||||
useArchiveActions();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "units" && !selectedOrgId && myAdminOrganizations.length > 0) {
|
||||
setSelectedOrgId(myAdminOrganizations[0].id);
|
||||
}
|
||||
}, [activeTab, myAdminOrganizations.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "positions" && !selectedUnitId && unitList.length > 0) {
|
||||
setSelectedUnitId(unitList[0].id);
|
||||
}
|
||||
}, [activeTab, unitList.length]);
|
||||
|
||||
const handlePermanentDelete = async () => {
|
||||
if (!unitToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteUnit(unitToDelete.id);
|
||||
toast.success(t("archive.unitDeletedPermanently", "Unit permanently deleted"));
|
||||
setDeleteConfirmOpen(false);
|
||||
setUnitToDelete(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["archived-units"] });
|
||||
refetchArchivedUnits();
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 400 && error?.response?.data?.message) {
|
||||
const errorMessage = error?.response?.data?.message;
|
||||
|
||||
if (errorMessage.includes("Referenced Entity") || errorMessage.includes("referenced")) {
|
||||
toast.error(
|
||||
t("archive.cannotDeleteUnitWithPositions", "Cannot delete unit with related positions"),
|
||||
{
|
||||
description: t("archive.deletePositionsFirst", "Please delete or reassign all positions first."),
|
||||
duration: 5000
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(t("archive.deleteUnitFailed", "Failed to delete unit"));
|
||||
}
|
||||
} else {
|
||||
toast.error(t("archive.deleteUnitFailed", "Failed to delete unit"));
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const archivedUnits: any[] = useMemo(
|
||||
() => archivedUnitsData?.items ?? archivedUnitsData ?? [],
|
||||
[archivedUnitsData],
|
||||
);
|
||||
const archivedPositions: any[] = useMemo(
|
||||
() => archivedPositionsData?.items ?? archivedPositionsData ?? [],
|
||||
[archivedPositionsData],
|
||||
);
|
||||
|
||||
const unitColumns = [
|
||||
{
|
||||
id: "name",
|
||||
header: t("organization.name", "Name"),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{localizedName(row.original?.name) || "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t("archive.archivedAt", "Archived")}: {new Date(row.original?.archivedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "key",
|
||||
header: t("organization.key", "Key"),
|
||||
cell: ({ row }: any) => (
|
||||
<Badge variant="outline" className="font-mono bg-gray-50">
|
||||
{row.original?.key || "—"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: t("common.status", "Status"),
|
||||
cell: ({ row }: any) => (
|
||||
<Badge variant="destructive" className="bg-amber-100 text-amber-800 hover:bg-amber-100">
|
||||
{t("archive.archived", "Archived")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: t("userIncomingretun.Actions", "Actions"),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isRestoringUnit}
|
||||
onClick={() => restoreUnit(row.original.id)}>
|
||||
<ArchiveRestore className="h-4 w-4 text-emerald-600" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setUnitToDelete({
|
||||
id: row.original.id,
|
||||
name: localizedName(row.original?.name),
|
||||
});
|
||||
setDeleteConfirmOpen(true);
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const positionColumns = [
|
||||
{
|
||||
id: "name",
|
||||
header: t("organization.name", "Name"),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<UserSquare2 className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{localizedName(row.original?.name) || "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t("archive.archivedAt", "Archived")}: {new Date(row.original?.archivedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "key",
|
||||
header: t("organization.key", "Key"),
|
||||
cell: ({ row }: any) => (
|
||||
<Badge variant="outline" className="font-mono bg-gray-50">
|
||||
{row.original?.key || "—"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: t("common.status", "Status"),
|
||||
cell: ({ row }: any) => (
|
||||
<Badge variant="destructive" className="bg-amber-100 text-amber-800 hover:bg-amber-100">
|
||||
{t("archive.archived", "Archived")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: t("userIncomingretun.Actions", "Actions"),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isRestoringPosition}
|
||||
onClick={() => restorePosition(row.original.id)}
|
||||
className="gap-1.5 border-emerald-200 text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800">
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
<span>{t("archive.restore", "Restore")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const isLoading = activeTab === "units" ? isLoadingArchivedUnits : isLoadingArchivedPositions;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent">
|
||||
{t("archive.archivedItems", "Archived Items")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||
{t("archive.manageArchivedContent", "Restore or permanently delete archived content")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{activeTab === "units" ? archivedUnits.length : archivedPositions.length} {t("common.items", "items")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Buttons */}
|
||||
<div className="flex gap-2 border-b border-gray-200 bg-white rounded-t-xl p-1 shadow-sm">
|
||||
<Button
|
||||
variant={activeTab === "units" ? "default" : "ghost"}
|
||||
onClick={() => setActiveTab("units")}
|
||||
className={`flex-1 md:flex-none gap-2 transition-all ${
|
||||
activeTab === "units"
|
||||
? "shadow-md"
|
||||
: "hover:bg-gray-50"
|
||||
}`}>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span>{t("archive.archivedUnits", "Archived Units")}</span>
|
||||
{activeTab === "units" && (
|
||||
<span className="ml-1 text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{archivedUnits.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "positions" ? "default" : "ghost"}
|
||||
onClick={() => setActiveTab("positions")}
|
||||
className={`flex-1 md:flex-none gap-2 transition-all ${
|
||||
activeTab === "positions"
|
||||
? "shadow-md"
|
||||
: "hover:bg-gray-50"
|
||||
}`}>
|
||||
<UserSquare2 className="h-4 w-4" />
|
||||
<span>{t("archive.archivedPositions", "Archived Positions")}</span>
|
||||
{activeTab === "positions" && (
|
||||
<span className="ml-1 text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{archivedPositions.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
{activeTab === "units"
|
||||
? t("organization.selectOrganization", "Select Organization")
|
||||
: t("organization.selectUnit", "Select Unit")}
|
||||
</label>
|
||||
{activeTab === "units" ? (
|
||||
myAdminOrganizations.length === 0 ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedOrgId}
|
||||
onValueChange={(value) => setSelectedOrgId(value)}>
|
||||
<SelectTrigger className="w-full border-gray-300 focus:ring-2 focus:ring-primary/20">
|
||||
<SelectValue
|
||||
placeholder={t("organization.selectOrganization")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{myAdminOrganizations.map((org: any) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
{localizedName(org.name)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
) : (
|
||||
unitList.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-2">
|
||||
{isLoadingUnits ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
t("common.noData", "No units available")
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedUnitId}
|
||||
onValueChange={(value) => setSelectedUnitId(value)}>
|
||||
<SelectTrigger className="w-full border-gray-300 focus:ring-2 focus:ring-primary/20">
|
||||
<SelectValue placeholder={t("organization.selectUnit")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitList.map((unit) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
{localizedName(unit.name)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{activeTab === "units" && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{t("common.filter", "Filter")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="shadow-sm border border-gray-200 overflow-hidden">
|
||||
<CardHeader className="bg-gray-50/50 border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
{activeTab === "units" ? (
|
||||
<>
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
{t("archive.archivedUnits", "Archived Units")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserSquare2 className="h-5 w-5 text-primary-600" />
|
||||
{t("archive.archivedPositions", "Archived Positions")}
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (activeTab === "units") {
|
||||
refetchArchivedUnits();
|
||||
} else {
|
||||
refetchArchivedPositions();
|
||||
}
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700">
|
||||
<span className="sr-only">{t("common.refresh", "Refresh")}</span>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === "units" ? (
|
||||
archivedUnits.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<Building2 className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">
|
||||
{t("common.noData", "No archived units")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("archive.noArchivedUnits", "No archived units found for this organization")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={unitColumns as any}
|
||||
data={archivedUnits}
|
||||
tableName="ArchivedUnits"
|
||||
toolBarPosition="right"
|
||||
itemCount={archivedUnits.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
nextFunction={() => {}}
|
||||
prevFunction={() => {}}
|
||||
refresh={refetchArchivedUnits}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
archivedPositions.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<UserSquare2 className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">
|
||||
{t("common.noData", "No archived positions")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("archive.noArchivedPositions", "No archived positions found for this unit")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={positionColumns as any}
|
||||
data={archivedPositions}
|
||||
tableName="ArchivedPositions"
|
||||
toolBarPosition="right"
|
||||
itemCount={archivedPositions.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
nextFunction={() => {}}
|
||||
prevFunction={() => {}}
|
||||
refresh={refetchArchivedPositions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||
<Trash2 className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<AlertDialogTitle className="text-center">
|
||||
{t("archive.deletePermanentlyConfirm", "Permanently delete unit?")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-center">
|
||||
<span className="block mb-2">
|
||||
{t("archive.deletePermanentlyDescription", "This will permanently delete")}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 block text-base">
|
||||
"{unitToDelete?.name}"
|
||||
</span>
|
||||
<span className="block mt-2 text-red-600 text-sm font-medium">
|
||||
{t("archive.cannotBeUndone", "This action cannot be undone.")}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex gap-2">
|
||||
<AlertDialogCancel disabled={isDeleting} className="flex-1">
|
||||
{t("common.Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handlePermanentDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 focus:ring-red-500">
|
||||
{isDeleting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
{t("common.deleting", "Deleting...")}
|
||||
</span>
|
||||
) : (
|
||||
t("archive.deletePermanently", "Delete Permanently")
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchivedUnitsPositionsPage;
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { useEffect, useId, useState } from "react";
|
||||
import { Building2, PenLine, Settings, ShieldCheck } from "lucide-react";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
|
||||
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { t } from "i18next";
|
||||
|
||||
type UnitOption = {
|
||||
id: string;
|
||||
name?: { am: string; en: string };
|
||||
};
|
||||
|
||||
type UnitConfigurationItem = {
|
||||
id: string;
|
||||
unitId: string;
|
||||
attachSignatureOnAttachment?: boolean;
|
||||
};
|
||||
|
||||
const AttachmentSignature = () => {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const organizationId = user?.employee?.[0]?.organizationId || "";
|
||||
const localizedName = useLocalizedName();
|
||||
const switchId = useId();
|
||||
|
||||
const { getList: getUnitList } = useUnit();
|
||||
const { data: units, isLoading: isLoadingUnits } = getUnitList(
|
||||
organizationId,
|
||||
{ take: 1000 },
|
||||
);
|
||||
|
||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||
const [localAttachSignatureValue, setLocalAttachSignatureValue] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUnitId && units?.data?.items?.length) {
|
||||
setSelectedUnitId(units.data.items[0].id);
|
||||
}
|
||||
}, [selectedUnitId, units]);
|
||||
|
||||
const unitItems = (units?.data?.items ?? []) as UnitOption[];
|
||||
const hasUnits = unitItems.length > 0;
|
||||
const selectedUnitName = unitItems.find(
|
||||
(unit) => unit.id === selectedUnitId,
|
||||
)?.name;
|
||||
|
||||
const { data: unitConfigResponse, isLoading: isLoadingConfig } =
|
||||
useUnitConfiguration(selectedUnitId);
|
||||
|
||||
const unitConfigurations =
|
||||
(unitConfigResponse?.data?.items as UnitConfigurationItem[] | undefined) ||
|
||||
[];
|
||||
const filteredConfig =
|
||||
unitConfigurations.find((item) => item.unitId === selectedUnitId) ||
|
||||
unitConfigurations[0];
|
||||
|
||||
const configValue = Boolean(filteredConfig?.attachSignatureOnAttachment);
|
||||
const isSwitchEnabled = Boolean(filteredConfig?.id);
|
||||
const switchValue =
|
||||
localAttachSignatureValue === null ? configValue : localAttachSignatureValue;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalAttachSignatureValue(null);
|
||||
}, [selectedUnitId, configValue]);
|
||||
|
||||
const { mutate: updateEscalationField, isPending: isUpdatingSignature } =
|
||||
useMutation({
|
||||
mutationFn: ({ id, value }: { id: string; value: boolean }) =>
|
||||
prefixSuffixService.updateInternalPrefixSuffix(
|
||||
{
|
||||
attachSignatureOnAttachment: value,
|
||||
unitId: selectedUnitId,
|
||||
},
|
||||
id,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["unitConfig", selectedUnitId],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
setLocalAttachSignatureValue(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleSignature = (checked: boolean) => {
|
||||
if (!filteredConfig?.id || isUpdatingSignature) return;
|
||||
setLocalAttachSignatureValue(checked);
|
||||
updateEscalationField({
|
||||
id: filteredConfig.id,
|
||||
value: checked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50 to-slate-100/60 p-4 shadow-sm sm:p-6 dark:border-slate-700/80 dark:bg-gradient-to-br dark:from-slate-950 dark:via-slate-900 dark:to-slate-900">
|
||||
<div className="relative space-y-5">
|
||||
<header className="flex flex-col gap-2 border-b border-slate-200 pb-4 dark:border-slate-700 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="inline-flex w-fit items-center gap-2 rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-slate-700 ring-1 ring-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
{t("setting.attachmentSignature.badge")}
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 sm:text-2xl flex gap-2">
|
||||
<Settings className="h-8 w-8 text-primary" />
|
||||
{t("setting.attachmentSignature.title")}
|
||||
</h2>
|
||||
<p className="max-w-2xl text-sm text-slate-600 dark:text-slate-400">
|
||||
{t("setting.attachmentSignature.description")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-2 rounded-xl border border-slate-200/80 bg-white/80 p-4 backdrop-blur-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<Label className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||
{t("setting.attachmentSignature.unit")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedUnitId}
|
||||
onValueChange={(value) => setSelectedUnitId(value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-11 w-full bg-white/90 dark:border-slate-700 dark:bg-slate-900"
|
||||
aria-label={t("setting.attachmentSignature.unit")}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("setting.attachmentSignature.selectUnit")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingUnits ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
{t("setting.attachmentSignature.loadingUnits")}
|
||||
</SelectItem>
|
||||
) : hasUnits ? (
|
||||
unitItems.map((unit) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
{localizedName(unit.name)}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-units" disabled>
|
||||
{t("setting.attachmentSignature.noUnitsFound")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t("setting.attachmentSignature.unitHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-slate-200/80 bg-white/80 p-4 backdrop-blur-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor={switchId}
|
||||
className="text-sm font-medium text-slate-800 dark:text-slate-200"
|
||||
>
|
||||
{t("setting.attachmentSignature.toggleLabel")}
|
||||
</Label>
|
||||
<p
|
||||
id={`${switchId}-description`}
|
||||
className="text-xs text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{t("setting.attachmentSignature.toggleDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={switchValue}
|
||||
onCheckedChange={handleToggleSignature}
|
||||
aria-describedby={`${switchId}-description`}
|
||||
disabled={!isSwitchEnabled || isLoadingConfig || isUpdatingSignature}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed border-slate-300/80 bg-slate-50/80 px-3 py-2 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300">
|
||||
<PenLine className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
|
||||
<span>
|
||||
{t("setting.attachmentSignature.status")}{" "}
|
||||
<span className="font-semibold">
|
||||
{switchValue
|
||||
? t("profile.enabled")
|
||||
: t("profile.disabled")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{!isSwitchEnabled && !isLoadingConfig && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{t("setting.noConfigFound")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200/80 bg-white/70 px-3 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-300">
|
||||
<Building2 className="h-3.5 w-3.5 text-slate-500 dark:text-slate-400" />
|
||||
<span>
|
||||
{t("setting.attachmentSignature.activeUnit")}{" "}
|
||||
<strong className="font-semibold">
|
||||
{selectedUnitName
|
||||
? localizedName(selectedUnitName)
|
||||
: t("common.None")}
|
||||
</strong>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentSignature;
|
||||
264
apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx
Normal file
264
apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/common/ui/table";
|
||||
import { CollectionQueryDTO, fetchAuditLogs } from "@/user-management/services/api/auditService";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
interface AuditLogItem {
|
||||
id: string;
|
||||
user: string | { id: string; name: string; email?: string; phone?: string; position?: string; userType?: string; employeeId?: string; employeePositionId?: string };
|
||||
action: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const AuditLog: React.FC = () => {
|
||||
const {t} = useTranslation()
|
||||
const [logs, setLogs] = useState<AuditLogItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState("timestamp:DESC");
|
||||
const [dateRange, setDateRange] = useState<{ start?: string; end?: string }>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [status, setStatus] = useState("")
|
||||
const localizedName = useLocalizedName()
|
||||
const pageSize = 10;
|
||||
|
||||
const statuses = [
|
||||
{
|
||||
status: "Draft",
|
||||
color: "bg-gray-200",
|
||||
detail: t("landingPage.notForwarded"),
|
||||
},
|
||||
{
|
||||
status: "Submitted",
|
||||
color: "bg-purple-100",
|
||||
detail: t("landingPage.awaiting"),
|
||||
},
|
||||
{
|
||||
status: "Accepted",
|
||||
color: "bg-primary-100",
|
||||
detail: t("landingPage.accepted"),
|
||||
},
|
||||
{
|
||||
status: "Approved",
|
||||
color: "bg-primary-200",
|
||||
detail: t("landingPage.approvedSent"),
|
||||
},
|
||||
{
|
||||
status: "Adjustment",
|
||||
color: "bg-yellow-100",
|
||||
detail: t("landingPage.returned"),
|
||||
},
|
||||
{
|
||||
status: "Rejected",
|
||||
color: "bg-red-100",
|
||||
detail: t("landingPage.rejected"),
|
||||
},
|
||||
{ status: "Sent", color: "bg-primary-300", detail: t("landingPage.sent") },
|
||||
{
|
||||
status: "Returned",
|
||||
color: "bg-gray-400",
|
||||
detail: t("landingPage.returnedByOfficer"),
|
||||
},
|
||||
];
|
||||
const buildQuery = (): CollectionQueryDTO => {
|
||||
const query: CollectionQueryDTO = {
|
||||
s: "id,user,action,message,timestamp",
|
||||
o: sort,
|
||||
t: pageSize,
|
||||
sk: (page - 1) * pageSize,
|
||||
};
|
||||
|
||||
const where: string[] = [];
|
||||
|
||||
if (search) where.push(`message:ILIKE:%${search}%`);
|
||||
if (status) where.push(`status:=:${status}`);
|
||||
if (status && status !== "all") where.push(`status:=:${status}`);
|
||||
|
||||
if (dateRange.start && dateRange.end)
|
||||
where.push(`timestamp:>=:${dateRange.start}|timestamp:<=:${dateRange.end}`);
|
||||
|
||||
if (where.length) query.w = where.join("|");
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchAuditLogs(buildQuery());
|
||||
setLogs(result.items);
|
||||
setTotal(result.total ?? 0);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [search, sort, dateRange, page]);
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-background min-h-screen">
|
||||
<h1 className="text-2xl font-semibold mb-6 text-foreground">
|
||||
Audit Logs
|
||||
</h1>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="flex flex-wrap gap-3 mb-5 items-center">
|
||||
<Input
|
||||
placeholder="Search message..."
|
||||
value={search}
|
||||
onChange={(e: any) => setSearch(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
type="date"
|
||||
onChange={(e) =>
|
||||
setDateRange({ ...dateRange, start: e.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">to</span>
|
||||
<Input
|
||||
type="date"
|
||||
onChange={(e) =>
|
||||
setDateRange({ ...dateRange, end: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={sort} onValueChange={setSort}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="timestamp:DESC">Newest First</SelectItem>
|
||||
<SelectItem value="timestamp:ASC">Oldest First</SelectItem>
|
||||
<SelectItem value="user:ASC">User (A–Z)</SelectItem>
|
||||
<SelectItem value="user:DESC">User (Z–A)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
{/* ✅ Status Dropdown */}
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
t("auditLogs.filterByStatus") || "Filter by status"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("common.all") || "All"}</SelectItem>
|
||||
{statuses.map((s) => (
|
||||
<SelectItem key={s.status} value={s.status}>
|
||||
{t(`${s.status}`) || s.status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border bg-card shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-6 text-muted-foreground">
|
||||
Loading logs...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : logs.length > 0 ? (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-medium">
|
||||
{typeof log.user === "string"
|
||||
? log.user
|
||||
: typeof log.user?.name === "string"
|
||||
? log.user.name
|
||||
: localizedName(log.user?.name) || "Unknown"}
|
||||
</TableCell>
|
||||
<TableCell>{log.action}</TableCell>
|
||||
<TableCell>{log.message}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{log.timestamp && !isNaN(new Date(log.timestamp).getTime())
|
||||
? format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-6 text-muted-foreground">
|
||||
No logs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex justify-between items-center mt-5 text-sm">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((p) => Math.max(p - 1, 1))}>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="text-muted-foreground">
|
||||
Page {page} of {Math.ceil(total / pageSize) || 1}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page * pageSize >= total}
|
||||
onClick={() => setPage((p) => p + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLog;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { BulkUserUpload } from "@/user-management/bulkUpload/bulkUpload";
|
||||
|
||||
const BulkUploadPage = () => {
|
||||
return <BulkUserUpload />;
|
||||
};
|
||||
|
||||
export default BulkUploadPage;
|
||||
@@ -0,0 +1,19 @@
|
||||
import EscalationConfigurationPage from "./EscalationConfigurationPage";
|
||||
import PositionConfigurationPage from "./PositionConfigurationPage";
|
||||
import UnitConfigurationPage from "./UnitConfigurationPage";
|
||||
import AttachmentSignature from "./AttachmentSignature";
|
||||
|
||||
const ConfigurationPage = () => {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1500px] space-y-6 p-4 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<PositionConfigurationPage />
|
||||
<UnitConfigurationPage />
|
||||
<EscalationConfigurationPage />
|
||||
<AttachmentSignature />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import ContentManagement from "@/user-management/components/content/ContentManagement";
|
||||
|
||||
const ContentManagementPage = () => {
|
||||
return <ContentManagement />;
|
||||
};
|
||||
|
||||
export default ContentManagementPage;
|
||||
158
apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx
Normal file
158
apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { ActivityTimeline } from "@/super-admin/components/dashboard/components/ActivityTimeline";
|
||||
import { OrgTable } from "@/super-admin/components/dashboard/components/OrgTable";
|
||||
import { StatCard } from "@/super-admin/components/dashboard/components/StatCard";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Plus, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SmartOfficeAuditPage from "@/record-management/pages/AuditLog/SmartOfficeAuditPage";
|
||||
|
||||
const DashboardPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const pageSize = 10;
|
||||
const { t } = useTranslation();
|
||||
const { organizationsResponse, isLoading, isError, refetch } =
|
||||
useOrganizations("Org", {
|
||||
take: pageSize,
|
||||
orderBy: "createdAt",
|
||||
order: "createdAt:Desc",
|
||||
});
|
||||
|
||||
const {
|
||||
organizationsAdminsResponse,
|
||||
isLoading: isLoadingAdmins,
|
||||
isError: isAdminError,
|
||||
refetch: refetchAdmins,
|
||||
} = useOrganizations("Admin", {
|
||||
take: pageSize,
|
||||
orderBy: "updatedAt",
|
||||
order: "updatedAt: DESC",
|
||||
});
|
||||
|
||||
if (isLoading || isLoadingAdmins) {
|
||||
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">
|
||||
Loading dashboard...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || isAdminError) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<AlertCircle className="h-12 w-12 text-red-500" />
|
||||
<div className="text-red-500 font-medium">
|
||||
Error loading dashboard
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center max-w-md mb-4">
|
||||
There was an issue loading the dashboard data. This could be due to
|
||||
network issues or server problems.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
refetch();
|
||||
refetchAdmins();
|
||||
}}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("organization.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-10">
|
||||
{/* Stat Cards */}
|
||||
<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("organization.statistics")}
|
||||
</CardTitle>
|
||||
<Link to="/user-management/organizations/new">
|
||||
<Button className="bg-primary hover:bg-primary/90 text-primary-foreground px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("organization.newOrganization")}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<StatCard
|
||||
title={t("organization.totalOrganizations")}
|
||||
value={organizationsResponse?.count || 0}
|
||||
// indicator={`${0}% ↑`}
|
||||
color="text-primary-500"
|
||||
icon="building"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
navigate("/user-management/organizations");
|
||||
}}
|
||||
/>
|
||||
{/* <StatCard
|
||||
title="Pending Requests"
|
||||
value={stats.pendingRequests}
|
||||
indicator={`${stats.percentChanges.pending}% ↓`}
|
||||
color="text-red-500"
|
||||
icon="clock"
|
||||
variant="default"
|
||||
/> */}
|
||||
<StatCard
|
||||
title={t("organization.organizationAdmins")}
|
||||
value={organizationsAdminsResponse?.count || 0}
|
||||
// indicator={`${0}% ↓`}
|
||||
color="text-red-500"
|
||||
icon="user"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
navigate("/user-management/organization_admins");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Table and Activities */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-4">
|
||||
<Card className="min-w-0 shadow-none border-none bg-transparent px-0">
|
||||
<SmartOfficeAuditPage />
|
||||
</Card>
|
||||
<Card className=" min-w-0 shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("organization.recentOrganizations")}
|
||||
</CardTitle>
|
||||
<Link to="/organizations">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-primary dark:text-primary-400 text-sm px-0">
|
||||
{t("organization.viewMore")}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0 overflow-x-auto">
|
||||
<OrgTable organizations={organizationsResponse?.items || []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,412 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Edit2Icon, Settings } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/common/ui/table";
|
||||
import {
|
||||
prefixSuffixService,
|
||||
EEscalationNotificationType,
|
||||
EEscalationNotificationChannel,
|
||||
} from "@/user-management/services/api/prefixSuffixService";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { t } from "i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
const configurationCardShell =
|
||||
"relative overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50 to-slate-100/60 p-4 shadow-sm sm:p-6 dark:border-slate-700/80 dark:bg-gradient-to-br dark:from-slate-950 dark:via-slate-900 dark:to-slate-900";
|
||||
|
||||
type FormState = {
|
||||
escalationNotificationType: EEscalationNotificationType | "";
|
||||
escalationNotificationChannel: EEscalationNotificationChannel | "";
|
||||
escalationHour: number | undefined;
|
||||
urgentLetterEscalationHour: number | undefined;
|
||||
onReviewLetterEscalationHour: number | undefined;
|
||||
urgentOnReviewLetterEscalationHour: number | undefined;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
escalationNotificationType: "",
|
||||
escalationNotificationChannel: "",
|
||||
escalationHour: undefined,
|
||||
urgentLetterEscalationHour: undefined,
|
||||
onReviewLetterEscalationHour: undefined,
|
||||
urgentOnReviewLetterEscalationHour: undefined,
|
||||
};
|
||||
|
||||
const EscalationConfigurationPage = () => {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const organizationId = user?.employee?.[0]?.organizationId || "";
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const { getList: getUnitList } = useUnit();
|
||||
const { data: units, isLoading: isLoadingUnits } = getUnitList(
|
||||
organizationId,
|
||||
{ take: 1000 },
|
||||
);
|
||||
|
||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [fieldKey, setFieldKey] = useState<string | null>(null);
|
||||
const [fieldValue, setFieldValue] = useState<number | undefined>(0);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (units?.data?.items?.length > 0 && !selectedUnitId) {
|
||||
setSelectedUnitId(units?.data?.items[0].id);
|
||||
}
|
||||
}, [units]);
|
||||
|
||||
const { data, isLoading, error } = useUnitConfiguration(selectedUnitId);
|
||||
const config = data?.data?.items?.[0];
|
||||
|
||||
// Sync local form from server data whenever the config or selected unit changes
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setForm({
|
||||
escalationNotificationType: config.escalationNotificationType ?? "",
|
||||
escalationNotificationChannel: config.escalationNotificationChannel ?? "",
|
||||
escalationHour: config.escalationHour,
|
||||
urgentLetterEscalationHour: config.urgentLetterEscalationHour,
|
||||
onReviewLetterEscalationHour: config.onReviewLetterEscalationHour,
|
||||
urgentOnReviewLetterEscalationHour:
|
||||
config.urgentOnReviewLetterEscalationHour,
|
||||
});
|
||||
} else {
|
||||
setForm(emptyForm);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const { mutate: saveAll, isPending: isUpdating } = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
prefixSuffixService.updateInternalPrefixSuffix(
|
||||
{
|
||||
escalationNotificationType:
|
||||
form.escalationNotificationType || undefined,
|
||||
escalationNotificationChannel:
|
||||
form.escalationNotificationChannel || undefined,
|
||||
escalationHour: form.escalationHour,
|
||||
urgentLetterEscalationHour: form.urgentLetterEscalationHour,
|
||||
onReviewLetterEscalationHour: form.onReviewLetterEscalationHour,
|
||||
urgentOnReviewLetterEscalationHour:
|
||||
form.urgentOnReviewLetterEscalationHour,
|
||||
unitId: selectedUnitId,
|
||||
},
|
||||
id,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["unitConfig", selectedUnitId],
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("Failed to update escalation settings", err);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSaveAll = () => {
|
||||
if (!config?.id) return;
|
||||
saveAll(config.id);
|
||||
};
|
||||
|
||||
const handleEdit = (key: string, value: number | undefined) => {
|
||||
setFieldKey(key);
|
||||
setFieldValue(value);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
// Applies the edited hour value to local form state (no API call yet)
|
||||
const handleDialogApply = () => {
|
||||
if (!fieldKey) return;
|
||||
setForm((prev) => ({ ...prev, [fieldKey]: fieldValue }));
|
||||
setOpen(false);
|
||||
setFieldKey(null);
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{
|
||||
key: "escalationHour",
|
||||
label: t("setting.escalationHour"),
|
||||
value: form.escalationHour,
|
||||
},
|
||||
{
|
||||
key: "urgentLetterEscalationHour",
|
||||
label: t("setting.urgentLetterEscalationHour"),
|
||||
value: form.urgentLetterEscalationHour,
|
||||
},
|
||||
{
|
||||
key: "onReviewLetterEscalationHour",
|
||||
label: t("setting.onReviewLetterEscalationHour"),
|
||||
value: form.onReviewLetterEscalationHour,
|
||||
},
|
||||
{
|
||||
key: "urgentOnReviewLetterEscalationHour",
|
||||
label: t("setting.urgentOnReviewLetterEscalationHour"),
|
||||
value: form.urgentOnReviewLetterEscalationHour,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={configurationCardShell}>
|
||||
<div className="mb-5 flex flex-col gap-4 border-b border-slate-200 pb-4 dark:border-slate-700 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-100">
|
||||
<Settings className="h-8 w-8 text-primary" />
|
||||
{t("setting.escalationTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{t("setting.escalationDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:ml-auto sm:w-auto">
|
||||
<Select
|
||||
value={selectedUnitId}
|
||||
onValueChange={(value) => setSelectedUnitId(value)}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-full bg-white/90 dark:border-slate-700 dark:bg-slate-900 sm:w-44 [&>span]:truncate">
|
||||
<SelectValue placeholder="Select a unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dark:border-slate-700 dark:bg-slate-900">
|
||||
{isLoadingUnits ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
{t("setting.loading")}
|
||||
</SelectItem>
|
||||
) : units?.data?.items?.length > 0 ? (
|
||||
units?.data?.items.map((unit: any) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
<span className="block truncate max-w-70">{localizedName(unit.name)}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-units" disabled>
|
||||
{t("setting.noUnit")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t("setting.loadingConfig")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("setting.loadError")}
|
||||
</p>
|
||||
)}
|
||||
{!config && !isLoading && !error && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t("setting.noConfigFound")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<div className="space-y-4">
|
||||
{/* Escalation Notification Type */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t("setting.escalationNotificationType")}
|
||||
</p>
|
||||
<Select
|
||||
value={form.escalationNotificationType}
|
||||
onValueChange={(val) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
escalationNotificationType:
|
||||
val as EEscalationNotificationType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-64 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"setting.escalationNotificationTypePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dark:border-slate-700 dark:bg-slate-900">
|
||||
<SelectItem value={EEscalationNotificationType.JUMP}>
|
||||
{t("setting.escalationTypeJump")}
|
||||
</SelectItem>
|
||||
<SelectItem value={EEscalationNotificationType.NOTIFY_STEP}>
|
||||
{t("setting.escalationTypeNotifyStep")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value={EEscalationNotificationType.NOTIFY_PARENT_STEP}
|
||||
>
|
||||
{t("setting.escalationTypeNotifyParentStep")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Escalation Hours */}
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white/90 dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-slate-700">
|
||||
<TableHead className="text-slate-600 dark:text-slate-300">
|
||||
{t("setting.Name")}
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-600 dark:text-slate-300">
|
||||
{t("setting.Value")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-slate-600 dark:text-slate-300">
|
||||
{t("setting.Action")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fields.map((field) => (
|
||||
<TableRow key={field.key} className="dark:border-slate-700">
|
||||
<TableCell className="text-slate-900 dark:text-slate-100">
|
||||
{field.label}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-700 dark:text-slate-300">
|
||||
{field.value ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
onClick={() => handleEdit(field.key, field.value)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Edit2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Escalation Notification Channel */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{t("setting.escalationNotificationChannel")}
|
||||
</p>
|
||||
<Select
|
||||
value={form.escalationNotificationChannel}
|
||||
onValueChange={(val) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
escalationNotificationChannel:
|
||||
val as EEscalationNotificationChannel,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-64 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"setting.escalationNotificationChannelPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dark:border-slate-700 dark:bg-slate-900">
|
||||
<SelectItem value={EEscalationNotificationChannel.ALL}>
|
||||
{t("setting.escalationChannelAll")}
|
||||
</SelectItem>
|
||||
<SelectItem value={EEscalationNotificationChannel.EMAIL}>
|
||||
{t("setting.escalationChannelEmail")}
|
||||
</SelectItem>
|
||||
<SelectItem value={EEscalationNotificationChannel.IN_APP}>
|
||||
{t("setting.escalationChannelInApp")}
|
||||
</SelectItem>
|
||||
<SelectItem value={EEscalationNotificationChannel.SMS}>
|
||||
{t("setting.escalationChannelSms")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Single batch save */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
disabled={isUpdating}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
{isUpdating ? t("setting.saving") : t("setting.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal for editing a single hour field — applies to local form only */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="dark:border-slate-700 dark:bg-slate-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="dark:text-slate-100">
|
||||
{t("setting.edit")} {fieldKey && t(`setting.${fieldKey}`)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="dark:text-slate-400">
|
||||
{t("setting.updateEscalationMsg")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{fieldKey && t(`setting.${fieldKey}`)}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={fieldValue ?? ""}
|
||||
onChange={(e) => setFieldValue(Number(e.target.value))}
|
||||
className="dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setOpen(false)}
|
||||
className="dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDialogApply}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
{t("common.apply", "Apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EscalationConfigurationPage;
|
||||
38
apps/edr-freight-web/backoffice/src/pages/HomePage/App.tsx
Normal file
38
apps/edr-freight-web/backoffice/src/pages/HomePage/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import Header from "./Header";
|
||||
import DeputyMessage from "./DeputyMessage";
|
||||
|
||||
import Footer from "./Footer";
|
||||
import NewsSection from "./NewsSection";
|
||||
import OfficeHeadMessage from "./OfficeHeadMessage";
|
||||
import OrganizationGoal from "./OrganizationGoal";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="bg-background text-foreground">
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow space-y-12 pt-12 md:pt-16">
|
||||
<section id="OfficeHeadMessage">
|
||||
<OfficeHeadMessage />
|
||||
</section>
|
||||
|
||||
<section id="DeputyMessage">
|
||||
<DeputyMessage />
|
||||
</section>
|
||||
<section id="OrganizationGoal">
|
||||
<OrganizationGoal />
|
||||
</section>
|
||||
|
||||
<section id="NewsSection">
|
||||
<NewsSection />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useMessage } from "@/user-management/web-Management/hooks/useMessage";
|
||||
import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
|
||||
import { useTenantConfig } from "@/layout/components/TenantConfig";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const DeputyMessage = () => {
|
||||
const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [showReadMore, setShowReadMore] = useState(false);
|
||||
const [messageRef, setMessageRef] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const baseParams = {
|
||||
isActive: true,
|
||||
};
|
||||
const { items } = useMessage(baseParams);
|
||||
const localizedName = useLocalizedName();
|
||||
const { items: theme } = useTheme();
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
const deputy: any[] = items?.filter((i) => i.staffType === "deputy") ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentMessageIndex((prevIndex) =>
|
||||
prevIndex === deputy.length - 1 ? 0 : prevIndex + 1
|
||||
);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deputy.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkScreenSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check if content overflows after render
|
||||
useEffect(() => {
|
||||
if (messageRef) {
|
||||
const isOverflowing = messageRef.scrollHeight > messageRef.clientHeight;
|
||||
setShowReadMore(isOverflowing);
|
||||
}
|
||||
}, [messageRef, currentMessageIndex, deputy]);
|
||||
|
||||
const goToMessage = (index: any) => {
|
||||
setCurrentMessageIndex(index);
|
||||
};
|
||||
|
||||
const currentDeputy = deputy[currentMessageIndex];
|
||||
|
||||
const getMessageContent = () => {
|
||||
return (
|
||||
localizedName(currentDeputy?.messageContent) ||
|
||||
tenantConfig.welcomeMessage ||
|
||||
t("msg.waitingMsg")
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessagePreview = () => {
|
||||
const message = getMessageContent();
|
||||
return message.split("\n\n").map((paragraph, index) => (
|
||||
<p key={index} className="text-gray-700 text-lg leading-relaxed mb-4">
|
||||
{paragraph}
|
||||
</p>
|
||||
));
|
||||
};
|
||||
|
||||
const renderFullMessage = () => {
|
||||
const message = getMessageContent();
|
||||
return message.split("\n\n").map((paragraph, index) => (
|
||||
<p key={index} className="text-gray-700 text-lg leading-relaxed mb-4">
|
||||
{paragraph}
|
||||
</p>
|
||||
));
|
||||
};
|
||||
|
||||
function lightenColor(color: string, percent: number) {
|
||||
const R = parseInt(color.substring(1, 3), 16);
|
||||
const G = parseInt(color.substring(3, 5), 16);
|
||||
const B = parseInt(color.substring(5, 7), 16);
|
||||
|
||||
const newR = Math.min(255, R + Math.round((255 - R) * (percent / 100)));
|
||||
const newG = Math.min(255, G + Math.round((255 - G) * (percent / 100)));
|
||||
const newB = Math.min(255, B + Math.round((255 - B) * (percent / 100)));
|
||||
|
||||
return `#${newR.toString(16).padStart(2, "0")}${newG
|
||||
.toString(16)
|
||||
.padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center p-4 md:p-8"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
to bottom,
|
||||
${
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "PRIMARY_COLOR")!.value,
|
||||
30
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
},
|
||||
${
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "SECONDARY_COLOR")!.value,
|
||||
55
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
}
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="max-w-6xl w-full">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
{t("deputy.title")}
|
||||
</h1>
|
||||
<div className="w-24 h-1 bg-primary-600 mx-auto"></div>
|
||||
<p className="text-gray-600 mt-4 max-w-2xl mx-auto">
|
||||
{t("deputy.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Image Section */}
|
||||
<div className="md:w-2/5 relative">
|
||||
<img
|
||||
src={currentDeputy?.presigned || tenantConfig.logo || ""}
|
||||
alt={
|
||||
localizedName(currentDeputy?.employee?.name) ||
|
||||
tenantConfig.organizationName
|
||||
}
|
||||
className={`w-full h-64 md:h-full transition-opacity duration-500 ${
|
||||
currentDeputy?.presigned
|
||||
? "object-cover"
|
||||
: "object-contain bg-white p-6"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Name and Position Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent flex items-end p-6 md:p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-white mb-2">
|
||||
{localizedName(currentDeputy?.employee?.name) ||
|
||||
tenantConfig.organizationName}
|
||||
</h2>
|
||||
{localizedName(
|
||||
currentDeputy?.employee?.employeePositions?.[0]
|
||||
?.position?.name
|
||||
) && (
|
||||
<p className="text-gray-200 text-lg">
|
||||
{localizedName(
|
||||
currentDeputy?.employee?.employeePositions?.[0]
|
||||
?.position?.name
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Indicator Dots */}
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex space-x-2">
|
||||
{deputy.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToMessage(index)}
|
||||
className={`w-3 h-3 rounded-full transition-all duration-300 ${
|
||||
index === currentMessageIndex
|
||||
? "bg-white scale-125"
|
||||
: "bg-white/50 hover:bg-white/70"
|
||||
}`}
|
||||
aria-label={t("deputy.goToMessage", { index: index + 1 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
<button
|
||||
onClick={() =>
|
||||
goToMessage(
|
||||
currentMessageIndex === 0
|
||||
? deputy.length - 1
|
||||
: currentMessageIndex - 1
|
||||
)
|
||||
}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-white/80 hover:bg-white text-primary-600 rounded-full p-2 transition-all duration-300 hover:scale-110"
|
||||
aria-label={t("deputy.previous")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
goToMessage(
|
||||
currentMessageIndex === deputy.length - 1
|
||||
? 0
|
||||
: currentMessageIndex + 1
|
||||
)
|
||||
}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-white/80 hover:bg-white text-primary-600 rounded-full p-2 transition-all duration-300 hover:scale-110"
|
||||
aria-label={t("deputy.next")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message Section */}
|
||||
<div className="md:w-3/5 p-6 md:p-8 flex flex-col justify-center min-h-[470px]">
|
||||
<div className="mb-8 border-b border-gray-200 pb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-800">
|
||||
{localizedName(currentDeputy?.messageTitle) || ""}
|
||||
</h2>
|
||||
<p className="text-primary-600 text-sm mt-1">
|
||||
{localizedName(
|
||||
currentDeputy?.employee?.employeePositions?.at(0)
|
||||
?.position?.name
|
||||
) || t("deputy.defaultPosition")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
{/* Message content with fixed height */}
|
||||
<div
|
||||
ref={setMessageRef}
|
||||
className="max-h-64 overflow-hidden relative transition-opacity duration-500"
|
||||
>
|
||||
{renderMessagePreview()}
|
||||
{/* Gradient overlay to fade out text */}
|
||||
{showReadMore && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white to-transparent"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Read More Button */}
|
||||
{showReadMore && (
|
||||
<div className="text-center mt-4">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
to bottom,
|
||||
${
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "PRIMARY_COLOR")!.value,
|
||||
15
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
},
|
||||
${
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "SECONDARY_COLOR")!.value,
|
||||
30
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
}
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
{t("newssection.readMore") || "Read Full Message"}
|
||||
<svg
|
||||
className="ml-2 -mr-1 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-rotate indicator */}
|
||||
<div className="mt-6 flex items-center text-gray-500">
|
||||
<div className="w-full bg-gray-200 rounded-full h-1 mr-3">
|
||||
<div
|
||||
className="bg-primary-600 h-1 rounded-full transition-all duration-1000 ease-linear"
|
||||
style={{
|
||||
width: `${
|
||||
(currentMessageIndex / (deputy.length - 1)) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal for full message */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-1 bg-primary-600 mr-3"></div>
|
||||
<h3 className="text-xl font-semibold text-primary-600 uppercase tracking-wide">
|
||||
{localizedName(currentDeputy?.messageTitle) || ""}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors duration-200 p-1 rounded-full hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar flex-1">
|
||||
<div className="prose prose-lg max-w-none">
|
||||
{renderFullMessage()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-gray-800 font-semibold text-lg truncate">
|
||||
{localizedName(currentDeputy?.employee?.name)}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm truncate">
|
||||
{localizedName(
|
||||
currentDeputy?.employee?.employeePositions?.[0]
|
||||
?.position?.name
|
||||
) || t("deputy.defaultPosition")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-6 py-2 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200 flex-shrink-0"
|
||||
>
|
||||
{t("preview.close") || "Close"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeputyMessage;
|
||||
367
apps/edr-freight-web/backoffice/src/pages/HomePage/Footer.tsx
Normal file
367
apps/edr-freight-web/backoffice/src/pages/HomePage/Footer.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useContactUs } from "@/user-management/web-Management/hooks/useContactUs";
|
||||
import { useSocialMedia } from "@/user-management/web-Management/hooks/useSocialMedia";
|
||||
import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Facebook,
|
||||
Globe,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { FaTelegramPlane, FaTiktok } from "react-icons/fa";
|
||||
import { FaXTwitter } from "react-icons/fa6";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BrandColors {
|
||||
primary: string; // main accent color (e.g. var(--primary))
|
||||
gradientFrom: string;
|
||||
gradientTo: string;
|
||||
textColor: string;
|
||||
mutedText: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
const Footer = ({
|
||||
brandColors = {
|
||||
primary: "var(--primary)",
|
||||
gradientFrom: "#1f2937", // gray-900
|
||||
gradientTo: "#111827", // gray-800
|
||||
textColor: "#111827",
|
||||
mutedText: "#9ca3af", // gray-400
|
||||
borderColor: "#374151", // gray-700
|
||||
},
|
||||
}: {
|
||||
brandColors?: BrandColors;
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const baseParams = {
|
||||
isActive: true,
|
||||
};
|
||||
const { items: social } = useSocialMedia(baseParams);
|
||||
const { items: contact } = useContactUs(baseParams);
|
||||
const localizedName = useLocalizedName();
|
||||
const { items: theme, unit } = useTheme();
|
||||
|
||||
//icons
|
||||
const SOCIAL_ICONS: Record<
|
||||
string,
|
||||
{ icon: React.ElementType; color: string; labelKey: string }
|
||||
> = {
|
||||
FACEBOOK: {
|
||||
icon: Facebook,
|
||||
color: "#1877F2",
|
||||
labelKey: "landingPage.facebook",
|
||||
},
|
||||
TWITTER: {
|
||||
icon: FaXTwitter,
|
||||
color: "#000000",
|
||||
labelKey: "landingPage.twitter",
|
||||
},
|
||||
LINKEDIN: {
|
||||
icon: Linkedin,
|
||||
color: "#0077B5",
|
||||
labelKey: "landingPage.linkedin",
|
||||
},
|
||||
TELEGRAM: {
|
||||
icon: FaTelegramPlane,
|
||||
color: "#0088cc",
|
||||
labelKey: "landingPage.telegram",
|
||||
},
|
||||
TIKTOK: {
|
||||
icon: FaTiktok,
|
||||
color: "#ffffff",
|
||||
labelKey: "landingPage.tiktok",
|
||||
},
|
||||
INSTAGRAM: {
|
||||
icon: Instagram,
|
||||
color: "#E1306C",
|
||||
labelKey: "landingPage.instagram",
|
||||
}, // example
|
||||
WEBSITE: { icon: Globe, color: "#0077B5", labelKey: "landingPage.website" },
|
||||
YOUTUBE: {
|
||||
icon: Youtube,
|
||||
color: "#0077B5",
|
||||
labelKey: "landingPage.website",
|
||||
},
|
||||
};
|
||||
// === Dynamic Navigation Items ===
|
||||
const navItems: any[] = [
|
||||
{ id: "OfficeHeadMessage", label: t("nav.officeHeadMessage") },
|
||||
{ id: "DeputyMessage", label: t("nav.deputyMessage") },
|
||||
{
|
||||
id: "OrganizationGoal",
|
||||
label: t("nav.orgGoal"),
|
||||
},
|
||||
{ id: "NewsSection", label: t("nav.news") },
|
||||
];
|
||||
|
||||
// === Dynamic Social Links ===
|
||||
// Filter only active socials
|
||||
const socialLinks: any[] =
|
||||
social
|
||||
?.filter((s) => SOCIAL_ICONS[s.type])
|
||||
.map((s) => ({
|
||||
id: s.name || s.type.toLowerCase(),
|
||||
icon: SOCIAL_ICONS[s.type].icon,
|
||||
url: s.link,
|
||||
color: SOCIAL_ICONS[s.type].color,
|
||||
label: t(SOCIAL_ICONS[s.type].labelKey),
|
||||
})) ?? [];
|
||||
function lightenColor(color: string, percent: number) {
|
||||
const R = parseInt(color.substring(1, 3), 16);
|
||||
const G = parseInt(color.substring(3, 5), 16);
|
||||
const B = parseInt(color.substring(5, 7), 16);
|
||||
|
||||
const newR = Math.min(255, R + Math.round((255 - R) * (percent / 100)));
|
||||
const newG = Math.min(255, G + Math.round((255 - G) * (percent / 100)));
|
||||
const newB = Math.min(255, B + Math.round((255 - B) * (percent / 100)));
|
||||
|
||||
return `#${newR.toString(16).padStart(2, "0")}${newG
|
||||
.toString(16)
|
||||
.padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
return (
|
||||
<footer
|
||||
className="transition-colors duration-500"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
to top,
|
||||
${
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")?.value
|
||||
? lightenColor(theme.find((i) => i.type === "PRIMARY_COLOR")!.value, 5)
|
||||
: "var(--primary-300)"
|
||||
},
|
||||
${
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "PRIMARY_COLOR")!.value,
|
||||
10
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
}
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8">
|
||||
<div className="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||
{/* === Left Section === */}
|
||||
<div className="space-y-2 xl:col-span-1">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{
|
||||
width: "150px", // container width
|
||||
height: "150px", // container height
|
||||
borderRadius: "16px", // soft rounded corners, change to "50%" for circle
|
||||
backgroundColor: brandColors.primary,
|
||||
flexShrink: 0, // prevents shrinking
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
theme?.find((i) => i.type === "LOGO")?.presigned ||
|
||||
"https://www.w3.org/Icons/w3c_home"
|
||||
}
|
||||
alt={localizedName(unit?.name)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-4 text-xl md:text-2xl font-bold">
|
||||
{t("landingPage.smartOffice")}
|
||||
</span>
|
||||
<p className="text-lg" style={{ color: brandColors.mutedText }}>
|
||||
{t("landingPage.digitalTransformation")}
|
||||
</p>
|
||||
|
||||
{/* === Social Links (Dynamic) === */}
|
||||
<div className="flex space-x-6">
|
||||
{socialLinks.map(({ id, icon: Icon, url, color, label }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition-transform hover:scale-110"
|
||||
>
|
||||
<Icon
|
||||
className="h-6 w-6 transition-colors duration-300"
|
||||
style={{
|
||||
color: brandColors.mutedText,
|
||||
}}
|
||||
onMouseEnter={(e: any) =>
|
||||
(e.currentTarget.style.color = color)
|
||||
}
|
||||
onMouseLeave={(e: any) =>
|
||||
(e.currentTarget.style.color = brandColors.mutedText)
|
||||
}
|
||||
/>
|
||||
<span className="sr-only">{label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === Right Section === */}
|
||||
<div className="mt-12 xl:mt-0 xl:col-span-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* === Contact Info === */}
|
||||
<div>
|
||||
<h3
|
||||
className="text-sm font-semibold tracking-wider uppercase"
|
||||
style={{ color: brandColors.textColor }}
|
||||
>
|
||||
{t("landingPage.contactUs")}
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-4">
|
||||
<li className="flex items-center space-x-3 text-base transition-colors duration-300 cursor-pointer">
|
||||
<Mail
|
||||
className="h-5 w-5"
|
||||
style={{ color: brandColors.mutedText }}
|
||||
/>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: brandColors.textColor }}
|
||||
>
|
||||
{
|
||||
contact?.find((i) => i.type === "EMAIL" && i.isActive)
|
||||
?.value
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center space-x-3 text-base cursor-pointer">
|
||||
<Phone
|
||||
className="h-5 w-5"
|
||||
style={{ color: brandColors.mutedText }}
|
||||
/>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: brandColors.textColor }}
|
||||
>
|
||||
{
|
||||
contact?.find(
|
||||
(i) => i.type === "PHONE_NUMBER" && i.isActive
|
||||
)?.value
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center space-x-3 text-base">
|
||||
<MapPin
|
||||
className="h-5 w-5 flex-shrink-0"
|
||||
style={{ color: brandColors.mutedText }}
|
||||
/>
|
||||
|
||||
{(() => {
|
||||
const location = contact?.find(
|
||||
(i) => i.type === "LOCATION" && i.isActive
|
||||
)?.value;
|
||||
|
||||
if (!location) {
|
||||
return (
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: brandColors.textColor }}
|
||||
>
|
||||
Not available
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const shortText =
|
||||
location.length > 25
|
||||
? location.slice(0, 25) + "…"
|
||||
: location;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
|
||||
location
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium hover:underline truncate max-w-[180px] inline-block align-middle"
|
||||
style={{ color: brandColors.textColor }}
|
||||
title={location} // tooltip shows full text on hover
|
||||
>
|
||||
{shortText}
|
||||
</a>
|
||||
);
|
||||
})()}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* === Quick Links === */}
|
||||
<div>
|
||||
<h3
|
||||
className="text-sm font-semibold tracking-wider uppercase"
|
||||
style={{ color: brandColors.textColor }}
|
||||
>
|
||||
{t("landingPage.quickLinks")}
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-4 relative">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveTab(item.id);
|
||||
const el = document.getElementById(item.id);
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="relative flex items-center text-base transition-colors duration-300 cursor-pointer"
|
||||
style={{
|
||||
color:
|
||||
activeTab === item.id
|
||||
? brandColors.primary
|
||||
: brandColors.mutedText,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{activeTab === item.id && (
|
||||
<motion.div
|
||||
layoutId="activeTabIndicator"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5"
|
||||
style={{ backgroundColor: brandColors.primary }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === Bottom Section === */}
|
||||
<div
|
||||
className="mt-12 pt-8 flex flex-col items-center justify-center text-center"
|
||||
style={{ borderTop: `1px solid ${brandColors.borderColor}` }}
|
||||
>
|
||||
<p style={{ color: brandColors.mutedText }}>
|
||||
© {t("landingPage.copyright")} {new Date().getFullYear()}{" "}
|
||||
{t("landingPage.rightsReserved")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
702
apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx
Normal file
702
apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx
Normal file
@@ -0,0 +1,702 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence, Variants } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Menu,
|
||||
X,
|
||||
User,
|
||||
UserPlus as UserPen,
|
||||
Key,
|
||||
LogOut,
|
||||
Cog,
|
||||
Bolt,
|
||||
Globe,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useModules } from "@/user-management/web-Management/hooks/useModules";
|
||||
import { useDarkMode } from "@/shared/hooks/useDarkMode";
|
||||
import {
|
||||
useTenantConfig,
|
||||
resolveModuleConfig,
|
||||
} from "@/layout/components/TenantConfig";
|
||||
import {
|
||||
UI_LANGUAGE_OPTIONS,
|
||||
resolveUiLanguage,
|
||||
} from "@/shared/i18n/uiLanguages";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label?: string;
|
||||
path?: string;
|
||||
link?: string;
|
||||
requiresCompleteRegistration?: true;
|
||||
children?: NavItem[];
|
||||
name?: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const languageOptions = UI_LANGUAGE_OPTIONS;
|
||||
|
||||
const Header = () => {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { items: item } = useTheme();
|
||||
const { userDetails, logout } = useAuthUser();
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLanguage = resolveUiLanguage(i18n.language);
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
const moduleConfig = resolveModuleConfig(tenantConfig);
|
||||
const localizedName = useLocalizedName();
|
||||
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
|
||||
const baseParams = {
|
||||
isActive: true,
|
||||
};
|
||||
const { items: modules } = useModules(baseParams);
|
||||
const fullName = localizedName(userDetails?.name) || t("header.user");
|
||||
const splittedName = fullName.trim().split(" ");
|
||||
const initials =
|
||||
splittedName.length === 1
|
||||
? splittedName[0][0]
|
||||
: `${splittedName[0][0]}${splittedName[1][0]}`;
|
||||
|
||||
const changeLanguage = (lng: string) => {
|
||||
i18n.changeLanguage(lng);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem("i18nextLng")) {
|
||||
i18n.changeLanguage("am");
|
||||
localStorage.setItem("i18nextLng", "am");
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 10);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
const isSuperAdmin = userDetails?.roles?.some(
|
||||
(role) => role.key === "super_admin",
|
||||
);
|
||||
const isOrgAdmin = userDetails?.roles?.some(
|
||||
(role) => role.key === "unit_admin" || role.key === "organization_admin",
|
||||
);
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
id: "Modules",
|
||||
label: t("nav.modules"),
|
||||
children: [
|
||||
...(moduleConfig.recordManagement
|
||||
? [
|
||||
{
|
||||
id: "recordManagement",
|
||||
label: t("nav.Record Management"),
|
||||
path: "/record-management/dashboard",
|
||||
description: t("msg.recordDes"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(moduleConfig.performance
|
||||
? [
|
||||
{
|
||||
id: "performanceManagement",
|
||||
label: t("nav.PerformanceManagement"),
|
||||
path: "/performance-management/plan-years",
|
||||
description: t("nav.PerformanceManagement"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(moduleConfig.objective
|
||||
? [
|
||||
{
|
||||
id: "objectiveManagement",
|
||||
label: t("nav.objectiveManagement"),
|
||||
path: "/objective-management/plan-years",
|
||||
description: t("nav.objectiveManagement"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(moduleConfig.dms
|
||||
? [
|
||||
{
|
||||
id: "documentManagement",
|
||||
label: t("nav.DocumentManagement"),
|
||||
path: "/dms/dashboard",
|
||||
description: t("nav.DocumentManagement"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isOrgAdmin && moduleConfig.siteManagement
|
||||
? [
|
||||
{
|
||||
id: "orgAdmin",
|
||||
label: t("nav.admin"),
|
||||
path: "/user-management/user_management-dashboard",
|
||||
description: t("msg.orgAdminDes"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSuperAdmin
|
||||
? [
|
||||
{
|
||||
id: "superAdmin",
|
||||
label: t("OrganizationAdmin"),
|
||||
path: "/user-management/dashboard",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{ id: "OfficeHeadMessage", label: t("nav.officeHeadMessage") },
|
||||
{ id: "DeputyMessage", label: t("nav.deputyMessage") },
|
||||
{
|
||||
id: "OrganizationGoal",
|
||||
label: t("nav.orgGoal"),
|
||||
},
|
||||
{ id: "NewsSection", label: t("nav.news") },
|
||||
...(modules && modules.length > 0
|
||||
? [
|
||||
{
|
||||
id: "Other",
|
||||
label: t("nav.other"),
|
||||
children:
|
||||
modules?.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.label,
|
||||
link: i.link,
|
||||
description: localizedName(i.description),
|
||||
})) || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const handleNavClick = (item: NavItem) => {
|
||||
if (item.requiresCompleteRegistration && !hasCompletedRegistration) {
|
||||
toast.error(t("registration.registrationRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
navigate(item.path);
|
||||
setMobileMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.link) {
|
||||
let url = item.link.trim();
|
||||
|
||||
// 🧹 Fix common typos like "https//:" or "http//:"
|
||||
url = url.replace(/^https?\/\/:/i, "https://");
|
||||
|
||||
// 🌐 If it doesn't start with http/https, add https://
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = `https://${url.replace(/^\/+/, "")}`; // remove leading slashes
|
||||
}
|
||||
|
||||
// ✅ Finally open the link safely in a new tab
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mobileMenuVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
transition: { duration: 0.3, when: "afterChildren" },
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
height: "auto",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
when: "beforeChildren",
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mobileItemVariants: Variants = {
|
||||
hidden: { x: -20, opacity: 0 },
|
||||
visible: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transition: { type: "spring" as const, stiffness: 300, damping: 30 },
|
||||
},
|
||||
};
|
||||
|
||||
const servicesVariants = {
|
||||
hidden: { opacity: 0, y: -15, scale: 0.92 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.25, staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
const serviceItemVariants = {
|
||||
hidden: { opacity: 0, x: -10 },
|
||||
visible: { opacity: 1, x: 0 },
|
||||
};
|
||||
|
||||
const getServiceIcon = (id: string, isOrgAdmin: any, isSuperAdmin: any) => {
|
||||
switch (id) {
|
||||
case "recordManagement":
|
||||
return <FileText className="w-4 h-4" />;
|
||||
|
||||
case "orgAdmin":
|
||||
return isOrgAdmin ? <Cog className="w-4 h-4" /> : null;
|
||||
case "superAdmin":
|
||||
return isSuperAdmin ? <Bolt className="w-4 h-4" /> : null;
|
||||
|
||||
default:
|
||||
return <Globe className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentLanguageDisplay = () => {
|
||||
const currentLang = languageOptions.find(
|
||||
(lang) => lang.value === currentLanguage,
|
||||
);
|
||||
return currentLang ? currentLang.label : "English";
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? "bg-white/95 dark:bg-gray-900/95 backdrop-blur-md shadow-lg border-b border-gray-100 dark:border-gray-800"
|
||||
: "bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm shadow-sm border-b border-gray-50 dark:border-gray-800"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16 md:h-20">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<motion.img
|
||||
src={tenantConfig.logo}
|
||||
alt={tenantConfig.appName}
|
||||
className="h-10 w-15 md:h-17 md:w-25 rounded-lg object-contain cursor-pointer shadow-sm hover:shadow-md transition-shadow"
|
||||
whileHover={{ scale: 1.08 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => navigate("/")}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:flex md:gap-2 lg:gap-4 items-center flex-1 ml-8">
|
||||
{navItems.map((item) => (
|
||||
<div key={item?.id} className="relative group">
|
||||
{item?.children ? (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setOpenDropdown(item.id)}
|
||||
onMouseLeave={() => setOpenDropdown(null)}
|
||||
>
|
||||
<motion.button
|
||||
onClick={() =>
|
||||
setOpenDropdown(
|
||||
openDropdown === item.id ? null : item.id,
|
||||
)
|
||||
}
|
||||
className={`relative inline-flex items-center gap-1.5 px-3 py-2 text-sm lg:text-base font-medium transition-all duration-200 rounded-lg ${
|
||||
activeTab === item?.id
|
||||
? "text-primary bg-primary/10 dark:bg-primary/20"
|
||||
: "text-gray-700 dark:text-gray-300 hover:text-primary hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
{item?.label || localizedName(item?.name)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform duration-300 ${
|
||||
openDropdown === item.id ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{openDropdown === item.id && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
variants={servicesVariants}
|
||||
className="absolute top-full left-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-800 z-50 overflow-hidden"
|
||||
>
|
||||
<div className="py-2">
|
||||
{item?.children.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="group/child relative"
|
||||
>
|
||||
<motion.button
|
||||
variants={serviceItemVariants}
|
||||
onClick={() => handleNavClick(child)}
|
||||
className="w-full text-left hover:bg-primary/20 dark:hover:bg-primary/30 hover:text-primary transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<span className="text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5">
|
||||
{getServiceIcon(
|
||||
child.id,
|
||||
isOrgAdmin,
|
||||
isSuperAdmin,
|
||||
)}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{child.label ||
|
||||
localizedName(child.name)}
|
||||
</div>
|
||||
{child.description && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 max-h-0 overflow-hidden group-hover/child:max-h-20 transition-all duration-300">
|
||||
{child.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
setActiveTab(item?.id);
|
||||
if (item?.path) {
|
||||
handleNavClick(item);
|
||||
} else {
|
||||
const el = document.getElementById(item?.id);
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex items-center px-3 py-2 text-sm lg:text-base font-medium transition-all duration-200 rounded-lg ${
|
||||
activeTab === item?.id
|
||||
? "text-primary bg-primary/10 dark:bg-primary/20"
|
||||
: "text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
{item?.label || localizedName(item?.name)}
|
||||
{activeTab === item?.id && (
|
||||
<motion.div
|
||||
layoutId="activeTabIndicator"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.2,
|
||||
duration: 0.6,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-3 lg:gap-4">
|
||||
<motion.button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-lg text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
aria-label={
|
||||
isDarkMode ? t("header.lightMode") : t("header.darkMode")
|
||||
}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<div className="w-32 lg:w-36">
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onValueChange={(lng) => changeLanguage(lng)}
|
||||
>
|
||||
<SelectTrigger className="w-full text-sm rounded-lg border border-gray-200 px-3 py-2 shadow-sm hover:border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
|
||||
<SelectValue>{getCurrentLanguageDisplay()}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languageOptions.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 h-10 px-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label={t("header.userMenu")}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-600 text-primary-foreground font-semibold text-sm shadow-md">
|
||||
{initials.toUpperCase()}
|
||||
</div>
|
||||
<div className="hidden sm:flex flex-col items-start">
|
||||
<span className="text-xs font-semibold leading-none">
|
||||
{localizedName(userDetails?.name) || ""}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none mt-0.5">
|
||||
{userDetails?.roles && userDetails.roles.length > 0
|
||||
? "User Role"
|
||||
: "User"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56 p-2 bg-white dark:bg-gray-900 shadow-xl rounded-xl border border-gray-100 dark:border-gray-800"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
|
||||
onClick={() => navigate("/profile")}
|
||||
>
|
||||
<User className="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
||||
<span className="font-medium">
|
||||
{t("header.viewProfile")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
|
||||
onClick={() => navigate("/update-profile")}
|
||||
>
|
||||
<UserPen className="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
||||
<span className="font-medium">
|
||||
{t("header.editProfile")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
|
||||
onClick={() => navigate("/change-password")}
|
||||
>
|
||||
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
||||
<span className="font-medium">
|
||||
{t("header.changePassword")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="my-2 h-px bg-gray-100 dark:bg-gray-800" />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 rounded-lg cursor-pointer transition-colors"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span className="font-medium">{t("header.signOut")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center md:hidden gap-2">
|
||||
<motion.button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
|
||||
whileTap={{ scale: 0.9 }}
|
||||
aria-label={
|
||||
isDarkMode ? t("header.lightMode") : t("header.darkMode")
|
||||
}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all"
|
||||
aria-expanded="false"
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" />
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
variants={mobileMenuVariants}
|
||||
className="md:hidden overflow-hidden bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800 shadow-xl"
|
||||
>
|
||||
<motion.div className="pt-2 pb-4 space-y-1 px-4">
|
||||
{navItems.map((item) => (
|
||||
<div key={item?.id}>
|
||||
{item?.children ? (
|
||||
<div>
|
||||
<motion.button
|
||||
onClick={() =>
|
||||
setOpenDropdown(
|
||||
openDropdown === item.id ? null : item.id,
|
||||
)
|
||||
}
|
||||
className={`block w-full text-left px-3 py-3 rounded-lg text-base font-medium transition-all duration-200 ${
|
||||
activeTab === item?.id
|
||||
? "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
}`}
|
||||
whileHover={{ x: 4 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{item?.label || localizedName(item?.name)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${
|
||||
openDropdown === item.id ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{openDropdown === item.id && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="pl-4 overflow-hidden"
|
||||
>
|
||||
{item.children.map((child) => (
|
||||
<motion.button
|
||||
key={child.id}
|
||||
onClick={() => handleNavClick(child)}
|
||||
className="block w-full text-left px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg transition-colors"
|
||||
whileHover={{ x: 4 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary-600 dark:text-primary-400">
|
||||
{getServiceIcon(
|
||||
child.id,
|
||||
isOrgAdmin,
|
||||
isSuperAdmin,
|
||||
)}
|
||||
</span>
|
||||
{child.label || localizedName(child.name)}
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<motion.button
|
||||
variants={mobileItemVariants}
|
||||
onClick={() => {
|
||||
setActiveTab(item?.id);
|
||||
setMobileMenuOpen(false);
|
||||
if (item.path) {
|
||||
handleNavClick(item);
|
||||
}
|
||||
}}
|
||||
whileHover={{ x: 4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`block w-full text-left px-3 py-3 rounded-lg text-base font-medium transition-all duration-200 ${
|
||||
activeTab === item?.id
|
||||
? "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
}`}
|
||||
>
|
||||
{item?.label || localizedName(item?.name)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
variants={mobileItemVariants}
|
||||
className="pt-4 pb-2 border-t border-gray-100 space-y-3"
|
||||
>
|
||||
<div className="px-2">
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onValueChange={(lng) => changeLanguage(lng)}
|
||||
>
|
||||
<SelectTrigger className="w-full text-sm bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2.5 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all">
|
||||
<SelectValue>{getCurrentLanguageDisplay()}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languageOptions.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,452 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useNews } from "@/user-management/web-Management/hooks/useNews";
|
||||
import { useNewsCategory } from "@/user-management/web-Management/hooks/useNewsCategory";
|
||||
import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
|
||||
|
||||
const FeaturedNews = () => {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState("all");
|
||||
const [visibleNews, setVisibleNews] = useState(6);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState("Department");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [selectedArticle, setSelectedArticle] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (searchQuery) {
|
||||
switch (filterType) {
|
||||
case "Department":
|
||||
searchParams.sendingPosition = searchQuery;
|
||||
break;
|
||||
case "Subject":
|
||||
searchParams.subject = searchQuery;
|
||||
break;
|
||||
case "Reference Number":
|
||||
searchParams.referenceNumber = searchQuery;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const baseParams = { isActive: true };
|
||||
const baseParams2 = { isActive: true, isFeatured: true };
|
||||
|
||||
const { items: news, count: newsCount } = useNews(baseParams);
|
||||
const { items: featurednews, count: featuredCount } = useNews(baseParams2);
|
||||
const { items: newsCategory } = useNewsCategory(baseParams);
|
||||
const { items: theme } = useTheme();
|
||||
|
||||
const INITIAL_NEWS_COUNT = 6;
|
||||
const LOAD_MORE_COUNT = 3;
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => setIsMobile(window.innerWidth < 768);
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, []);
|
||||
|
||||
const loadMoreNews = () => setVisibleNews((prev) => prev + LOAD_MORE_COUNT);
|
||||
const showLessNews = () => setVisibleNews(INITIAL_NEWS_COUNT);
|
||||
|
||||
const openArticleModal = (article: any) => {
|
||||
setSelectedArticle(article);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeArticleModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedArticle(null);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
function lightenColor(color: string, percent: number) {
|
||||
const R = parseInt(color.substring(1, 3), 16);
|
||||
const G = parseInt(color.substring(3, 5), 16);
|
||||
const B = parseInt(color.substring(5, 7), 16);
|
||||
|
||||
const newR = Math.min(255, R + Math.round((255 - R) * (percent / 100)));
|
||||
const newG = Math.min(255, G + Math.round((255 - G) * (percent / 100)));
|
||||
const newB = Math.min(255, B + Math.round((255 - B) * (percent / 100)));
|
||||
|
||||
return `#${newR.toString(16).padStart(2, "0")}${newG
|
||||
.toString(16)
|
||||
.padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const renderArticleContent = (content: string) => {
|
||||
if (!content) return null;
|
||||
|
||||
return content.split("\n\n").map((paragraph, index) => (
|
||||
<p key={index} className="text-gray-700 leading-relaxed mb-4">
|
||||
{paragraph}
|
||||
</p>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="min-h-screen py-12 px-4 sm:px-6 lg:px-8"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
to bottom,
|
||||
${
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "PRIMARY_COLOR")!.value,
|
||||
30
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
},
|
||||
${
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "SECONDARY_COLOR")!.value,
|
||||
55
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
}
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
|
||||
{t("newssection.featuredNews")}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
{t("newssection.description")}
|
||||
</p>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-blue-500 to-purple-600 mx-auto mt-6 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="mb-12 bg-white rounded-2xl shadow-lg p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("newssection.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{newsCategory?.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
setStatusFilter(category.newsCategoryTitle?.en || "");
|
||||
setVisibleNews(INITIAL_NEWS_COUNT);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-300 ${
|
||||
activeCategory === category.newsCategoryTitle?.en
|
||||
? "bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-lg"
|
||||
: "bg-white text-gray-700 border border-gray-300 hover:border-blue-500"
|
||||
}`}
|
||||
>
|
||||
{localizedName(category.newsCategoryTitle).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured News */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{t("newssection.topStories")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{featurednews?.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl group"
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={article.presigned}
|
||||
alt={localizedName(article.title)}
|
||||
className="w-full h-64 object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute top-4 left-4">
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-xs font-semibold"
|
||||
style={{
|
||||
backgroundColor: article.newsCategory?.color || "",
|
||||
}}
|
||||
>
|
||||
{localizedName(article.newsCategory?.newsCategoryTitle)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div className="absolute bottom-4 left-4 right-4 text-white">
|
||||
<h3 className="text-xl font-bold mb-2 line-clamp-2">
|
||||
{localizedName(article?.title)}
|
||||
</h3>
|
||||
<div className="flex items-center text-sm text-gray-200">
|
||||
<span>{formatDate(article?.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 mb-4 line-clamp-3">
|
||||
{localizedName(article?.subTitle)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => openArticleModal(article)}
|
||||
className="text-blue-600 font-semibold hover:text-blue-700 transition-colors flex items-center"
|
||||
>
|
||||
{t("newssection.readFullStory")}
|
||||
<svg
|
||||
className="ml-1 w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* News Grid */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{activeCategory === "all"
|
||||
? t("newssection.allNews")
|
||||
: `${
|
||||
activeCategory.charAt(0).toUpperCase() +
|
||||
activeCategory.slice(1)
|
||||
} ${t("newssection.allNews")}`}
|
||||
<span className="text-gray-500 text-lg ml-2">({newsCount})</span>
|
||||
</h2>
|
||||
|
||||
{newsCount === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-xl font-semibold text-gray-600 mb-2">
|
||||
{t("newssection.noNewsFound")}
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
{t("newssection.noNewsFoundDesc")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{news?.slice(0, visibleNews).map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="bg-white rounded-xl shadow-md overflow-hidden transition-all duration-300 hover:shadow-lg hover:scale-105 group"
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={article.presigned}
|
||||
alt={localizedName(article?.title)}
|
||||
className="w-full h-48 object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute top-3 left-3">
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-xs font-semibold"
|
||||
style={{
|
||||
backgroundColor:
|
||||
article.newsCategory?.color || "var(--primary-100)",
|
||||
}}
|
||||
>
|
||||
{localizedName(
|
||||
article?.newsCategory?.newsCategoryTitle
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center text-sm text-gray-500 mb-2">
|
||||
<span>{formatDate(article.updatedAt)}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-blue-600 transition-colors">
|
||||
{localizedName(article.title)}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-3">
|
||||
{localizedName(article?.subTitle)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => openArticleModal(article)}
|
||||
className="text-blue-600 text-sm font-semibold hover:text-blue-700 transition-colors flex items-center"
|
||||
>
|
||||
{t("newssection.readMore")}
|
||||
<svg
|
||||
className="ml-1 w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(newsCount || 0) > INITIAL_NEWS_COUNT && (
|
||||
<div className="text-center mt-8 flex justify-center gap-4">
|
||||
{visibleNews < (newsCount || 0) && (
|
||||
<button
|
||||
onClick={loadMoreNews}
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 text-white px-8 py-3 rounded-xl font-semibold hover:shadow-lg transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
{t("newssection.loadMoreNews")} (
|
||||
{(newsCount || 0) - visibleNews} remaining)
|
||||
</button>
|
||||
)}
|
||||
{visibleNews > INITIAL_NEWS_COUNT && (
|
||||
<button
|
||||
onClick={showLessNews}
|
||||
className="bg-gray-200 text-gray-700 px-8 py-3 rounded-xl font-semibold hover:bg-gray-300 transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
{t("newssection.showLess")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* News Article Modal */}
|
||||
{isModalOpen && selectedArticle && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="relative">
|
||||
<img
|
||||
src={selectedArticle.presigned}
|
||||
alt={localizedName(selectedArticle.title)}
|
||||
className="w-full h-64 object-cover"
|
||||
/>
|
||||
<div className="absolute top-4 left-4">
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-xs font-semibold text-white"
|
||||
style={{
|
||||
backgroundColor:
|
||||
selectedArticle.newsCategory?.color || "rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
{localizedName(
|
||||
selectedArticle.newsCategory?.newsCategoryTitle
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeArticleModal}
|
||||
className="absolute top-4 right-4 text-white bg-black/50 hover:bg-black/70 rounded-full p-2 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute bottom-4 left-4 right-4 text-white">
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-2">
|
||||
{localizedName(selectedArticle.title)}
|
||||
</h2>
|
||||
<div className="flex items-center text-sm text-gray-200">
|
||||
<span>{formatDate(selectedArticle.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar flex-1">
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
{localizedName(selectedArticle.subTitle)}
|
||||
</h3>
|
||||
{renderArticleContent(
|
||||
localizedName(selectedArticle.content) ||
|
||||
localizedName(selectedArticle.subTitle)
|
||||
)}
|
||||
|
||||
{/* Additional article details */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-semibold">Published: </span>
|
||||
{formatDate(selectedArticle.createdAt)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Last Updated: </span>
|
||||
{formatDate(selectedArticle.updatedAt)}
|
||||
</div>
|
||||
{selectedArticle.referenceNumber && (
|
||||
<div>
|
||||
<span className="font-semibold">Reference: </span>
|
||||
{selectedArticle.referenceNumber}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||
<div className="p-4 text-center">
|
||||
<button
|
||||
onClick={closeArticleModal}
|
||||
className="px-6 py-2 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
|
||||
>
|
||||
{t("preview.close") || "Close"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedNews;
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useMessage } from "@/user-management/web-Management/hooks/useMessage";
|
||||
import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
|
||||
import { useTenantConfig } from "@/layout/components/TenantConfig";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const OfficeHeadMessage = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [showReadMore, setShowReadMore] = useState(false);
|
||||
const [messageRef, setMessageRef] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const baseParams = {
|
||||
isActive: true,
|
||||
};
|
||||
const { items } = useMessage(baseParams);
|
||||
const localizedName = useLocalizedName();
|
||||
const { items: theme } = useTheme();
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
const officeHead = items?.find((i) => i.staffType === "office_head");
|
||||
|
||||
const isPersonPhoto = !!officeHead?.presigned;
|
||||
const imageSrc = officeHead?.presigned || tenantConfig.logo || "";
|
||||
const displayName =
|
||||
localizedName(officeHead?.employee?.name) || tenantConfig.organizationName;
|
||||
const displayPosition = localizedName(
|
||||
officeHead?.employee?.employeePositions?.[0]?.position?.name
|
||||
) || "";
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkScreenSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check if content overflows after render
|
||||
useEffect(() => {
|
||||
if (messageRef) {
|
||||
const isOverflowing = messageRef.scrollHeight > messageRef.clientHeight;
|
||||
setShowReadMore(isOverflowing);
|
||||
}
|
||||
}, [messageRef, officeHead]);
|
||||
|
||||
const getMessageContent = () => {
|
||||
return (
|
||||
localizedName(officeHead?.messageContent) ||
|
||||
tenantConfig.welcomeMessage ||
|
||||
t("msg.waitingMsg")
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessagePreview = () => {
|
||||
const message = getMessageContent();
|
||||
return message.split("\n\n").map((paragraph, index) => (
|
||||
<p key={index} className="text-gray-700 text-lg leading-relaxed mb-4">
|
||||
{paragraph}
|
||||
</p>
|
||||
));
|
||||
};
|
||||
|
||||
const renderFullMessage = () => {
|
||||
const message = getMessageContent();
|
||||
return message.split("\n\n").map((paragraph, index) => (
|
||||
<p key={index} className="text-gray-700 text-lg leading-relaxed mb-4">
|
||||
{paragraph}
|
||||
</p>
|
||||
));
|
||||
};
|
||||
|
||||
function lightenColor(color: string, percent: number) {
|
||||
const R = parseInt(color.substring(1, 3), 16);
|
||||
const G = parseInt(color.substring(3, 5), 16);
|
||||
const B = parseInt(color.substring(5, 7), 16);
|
||||
const newR = Math.min(255, R + Math.round((255 - R) * (percent / 100)));
|
||||
const newG = Math.min(255, G + Math.round((255 - G) * (percent / 100)));
|
||||
const newB = Math.min(255, B + Math.round((255 - B) * (percent / 100)));
|
||||
return `#${newR.toString(16).padStart(2, "0")}${newG
|
||||
.toString(16)
|
||||
.padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center pt-1 pb-4 px-4 md:pt-1 md:pb-10 md:px-10"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
to bottom,
|
||||
${lightenColor(
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")?.value ||
|
||||
tenantConfig.primaryColor,
|
||||
30,
|
||||
)},
|
||||
${lightenColor(
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")?.value ||
|
||||
tenantConfig.secondaryColor ||
|
||||
tenantConfig.primaryColor,
|
||||
55,
|
||||
)}
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="max-w-6xl w-full">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
{t("officeHead.title")}
|
||||
</h1>
|
||||
<div className="w-24 h-1 bg-indigo-600 mx-auto"></div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Image / Logo Section */}
|
||||
<div className="md:w-2/5 relative">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={displayName}
|
||||
className={`w-full h-64 md:h-full transition-opacity duration-500 ${
|
||||
isPersonPhoto
|
||||
? "object-cover"
|
||||
: "object-contain bg-white p-6"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Name and Position Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent flex items-end p-6 md:p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-white mb-2">
|
||||
{displayName}
|
||||
</h2>
|
||||
{displayPosition && (
|
||||
<p className="text-gray-200 text-lg">{displayPosition}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Element */}
|
||||
<div className="absolute top-6 right-6 bg-white text-indigo-600 rounded-full p-2 shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Section */}
|
||||
<div className="md:w-3/5 p-6 md:p-8 flex flex-col justify-center min-h-[470px]">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="w-10 h-1 bg-indigo-600 mr-3"></div>
|
||||
<h3 className="text-lg font-semibold text-indigo-600 uppercase tracking-wide">
|
||||
{localizedName(officeHead?.messageTitle) ||
|
||||
t("officeHead.subtitle")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Message content with fixed height */}
|
||||
<div
|
||||
ref={setMessageRef}
|
||||
className="max-h-64 overflow-hidden relative"
|
||||
>
|
||||
{renderMessagePreview()}
|
||||
{/* Gradient overlay to fade out text */}
|
||||
{showReadMore && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white to-transparent"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Read More Button */}
|
||||
{showReadMore && (
|
||||
<div className="text-center mt-4">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
to bottom,
|
||||
${lightenColor(
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")
|
||||
?.value || tenantConfig.primaryColor,
|
||||
15,
|
||||
)},
|
||||
${lightenColor(
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")
|
||||
?.value ||
|
||||
tenantConfig.secondaryColor ||
|
||||
tenantConfig.primaryColor,
|
||||
30,
|
||||
)}
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
{t("newssection.readMore") || "Read Full Message"}
|
||||
<svg
|
||||
className="ml-2 -mr-1 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signature Section */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-gray-800 font-semibold text-xl">
|
||||
{displayName}
|
||||
</p>
|
||||
{displayPosition && (
|
||||
<p className="text-gray-600">{displayPosition}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal for full message */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-1 bg-indigo-600 mr-3"></div>
|
||||
<h3 className="text-xl font-semibold text-indigo-600 uppercase tracking-wide">
|
||||
{localizedName(officeHead?.messageTitle) ||
|
||||
t("officeHead.subtitle")}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors duration-200 p-1 rounded-full hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar flex-1">
|
||||
<div className="prose prose-lg max-w-none">
|
||||
{renderFullMessage()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer - Fixed layout to prevent cutting */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 flex-shrink-0">
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{" "}
|
||||
{/* Added min-w-0 to prevent text overflow */}
|
||||
<p className="text-gray-800 font-semibold text-lg truncate">
|
||||
{localizedName(officeHead?.employee?.name)}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm truncate">
|
||||
{localizedName(
|
||||
officeHead?.employee?.employeePositions?.[0]?.position
|
||||
?.name
|
||||
) || t("officeHead.defaultPosition")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-6 py-2 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200 flex-shrink-0"
|
||||
>
|
||||
{t("preview.close") || "Close"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OfficeHeadMessage;
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useGoal } from "@/user-management/web-Management/hooks/useGoal";
|
||||
import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
|
||||
import { useVision } from "@/user-management/web-Management/hooks/useVision";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const OrganizationGoals = () => {
|
||||
const [currentGoalIndex, setCurrentGoalIndex] = useState(0);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const localizedName = useLocalizedName();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const baseParams = {
|
||||
isActive: true,
|
||||
};
|
||||
const { items } = useGoal(baseParams);
|
||||
const { items: theme, unit } = useTheme();
|
||||
const { items: vision } = useVision(baseParams);
|
||||
const goals: any[] = items ?? [];
|
||||
|
||||
// Auto-rotate goals
|
||||
useEffect(() => {
|
||||
if (items?.length) {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentGoalIndex((prevIndex) =>
|
||||
prevIndex === goals?.length - 1 ? 0 : prevIndex + 1
|
||||
);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [goals?.length, items]);
|
||||
|
||||
// Responsive
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, []);
|
||||
|
||||
const goToGoal = (index: any) => {
|
||||
setCurrentGoalIndex(index);
|
||||
};
|
||||
|
||||
const currentGoal = goals[currentGoalIndex];
|
||||
|
||||
function lightenColor(color: string, percent: number) {
|
||||
const R = parseInt(color.substring(1, 3), 16);
|
||||
const G = parseInt(color.substring(3, 5), 16);
|
||||
const B = parseInt(color.substring(5, 7), 16);
|
||||
const newR = Math.min(255, R + Math.round((255 - R) * (percent / 100)));
|
||||
const newG = Math.min(255, G + Math.round((255 - G) * (percent / 100)));
|
||||
const newB = Math.min(255, B + Math.round((255 - B) * (percent / 100)));
|
||||
return `#${newR.toString(16).padStart(2, "0")}${newG
|
||||
.toString(16)
|
||||
.padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center p-4 md:p-8"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
to bottom,
|
||||
${
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "PRIMARY_COLOR")!.value,
|
||||
30
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
},
|
||||
${
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")?.value
|
||||
? lightenColor(
|
||||
theme.find((i) => i.type === "SECONDARY_COLOR")!.value,
|
||||
55
|
||||
)
|
||||
: "var(--primary-300)"
|
||||
}
|
||||
)`,
|
||||
}}>
|
||||
<div className="max-w-6xl w-full">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex flex-col md:flex-row items-center justify-center mb-6">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="overflow-hidden flex items-center justify-center shadow-lg mb-4 md:mb-0 md:mr-6"
|
||||
style={{
|
||||
width: "150px",
|
||||
height: "150px",
|
||||
borderRadius: "16px", // change to "50%" for full circle
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<img
|
||||
src={
|
||||
theme?.find((i) => i.type === "LOGO")?.presigned ||
|
||||
"https://www.w3.org/Icons/w3c_home"
|
||||
}
|
||||
alt={localizedName(unit?.name)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="max-w-xs md:max-w-md break-words text-center md:text-left">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-800 mb-2 leading-tight">
|
||||
{localizedName(unit?.name)}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative line */}
|
||||
<div
|
||||
className="w-32 h-1 mx-auto rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")?.value ||
|
||||
"#3b82f6"
|
||||
}, ${
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")?.value ||
|
||||
"#9333ea"
|
||||
})`,
|
||||
}}></div>
|
||||
</div>
|
||||
|
||||
{/* Goals */}
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 min-h-[470px] hover:shadow-2xl mb-12">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="md:w-2/3 p-6 md:p-8 flex flex-col justify-center">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center mr-4 overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")
|
||||
?.value || "#32CD32"
|
||||
}, ${
|
||||
theme?.find((i) => i.type === "SECONDARY_COLOR")
|
||||
?.value || "#FFFFFF"
|
||||
})`,
|
||||
}}>
|
||||
<img
|
||||
src={
|
||||
currentGoal?.presigned ||
|
||||
"https://www.w3.org/Icons/w3c_home"
|
||||
}
|
||||
alt="Goal Icon"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-600 uppercase tracking-wide">
|
||||
{t("Strategic Goal")} {currentGoalIndex + 1} {t("of")}{" "}
|
||||
{goals?.length}
|
||||
</h3>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-gray-800 mt-1">
|
||||
{localizedName(currentGoal?.goalTitle)}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[120px] flex items-center">
|
||||
<p className="text-gray-700 text-lg leading-relaxed transition-opacity duration-500">
|
||||
{localizedName(currentGoal?.goalDescription) ||
|
||||
t("msg.waitingMsg")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-gray-600 text-sm">
|
||||
{t("Our Strategic Goals")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mb-4">
|
||||
{goals?.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToGoal(index)}
|
||||
className={`flex-1 h-2 rounded-full transition-all duration-300 ${
|
||||
index !== currentGoalIndex
|
||||
? "bg-gray-200 hover:bg-gray-300"
|
||||
: ""
|
||||
}`}
|
||||
style={
|
||||
index === currentGoalIndex
|
||||
? {
|
||||
background: `linear-gradient(to right, ${
|
||||
theme?.find((i) => i.type === "PRIMARY_COLOR")
|
||||
?.value || "#00FF00"
|
||||
})`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
aria-label={t("Go to goal", {
|
||||
index: index + 1,
|
||||
title: localizedName(goals[index].goalTitle),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() =>
|
||||
goToGoal(
|
||||
currentGoalIndex === 0
|
||||
? goals?.length - 1
|
||||
: currentGoalIndex - 1
|
||||
)
|
||||
}
|
||||
className="flex items-center text-gray-600 hover:text-gray-800 transition-colors">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t("Previous Goal")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
goToGoal(
|
||||
currentGoalIndex === goals?.length - 1
|
||||
? 0
|
||||
: currentGoalIndex + 1
|
||||
)
|
||||
}
|
||||
className="flex items-center text-gray-600 hover:text-gray-800 transition-colors">
|
||||
{t("Next Goal")}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 ml-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vision Section */}
|
||||
<div className="md:w-1/3 bg-gradient-to-br from-blue-50 to-indigo-100 p-6 md:p-8 flex flex-col justify-center items-center">
|
||||
<div className="text-center">
|
||||
<div className="w-32 h-32 mx-auto mb-6 bg-white rounded-2xl shadow-lg overflow-hidden">
|
||||
<img
|
||||
src={
|
||||
currentGoal?.presigned ||
|
||||
"https://www.w3.org/Icons/w3c_home"
|
||||
}
|
||||
alt="Vision Image"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">
|
||||
{localizedName(vision?.[0]?.visionTitle)}
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{localizedName(vision?.[0]?.visionDescription)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationGoals;
|
||||
16
apps/edr-freight-web/backoffice/src/pages/LoginPage.tsx
Normal file
16
apps/edr-freight-web/backoffice/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { RootRedirect } from "@/routes/RootRedirect";
|
||||
import { Login } from "@/shared/components/login/Login";
|
||||
import SmartOfficeLoader from "@/shared/components/SmartOfficeLoader";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { Root } from "@radix-ui/react-slot";
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return <SmartOfficeLoader label="Loading SmartOffice..." />;
|
||||
}
|
||||
if (user) {
|
||||
return <RootRedirect />;
|
||||
}
|
||||
return <Login />;
|
||||
};
|
||||
351
apps/edr-freight-web/backoffice/src/pages/OrgAdminDashboard.tsx
Normal file
351
apps/edr-freight-web/backoffice/src/pages/OrgAdminDashboard.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Users,
|
||||
FileText,
|
||||
Building2,
|
||||
Files,
|
||||
Upload,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useOrganizationReport } from "@/shared/hooks/useOrganizationReport";
|
||||
import { Alert, AlertDescription } from "@/shared/common/ui/alert";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { t } from "i18next";
|
||||
import SmartOfficeAuditPage from "@/record-management/pages/AuditLog/SmartOfficeAuditPage";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import type { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useUnitReport } from "@/shared/hooks/useUnitReport";
|
||||
import {
|
||||
useTenantConfig,
|
||||
resolveModuleConfig,
|
||||
} from "@/layout/components/TenantConfig";
|
||||
import { hasComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
|
||||
import { COMPLAINT_RECORDS_PATH } from "@/complaints/utils/complaintRoutes";
|
||||
|
||||
const MODULE_PATHS: Record<string, string> = {
|
||||
recordManagement: "/record-management/dashboard",
|
||||
performance: "/performance-management/plan-years",
|
||||
objective: "/objective-management/plan-years",
|
||||
dms: "/dms/dashboard",
|
||||
};
|
||||
|
||||
const OrgAdminDashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
const localizedName = useLocalizedName();
|
||||
const organizationId = user?.employee?.[0]?.organizationId ?? "";
|
||||
const isOrganizationAdmin =
|
||||
user?.roles?.some((role) => role.key === "organization_admin") ?? false;
|
||||
const hasUnitAdminRole =
|
||||
user?.roles?.some((role) => role.key === "unit_admin") ?? false;
|
||||
const isUnitAdmin = !isOrganizationAdmin && hasUnitAdminRole;
|
||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||
const { getAccessibleList } = useUnit();
|
||||
const { data: unitsResponse, isLoading: isUnitsLoading } = getAccessibleList(
|
||||
organizationId,
|
||||
{ take: 1000, skip: 0 },
|
||||
isUnitAdmin && !!organizationId,
|
||||
);
|
||||
const units = (unitsResponse?.data?.items ?? []) as UnitDto[];
|
||||
|
||||
useEffect(() => {
|
||||
if (!units.some((unit) => unit.id === selectedUnitId)) {
|
||||
setSelectedUnitId(units[0]?.id ?? "");
|
||||
}
|
||||
}, [selectedUnitId, units]);
|
||||
|
||||
const { report, isLoading, isError, error, refetch } =
|
||||
useOrganizationReport(organizationId, { enabled: !isUnitAdmin });
|
||||
const unitReportQuery = useUnitReport(organizationId, selectedUnitId, {
|
||||
enabled: isUnitAdmin,
|
||||
});
|
||||
const activeReport = isUnitAdmin ? unitReportQuery.data : report;
|
||||
const isDashboardLoading = isUnitAdmin
|
||||
? isUnitsLoading || (!!selectedUnitId && unitReportQuery.isLoading)
|
||||
: isLoading;
|
||||
const isDashboardError = isUnitAdmin
|
||||
? unitReportQuery.isError
|
||||
: isError;
|
||||
const dashboardError = isUnitAdmin ? unitReportQuery.error : error;
|
||||
const refetchDashboard = () =>
|
||||
isUnitAdmin ? unitReportQuery.refetch() : refetch();
|
||||
|
||||
const statusCode: number | undefined = (dashboardError as any)?.response
|
||||
?.status;
|
||||
const isUnauthorized =
|
||||
isDashboardError && (statusCode === 401 || statusCode === 403);
|
||||
|
||||
const fallbackPath = (() => {
|
||||
if (hasComplaintVerification()) return COMPLAINT_RECORDS_PATH;
|
||||
|
||||
const modules = resolveModuleConfig(tenantConfig);
|
||||
const enabledModulePaths = Object.entries(MODULE_PATHS)
|
||||
.filter(([key]) => modules[key as keyof typeof modules])
|
||||
.map(([, path]) => path);
|
||||
|
||||
if (enabledModulePaths.length === 1) return enabledModulePaths[0];
|
||||
return "/homepage";
|
||||
})();
|
||||
|
||||
if (isUnauthorized) {
|
||||
return <Navigate to={fallbackPath} replace />;
|
||||
}
|
||||
|
||||
// Define stats structure for the dashboard
|
||||
const getStats = () => [
|
||||
// One brand hue, stepped depth per tile — identity comes from the icon
|
||||
// and title, so the chips stay cohesive instead of competing hues.
|
||||
{
|
||||
id: "employees",
|
||||
title: t("dashboard.totalEmployees"),
|
||||
value: activeReport?.employeesCount?.toLocaleString() || "0",
|
||||
icon: Users,
|
||||
color: "from-primary-500 to-primary-600",
|
||||
},
|
||||
...(!isUnitAdmin
|
||||
? [{
|
||||
id: "units",
|
||||
title: t("dashboard.totalUnits"),
|
||||
value: report?.unitsCount?.toLocaleString() || "0",
|
||||
icon: Building2,
|
||||
color: "from-primary-600 to-primary-700",
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
id: "positions",
|
||||
title: t("dashboard.totalPositions"),
|
||||
value: activeReport?.positionsCount?.toLocaleString() || "0",
|
||||
icon: FileText,
|
||||
color: "from-primary-700 to-primary-800",
|
||||
},
|
||||
];
|
||||
|
||||
// Use report activities if available, otherwise use static data
|
||||
|
||||
// Brand gradient for all navigation actions; archive alone stays neutral
|
||||
// gray to read as the "dormant" destination. Icons carry the identity.
|
||||
const quickActions = [
|
||||
{
|
||||
id: "user-mgmt",
|
||||
title: t("dashboard.userManagement"),
|
||||
description: t("dashboard.userManagementDesc"),
|
||||
icon: Users,
|
||||
action: () => navigate("/user-management/user_management"),
|
||||
color: "bg-gradient-to-r from-primary-500 to-primary-700",
|
||||
},
|
||||
{
|
||||
id: "content-mgmt",
|
||||
title: t("dashboard.contentManagement"),
|
||||
description: t("dashboard.contentManagementDesc"),
|
||||
icon: Files,
|
||||
action: () => navigate("/user-management/content-management"),
|
||||
color: "bg-gradient-to-r from-primary-500 to-primary-700",
|
||||
},
|
||||
{
|
||||
id: "excel-upload",
|
||||
title: t("dashboard.excelUploader"),
|
||||
description: t("dashboard.excelUploaderDesc"),
|
||||
icon: Upload,
|
||||
action: () => navigate("/user-management/bulk-upload"),
|
||||
color: "bg-gradient-to-r from-primary-500 to-primary-700",
|
||||
},
|
||||
{
|
||||
id: "position-settings",
|
||||
title: t("dashboard.positionSettings"),
|
||||
description: t("dashboard.positionSettingsDesc"),
|
||||
icon: FileText,
|
||||
action: () => navigate("/user-management/position-management"),
|
||||
color: "bg-gradient-to-r from-primary-500 to-primary-700",
|
||||
},
|
||||
{
|
||||
id: "archive-users",
|
||||
title: t("dashboard.archiveUsers"),
|
||||
description: t("dashboard.archiveUsersDesc"),
|
||||
icon: AlertCircle,
|
||||
action: () => navigate("/user-management/archives"),
|
||||
color: "bg-gradient-to-r from-gray-500 to-slate-600",
|
||||
},
|
||||
{
|
||||
id: "web-management",
|
||||
title: t("dashboard.webManagement"),
|
||||
description: t("dashboard.webManagementDesc"),
|
||||
icon: FileText,
|
||||
action: () => navigate("/user-management/web-management"),
|
||||
color: "bg-gradient-to-r from-primary-500 to-primary-700",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{isUnitAdmin
|
||||
? t("dashboard.unitDashboard", "Unit Dashboard")
|
||||
: t("dashboard.organizationDashboard")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground dark:text-gray-400">
|
||||
{t("dashboard.orgMsg")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isUnitAdmin && (
|
||||
<Select
|
||||
value={selectedUnitId}
|
||||
onValueChange={setSelectedUnitId}
|
||||
disabled={isUnitsLoading || units.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-[260px]">
|
||||
<SelectValue
|
||||
placeholder={t("dashboard.selectUnit", "Select unit")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((unit) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
{localizedName(unit.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refetchDashboard}
|
||||
disabled={isDashboardLoading || (isUnitAdmin && !selectedUnitId)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${
|
||||
isDashboardLoading ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
{t("dashboard.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{isDashboardError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("dashboard.errorMsg")}
|
||||
{dashboardError instanceof Error && `: ${dashboardError.message}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{isDashboardLoading
|
||||
? // Loading skeletons for stats
|
||||
Array(isUnitAdmin ? 2 : 3)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Card
|
||||
key={`skeleton-stat-${index}`}
|
||||
className="hover:shadow-lg transition-shadow duration-200 dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-full">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
: getStats().map((stat) => (
|
||||
<Card
|
||||
key={`stat-${stat.id}`}
|
||||
className="hover:shadow-lg transition-shadow duration-200 border-l-4 border-l-primary-500 dark:bg-gray-800 dark:border-gray-700 dark:border-l-primary-500"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground dark:text-gray-400">
|
||||
{stat.title}
|
||||
</p>
|
||||
<h3 className="text-3xl font-bold mt-2 text-gray-900 dark:text-gray-100">
|
||||
{stat.value}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className={`p-4 rounded-full bg-gradient-to-r ${stat.color} shadow-lg`}
|
||||
>
|
||||
<stat.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="dark:bg-gray-800 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center dark:text-gray-100">
|
||||
<RefreshCw className="h-5 w-5 mr-2" />
|
||||
{t("landingPage.quickActions")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={`action-${action.id}`}
|
||||
onClick={action.action}
|
||||
className={`h-auto p-6 ${action.color} text-white hover:opacity-90 hover:scale-105 transition-all duration-200`}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<action.icon className="h-8 w-8" />
|
||||
<div>
|
||||
<div className="font-semibold text-base">
|
||||
{action.title}
|
||||
</div>
|
||||
<div className="text-sm opacity-90 mt-1">
|
||||
{action.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isUnitAdmin ? (
|
||||
selectedUnitId && <SmartOfficeAuditPage unitId={selectedUnitId} />
|
||||
) : (
|
||||
organizationId && (
|
||||
<SmartOfficeAuditPage organizationId={organizationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrgAdminDashboard;
|
||||
@@ -0,0 +1,7 @@
|
||||
import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins";
|
||||
|
||||
const OrganizationAdminsPage = () => {
|
||||
return <OrganizationsAdmins />;
|
||||
};
|
||||
|
||||
export default OrganizationAdminsPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AdminRegistrationForm } from "@/super-admin/components/organizations/AdminRegistrationForm";
|
||||
|
||||
export default function AdminRegistrationPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<AdminRegistrationForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EditOrganizationForm } from "@/super-admin/components/organizations/EditOrganizationForm";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const EditOrganizationPage = () => {
|
||||
const { id } = useParams();
|
||||
return id && <EditOrganizationForm id={id as string} />;
|
||||
};
|
||||
|
||||
export default EditOrganizationPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NewOrganizationForm } from "@/super-admin/components/organizations/NewOrganizationForm";
|
||||
|
||||
const NewOrganizationPage = () => {
|
||||
return <NewOrganizationForm />;
|
||||
};
|
||||
|
||||
export default NewOrganizationPage;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { OrganizationCard } from "@/super-admin/components/organizations/OrganizationDetail";
|
||||
import { useOrganizationDetail, useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
type OrgRouteParams = { id: string };
|
||||
|
||||
const OrganizationDetailPage = () => {
|
||||
const { id } = useParams<OrgRouteParams>();
|
||||
if (!id) {
|
||||
return <div className="text-gray-900 dark:text-gray-100">Organization ID is missing</div>; // or navigate away
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<OrganizationCard
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationDetailPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import Organizations from "@/super-admin/components/organizations/Organizations";
|
||||
|
||||
const OrganizationsPage = () => {
|
||||
return <Organizations />;
|
||||
};
|
||||
|
||||
export default OrganizationsPage;
|
||||
@@ -0,0 +1,531 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { usePositionConfigurationByPositionId } from "@/shared/hooks/usePositionConfigurations";
|
||||
import { usePositions } from "@/user-management/hooks/usePosition";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { Card, CardContent } from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { Alert, AlertDescription } from "@/shared/common/ui/alert";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Settings,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Workflow,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PositionConfiguration,
|
||||
PositionConfigurationPayload,
|
||||
} from "@/shared/services/positionConfigurationService";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { t } from "i18next";
|
||||
|
||||
export enum PositionScopeToFetch {
|
||||
PARALLEL_WITH_IMMEDIATE_CHILD = "parallel_with_immediate_child",
|
||||
IMMEDIATE_CHILD = "immediate_child",
|
||||
ALL = "all",
|
||||
}
|
||||
|
||||
export const positionScopeValues = [
|
||||
PositionScopeToFetch.ALL,
|
||||
PositionScopeToFetch.IMMEDIATE_CHILD,
|
||||
PositionScopeToFetch.PARALLEL_WITH_IMMEDIATE_CHILD,
|
||||
] as const;
|
||||
|
||||
const configurationSchema = z.object({
|
||||
positionId: z.string().min(1, "Position is required"),
|
||||
smsNotificationWhenRecordSubmitted: z.boolean(),
|
||||
emailNotificationWhenRecordSubmitted: z.boolean(),
|
||||
inboxNotificationWhenRecordSubmitted: z.boolean(),
|
||||
skipWorkflowIfNotAssigned: z.boolean(),
|
||||
positionScopeToFetch: z.enum(positionScopeValues),
|
||||
});
|
||||
|
||||
type ConfigurationFormData = z.infer<typeof configurationSchema>;
|
||||
|
||||
const defaultFormValues: ConfigurationFormData = {
|
||||
positionId: "",
|
||||
smsNotificationWhenRecordSubmitted: true,
|
||||
emailNotificationWhenRecordSubmitted: true,
|
||||
inboxNotificationWhenRecordSubmitted: true,
|
||||
skipWorkflowIfNotAssigned: false,
|
||||
positionScopeToFetch: PositionScopeToFetch.ALL,
|
||||
};
|
||||
|
||||
const parsePositionScope = (
|
||||
config: PositionConfiguration | null,
|
||||
): PositionScopeToFetch => {
|
||||
if (
|
||||
config?.positionScopeToFetch &&
|
||||
positionScopeValues.includes(config.positionScopeToFetch as PositionScopeToFetch)
|
||||
) {
|
||||
return config.positionScopeToFetch as PositionScopeToFetch;
|
||||
}
|
||||
|
||||
if (!config?.items?.data) return PositionScopeToFetch.ALL;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(config.items.data) as {
|
||||
positionScopeToFetch?: PositionScopeToFetch;
|
||||
};
|
||||
if (
|
||||
parsed.positionScopeToFetch &&
|
||||
positionScopeValues.includes(parsed.positionScopeToFetch)
|
||||
) {
|
||||
return parsed.positionScopeToFetch;
|
||||
}
|
||||
} catch {
|
||||
return PositionScopeToFetch.ALL;
|
||||
}
|
||||
|
||||
return PositionScopeToFetch.ALL;
|
||||
};
|
||||
|
||||
const buildPayload = (
|
||||
data: ConfigurationFormData,
|
||||
): PositionConfigurationPayload => ({
|
||||
positionId: data.positionId,
|
||||
smsNotificationWhenRecordSubmitted: data.smsNotificationWhenRecordSubmitted,
|
||||
emailNotificationWhenRecordSubmitted:
|
||||
data.emailNotificationWhenRecordSubmitted,
|
||||
inboxNotificationWhenRecordSubmitted:
|
||||
data.inboxNotificationWhenRecordSubmitted,
|
||||
skipWorkflowIfNotAssigned: data.skipWorkflowIfNotAssigned,
|
||||
positionScopeToFetch: data.positionScopeToFetch,
|
||||
});
|
||||
|
||||
const PositionConfigurationPage = () => {
|
||||
const { user } = useAuth();
|
||||
const defaultUnitId = user?.employee?.[0]?.unitId || "";
|
||||
const organizationId = user?.employee?.[0]?.organizationId || "";
|
||||
const localizedName = useLocalizedName();
|
||||
const [selectedUnitId, setSelectedUnitId] = useState(defaultUnitId);
|
||||
const [selectedPositionId, setSelectedPositionId] = useState("");
|
||||
|
||||
const {
|
||||
configuration,
|
||||
isLoading: isLoadingConfig,
|
||||
isError: isConfigError,
|
||||
error: configError,
|
||||
saveConfiguration,
|
||||
isSaving,
|
||||
refetch,
|
||||
} = usePositionConfigurationByPositionId(selectedPositionId);
|
||||
|
||||
const { getList: getUnitList } = useUnit();
|
||||
const { data: units, isLoading: isLoadingUnits } = getUnitList(
|
||||
organizationId,
|
||||
{ take: 1000 },
|
||||
);
|
||||
|
||||
const { usePositionListByUnitId } = usePositions();
|
||||
const { data: positions, isLoading: isLoadingPositions } =
|
||||
usePositionListByUnitId(selectedUnitId, { take: 1000 });
|
||||
|
||||
const form = useForm<ConfigurationFormData>({
|
||||
resolver: zodResolver(configurationSchema),
|
||||
defaultValues: defaultFormValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultUnitId && !selectedUnitId) {
|
||||
setSelectedUnitId(defaultUnitId);
|
||||
}
|
||||
}, [defaultUnitId, selectedUnitId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedPositionId("");
|
||||
}, [selectedUnitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPositionId) {
|
||||
form.reset(defaultFormValues);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingConfig) return;
|
||||
|
||||
if (configuration) {
|
||||
form.reset({
|
||||
positionId: selectedPositionId,
|
||||
smsNotificationWhenRecordSubmitted:
|
||||
configuration.smsNotificationWhenRecordSubmitted,
|
||||
emailNotificationWhenRecordSubmitted:
|
||||
configuration.emailNotificationWhenRecordSubmitted,
|
||||
inboxNotificationWhenRecordSubmitted:
|
||||
configuration.inboxNotificationWhenRecordSubmitted,
|
||||
skipWorkflowIfNotAssigned: configuration.skipWorkflowIfNotAssigned,
|
||||
positionScopeToFetch: parsePositionScope(configuration),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset({
|
||||
...defaultFormValues,
|
||||
positionId: selectedPositionId,
|
||||
});
|
||||
}, [selectedPositionId, configuration, isLoadingConfig, form]);
|
||||
|
||||
const handleSave = (data: ConfigurationFormData) => {
|
||||
saveConfiguration(buildPayload(data), {
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!organizationId) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("setting.msg1")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50 to-slate-100/60 p-4 shadow-sm sm:p-6 dark:border-slate-700/80 dark:bg-gradient-to-br dark:from-slate-950 dark:via-slate-900 dark:to-slate-900">
|
||||
<div className="mb-5 space-y-1 border-b border-slate-200 pb-4 dark:border-slate-700">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-100">
|
||||
<Settings className="h-8 w-8 text-primary" />
|
||||
{t("setting.positionConfig")}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{t("setting.positionConfigMsg")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200 bg-white/90 dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2 w-full max-w-md">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t("contentManagement.unit")}
|
||||
</label>
|
||||
<Select value={selectedUnitId} onValueChange={setSelectedUnitId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("organization.selectUnit")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="item-aligned"
|
||||
className="max-h-72 w-[min(24rem,90vw)]"
|
||||
>
|
||||
{isLoadingUnits ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
{t("common.loading", "Loading...")}
|
||||
</SelectItem>
|
||||
) : units?.data?.items?.length ? (
|
||||
units.data.items.map(
|
||||
(unit: {
|
||||
id: string;
|
||||
name: { am?: string; en?: string };
|
||||
}) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
{localizedName(unit.name)}
|
||||
</SelectItem>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<SelectItem value="no-units" disabled>
|
||||
{t("setting.noUnit", "No units found")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 w-full max-w-md">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
{t("setting.position")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedPositionId}
|
||||
onValueChange={setSelectedPositionId}
|
||||
disabled={!selectedUnitId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
selectedUnitId
|
||||
? t("setting.select")
|
||||
: t("setting.select2")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="item-aligned"
|
||||
className="max-h-72 w-[min(24rem,90vw)]"
|
||||
>
|
||||
{!selectedUnitId ? (
|
||||
<SelectItem value="no-unit-selected" disabled>
|
||||
{t("setting.select2")}
|
||||
</SelectItem>
|
||||
) : isLoadingPositions ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
{t("common.loading", "Loading...")}
|
||||
</SelectItem>
|
||||
) : positions?.items?.length ? (
|
||||
positions.items.map(
|
||||
(position: {
|
||||
id: string;
|
||||
name: { am?: string; en?: string };
|
||||
}) => (
|
||||
<SelectItem key={position.id} value={position.id}>
|
||||
{localizedName(position.name)}
|
||||
</SelectItem>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<SelectItem value="no-positions" disabled>
|
||||
{t(
|
||||
"setting.noPositions",
|
||||
"No positions found in this unit",
|
||||
)}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedPositionId ? (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{t(
|
||||
"setting.selectPositionToConfigure",
|
||||
"Select a unit and position to load configuration.",
|
||||
)}
|
||||
</p>
|
||||
) : isLoadingConfig ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("setting.msg2")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isConfigError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="flex flex-wrap items-center gap-2">
|
||||
{t("setting.error")}{" "}
|
||||
{configError instanceof Error
|
||||
? configError.message
|
||||
: "Unknown error"}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}>
|
||||
{t("setting.retry")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSave)}
|
||||
className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="positionScopeToFetch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="dark:text-gray-200">
|
||||
{t("setting.positionScope")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("setting.positionScope")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={PositionScopeToFetch.ALL}>
|
||||
{t("setting.all")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value={PositionScopeToFetch.IMMEDIATE_CHILD}>
|
||||
{t("setting.immediateChild")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value={
|
||||
PositionScopeToFetch.PARALLEL_WITH_IMMEDIATE_CHILD
|
||||
}>
|
||||
{t("setting.parallel")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("setting.notification")}
|
||||
</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smsNotificationWhenRecordSubmitted"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{t("setting.sms")}
|
||||
</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("setting.smsMsg")}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailNotificationWhenRecordSubmitted"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
{t("setting.email")}
|
||||
</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("setting.emailMsg")}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="inboxNotificationWhenRecordSubmitted"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
{t("setting.inbox")}
|
||||
</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("setting.inboxMsg")}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("setting.workflow")}
|
||||
</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skipWorkflowIfNotAssigned"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
{t("setting.workflowMsg")}
|
||||
</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("setting.workflowMsg1")}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="bg-primary hover:bg-primary/90">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("setting.updating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{configuration
|
||||
? t("setting.UpdateConfig")
|
||||
: t("setting.createConfig")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PositionConfigurationPage;
|
||||
224
apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx
Normal file
224
apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/common/ui/tabs";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Shield, Save, Trash2, Plus, Edit2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import PasswordSettingsForm from "@/super-admin/components/settings/PasswordSettingsForm";
|
||||
import { usePasswordSettingsQuery, usePasswordSettingsMutations } from "@/super-admin/hooks/usePasswordSettings";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const { data: passwordSettings, refetch: refetchPasswordSettings } = usePasswordSettingsQuery();
|
||||
const { delete: deleteMutation } = usePasswordSettingsMutations();
|
||||
|
||||
const handleDeletePasswordSettings = async () => {
|
||||
if (!passwordSettings?.id) {
|
||||
toast.error("No password settings to delete");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Attempting to delete password settings with ID:", passwordSettings.id);
|
||||
await deleteMutation.mutateAsync(passwordSettings.id);
|
||||
console.log("Delete successful, closing dialog");
|
||||
setDeleteDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Delete error:", error);
|
||||
toast.error("Failed to delete password settings");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditPasswordSettings = () => {
|
||||
if (passwordSettings?.id) {
|
||||
setEditDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
setCreateDialogOpen(false);
|
||||
refetchPasswordSettings();
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
setEditDialogOpen(false);
|
||||
refetchPasswordSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
System Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure system-wide settings for your organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="security" className="w-full">
|
||||
{/* Only Security Tab */}
|
||||
{/* Security Settings */}
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Password Settings</h3>
|
||||
</div>
|
||||
|
||||
{!passwordSettings ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-gray-600 dark:text-gray-400 mb-4">
|
||||
No password settings configured yet
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-primary hover:bg-primary/90">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Password Settings
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Password Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PasswordSettingsForm
|
||||
onSuccessCallback={handleCreateSuccess}
|
||||
isDialogForm={true}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Setting</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Value</th>
|
||||
<th className="px-6 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">Minimum Password Length</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">{passwordSettings.minimumPasswordLength}</td>
|
||||
<td rowSpan={5} className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={handleEditPasswordSettings}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
Update
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Password Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PasswordSettingsForm
|
||||
onSuccessCallback={handleEditSuccess}
|
||||
isDialogForm={true}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">Maximum Password Length</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">{passwordSettings.maximumPasswordLength}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">Password Expiry (Days)</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">{passwordSettings.passwordExpiry}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">Session Timeout (Minutes)</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">{passwordSettings.sessionTimeout}</td>
|
||||
</tr>
|
||||
<tr className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900 dark:text-white">Default Password Enabled</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{passwordSettings.isDefaultPasswordEnabled ? "Yes" : "No"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Password Settings</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the password settings? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex justify-end gap-3">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
onClick={handleDeletePasswordSettings}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { t } from "i18next";
|
||||
import { ArchivedUserColumnDefn } from "@/user-management/components/content/ArchivedUserColumnDefn";
|
||||
import {
|
||||
fetchValidOrganization,
|
||||
ValidOrganizationDto,
|
||||
} from "@/record-management/services/api/organizationService";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import {
|
||||
SimpleTreeSelect,
|
||||
TreeViewItemO,
|
||||
} from "@/shared/common/form/fields/FormFields";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useArchivedUsers } from "@/super-admin/hooks/useArchivedUsers";
|
||||
|
||||
const SuperAdminArchiveUsersPage = () => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const pageSize = 10;
|
||||
const localizedName = useLocalizedName();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: exteranlOrgsResponse,
|
||||
isLoading: loadingExternalOrgs,
|
||||
error: externalOrgsError,
|
||||
} = useQuery({
|
||||
queryKey: ["organizations"], // include param
|
||||
queryFn: fetchValidOrganization,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// State for selected unit (single selection)
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null);
|
||||
|
||||
// Handle unit selection and ensure dropdown closes
|
||||
const handleUnitSelection = (unitId: string | null) => {
|
||||
setSelectedUnitId(unitId);
|
||||
};
|
||||
|
||||
const treeOrganizationsOptions: TreeViewItemO[] = useMemo(() => {
|
||||
return (
|
||||
exteranlOrgsResponse?.items.map((org: ValidOrganizationDto) => ({
|
||||
id: org.id,
|
||||
name: org.name, // converts {en,am} => string
|
||||
hierarchyType: "organization",
|
||||
value: org.units.length === 1 ? org.units[0].id : "",
|
||||
children:
|
||||
Array.isArray(org.units) && org.units.length > 0
|
||||
? org.units.map((unit) => ({
|
||||
id: unit.id,
|
||||
name: unit.name, // string
|
||||
hierarchyType: "unit",
|
||||
value: unit.id,
|
||||
children: [],
|
||||
}))
|
||||
: [],
|
||||
})) || []
|
||||
);
|
||||
}, [exteranlOrgsResponse, localizedName]);
|
||||
|
||||
// After defining treeOrganizationsOptions
|
||||
useEffect(() => {
|
||||
if (!selectedUnitId && exteranlOrgsResponse?.items) {
|
||||
const firstValidUnit = exteranlOrgsResponse.items
|
||||
.flatMap((org) => org.units)
|
||||
.find((unit) => unit.id);
|
||||
|
||||
if (firstValidUnit) {
|
||||
setSelectedUnitId(firstValidUnit.id);
|
||||
}
|
||||
}
|
||||
}, [exteranlOrgsResponse, selectedUnitId]);
|
||||
|
||||
// Save
|
||||
useEffect(() => {
|
||||
if (selectedUnitId)
|
||||
sessionStorage.setItem("selectedArchiveUnitId", selectedUnitId);
|
||||
}, [selectedUnitId]);
|
||||
|
||||
// Load on mount
|
||||
useEffect(() => {
|
||||
const saved = sessionStorage.getItem("selectedArchiveUnitId");
|
||||
if (saved) setSelectedUnitId(saved);
|
||||
}, []);
|
||||
|
||||
const { data: archivedUsersData, refetch } = useArchivedUsers(
|
||||
selectedUnitId ?? ""
|
||||
);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
if (loadingExternalOrgs) {
|
||||
return <div className="text-gray-900 dark:text-gray-100">{t("loading")}</div>;
|
||||
}
|
||||
|
||||
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("setting.archivedUsers")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
{/* Unit Selector */}
|
||||
<div className="mb-4">
|
||||
<SimpleTreeSelect
|
||||
label={t("selectUnit")}
|
||||
options={treeOrganizationsOptions}
|
||||
value={selectedUnitId}
|
||||
onChange={handleUnitSelection}
|
||||
collapsible
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Archived Users Table */}
|
||||
<CardContent className="px-0">
|
||||
<AdvancedTable
|
||||
columns={ArchivedUserColumnDefn}
|
||||
data={archivedUsersData?.items || []}
|
||||
tableName="Archived Users"
|
||||
toolBarPosition="right"
|
||||
itemCount={archivedUsersData?.count || 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
refresh={refetch}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuperAdminArchiveUsersPage;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArchiveRestore, Building2, Landmark } from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import {
|
||||
useArchivedOrganizations,
|
||||
useArchivedUnits,
|
||||
useArchiveActions,
|
||||
} from "@/user-management/hooks/useArchived";
|
||||
|
||||
type Tab = "organizations" | "units";
|
||||
|
||||
const SuperAdminArchivedPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("organizations");
|
||||
|
||||
const { organizationsResponse } = useOrganizations("Org", {
|
||||
take: 300,
|
||||
skip: 0,
|
||||
});
|
||||
const organizations: any[] = organizationsResponse?.items ?? [];
|
||||
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
|
||||
if (!selectedOrgId && organizations.length > 0) {
|
||||
setSelectedOrgId(organizations[0].id);
|
||||
}
|
||||
|
||||
const {
|
||||
data: archivedOrgsData,
|
||||
refetch: refetchArchivedOrgs,
|
||||
} = useArchivedOrganizations();
|
||||
const { data: archivedUnitsData, refetch: refetchArchivedUnits } =
|
||||
useArchivedUnits(activeTab === "units" ? selectedOrgId || undefined : undefined);
|
||||
|
||||
const archivedOrgs: any[] = useMemo(
|
||||
() => archivedOrgsData?.items ?? archivedOrgsData ?? [],
|
||||
[archivedOrgsData],
|
||||
);
|
||||
const archivedUnits: any[] = useMemo(
|
||||
() => archivedUnitsData?.items ?? archivedUnitsData ?? [],
|
||||
[archivedUnitsData],
|
||||
);
|
||||
|
||||
const {
|
||||
restoreUnit,
|
||||
isRestoringUnit,
|
||||
restoreOrganization,
|
||||
isRestoringOrganization,
|
||||
} = useArchiveActions();
|
||||
|
||||
const orgColumns = [
|
||||
{
|
||||
id: "name",
|
||||
header: t("organization.name", "Name"),
|
||||
cell: ({ row }: any) => localizedName(row.original?.name) || "—",
|
||||
},
|
||||
{
|
||||
id: "key",
|
||||
header: t("organization.key", "Key"),
|
||||
accessorKey: "key",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: t("userIncomingretun.Actions", "Actions"),
|
||||
cell: ({ row }: any) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isRestoringOrganization}
|
||||
onClick={() => restoreOrganization(row.original.id)}
|
||||
className="flex items-center gap-2 text-primary-700 hover:bg-primary-50">
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
{t("archive.restore", "Restore")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const unitColumns = [
|
||||
{
|
||||
id: "name",
|
||||
header: t("organization.name", "Name"),
|
||||
cell: ({ row }: any) => localizedName(row.original?.name) || "—",
|
||||
},
|
||||
{
|
||||
id: "key",
|
||||
header: t("organization.key", "Key"),
|
||||
accessorKey: "key",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: t("userIncomingretun.Actions", "Actions"),
|
||||
cell: ({ row }: any) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isRestoringUnit}
|
||||
onClick={() => restoreUnit(row.original.id)}
|
||||
className="flex items-center gap-2 text-primary-700 hover:bg-primary-50">
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
{t("archive.restore", "Restore")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="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("archive.archivedItems", "Archived Items")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
variant={activeTab === "organizations" ? "default" : "outline"}
|
||||
onClick={() => setActiveTab("organizations")}
|
||||
className="flex items-center gap-2">
|
||||
<Landmark className="h-4 w-4" />
|
||||
{t("archive.archivedOrganizations", "Archived Organizations")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "units" ? "default" : "outline"}
|
||||
onClick={() => setActiveTab("units")}
|
||||
className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{t("archive.archivedUnits", "Archived Units")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeTab === "units" && organizations.length > 0 && (
|
||||
<div className="mb-4 w-full sm:w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{t("organization.selectOrganization", "Select Organization")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedOrgId}
|
||||
onValueChange={(value) => setSelectedOrgId(value)}>
|
||||
<SelectTrigger className="mt-1 block w-full">
|
||||
<SelectValue
|
||||
placeholder={t("organization.selectOrganization")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((org: any) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{localizedName(org.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="px-0">
|
||||
{activeTab === "organizations" ? (
|
||||
<AdvancedTable
|
||||
columns={orgColumns as any}
|
||||
data={archivedOrgs}
|
||||
tableName="ArchivedOrganizations"
|
||||
toolBarPosition="right"
|
||||
itemCount={archivedOrgs.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
nextFunction={() => {}}
|
||||
prevFunction={() => {}}
|
||||
refresh={refetchArchivedOrgs}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={unitColumns as any}
|
||||
data={archivedUnits}
|
||||
tableName="ArchivedUnits"
|
||||
toolBarPosition="right"
|
||||
itemCount={archivedUnits.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
nextFunction={() => {}}
|
||||
prevFunction={() => {}}
|
||||
refresh={refetchArchivedUnits}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuperAdminArchivedPage;
|
||||
1937
apps/edr-freight-web/backoffice/src/pages/UnitConfigurationPage.tsx
Normal file
1937
apps/edr-freight-web/backoffice/src/pages/UnitConfigurationPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
import ViewDocument from "@/super-admin/components/externalUsers/ViewDocuments";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const UploadedDocumentViewPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
if (!id) {
|
||||
return <div className="text-gray-900 dark:text-gray-100">Error: Document ID is missing</div>;
|
||||
}
|
||||
|
||||
return <ViewDocument userId={id} />;
|
||||
};
|
||||
|
||||
export default UploadedDocumentViewPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
import UserManagementTree from "../user-management/userManagement/UserManagementTree";
|
||||
|
||||
export default function UserManagementPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<UserManagementTree />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import UpdateProfile from '@/super-admin/components/externalUsers/UpdateProfile';
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export default function UserProfileEditPage() {
|
||||
|
||||
|
||||
return <UpdateProfile />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { WebManagementHomePage } from "@/user-management/web-Management/webManagement";
|
||||
|
||||
const WebManagement = () => {
|
||||
return <WebManagementHomePage />;
|
||||
};
|
||||
|
||||
export default WebManagement;
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import axios from "axios";
|
||||
|
||||
import {
|
||||
AiBookingExtractResult,
|
||||
extractBookingFromText,
|
||||
} from "@/services/ai.service";
|
||||
|
||||
const EXAMPLE_TEXT =
|
||||
"Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.";
|
||||
|
||||
const formatValue = (value: string | number | boolean | null): string => {
|
||||
if (value === null) return "—";
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const EXTRACTED_FIELD_LABELS: Array<{
|
||||
key: keyof AiBookingExtractResult["extracted"];
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "customerName", label: "Customer Name" },
|
||||
{ key: "origin", label: "Origin" },
|
||||
{ key: "destination", label: "Destination" },
|
||||
{ key: "cargoType", label: "Cargo Type" },
|
||||
{ key: "containerType", label: "Container Type" },
|
||||
{ key: "quantity", label: "Quantity" },
|
||||
{ key: "direction", label: "Direction" },
|
||||
{ key: "weightKg", label: "Weight (kg)" },
|
||||
{ key: "pickupRequired", label: "Pickup Required" },
|
||||
{ key: "deliveryRequired", label: "Delivery Required" },
|
||||
];
|
||||
|
||||
export default function AiBookingMockTestPage() {
|
||||
const [text, setText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<AiBookingExtractResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
|
||||
const handleTest = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
setResult(await extractBookingFromText(text));
|
||||
} catch (err) {
|
||||
const backendMessage = axios.isAxiosError(err)
|
||||
? (err.response?.data as { message?: string | string[] } | undefined)
|
||||
?.message
|
||||
: null;
|
||||
setError(
|
||||
backendMessage
|
||||
? `Mock AI request failed: ${
|
||||
Array.isArray(backendMessage)
|
||||
? backendMessage.join(", ")
|
||||
: backendMessage
|
||||
}`
|
||||
: "Mock AI request failed",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDraftBooking = () => {
|
||||
window.alert("Draft booking creation will be connected in the next step.");
|
||||
};
|
||||
|
||||
const canCreateDraft = Boolean(result?.validation.valid);
|
||||
|
||||
return (
|
||||
<Stack gap="lg" p="md" maw={860}>
|
||||
<Title order={2}>Mock AI Booking Assistant</Title>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label="Customer booking request"
|
||||
placeholder="Enter customer booking request..."
|
||||
description={`Example: ${EXAMPLE_TEXT}`}
|
||||
minRows={4}
|
||||
autosize
|
||||
value={text}
|
||||
onChange={(event) => setText(event.currentTarget.value)}
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
loading={loading}
|
||||
disabled={text.trim().length < 5}
|
||||
>
|
||||
{loading ? "Testing..." : "Test Mock AI"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={() => setText(EXAMPLE_TEXT)}
|
||||
>
|
||||
Use example
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" title="Request failed">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>Extracted Booking Data</Title>
|
||||
<Badge variant="light" color="gray">
|
||||
provider: {result.provider}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Table withTableBorder={false} verticalSpacing="xs">
|
||||
<Table.Tbody>
|
||||
{EXTRACTED_FIELD_LABELS.map(({ key, label }) => (
|
||||
<Table.Tr key={key}>
|
||||
<Table.Td w={200}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{formatValue(result.extracted[key])}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Group>
|
||||
<Title order={4}>Validation</Title>
|
||||
<Badge color={result.validation.valid ? "green" : "red"}>
|
||||
{result.validation.valid ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
</Group>
|
||||
{result.validation.errors.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{result.validation.errors.map((message) => (
|
||||
<Text key={message} size="sm" c="red">
|
||||
• {message}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Group>
|
||||
<Title order={4}>Recommendation</Title>
|
||||
<Badge
|
||||
color={
|
||||
result.recommendation.action === "CREATE_DRAFT_BOOKING"
|
||||
? "green"
|
||||
: "yellow"
|
||||
}
|
||||
>
|
||||
{result.recommendation.action}
|
||||
</Badge>
|
||||
<Badge variant="light">
|
||||
confidence {Math.round(result.recommendation.confidence * 100)}%
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm">{result.recommendation.message}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
disabled={!canCreateDraft}
|
||||
onClick={handleCreateDraftBooking}
|
||||
>
|
||||
Create Draft Booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setShowRawJson((open) => !open)}
|
||||
>
|
||||
{showRawJson ? "Hide raw JSON" : "Show raw JSON"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{showRawJson && (
|
||||
<Code block>{JSON.stringify(result, null, 2)}</Code>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
const DemoUser1Page = () => {
|
||||
const [data, setData] = useState<unknown>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get("/test_user1");
|
||||
if (cancelled) return;
|
||||
setData(response.data);
|
||||
} catch (e: any) {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
e?.response?.data?.message ||
|
||||
e?.response?.data?.error ||
|
||||
e?.message ||
|
||||
"Request failed";
|
||||
setError(String(message));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-6">
|
||||
<h1 className="text-lg font-semibold text-foreground">User1 Demo</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Calls <code className="font-mono">GET /api/test_user1</code> (requires{' '}
|
||||
<code className="font-mono">can:demo:user1</code>).
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading ? <p className="text-sm text-muted-foreground">Loading...</p> : null}
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !error ? (
|
||||
<pre className="mt-3 overflow-auto rounded-xl border border-border bg-background p-4 text-xs text-foreground">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemoUser1Page;
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
const DemoUser2Page = () => {
|
||||
const [data, setData] = useState<unknown>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get("/test_user2");
|
||||
if (cancelled) return;
|
||||
setData(response.data);
|
||||
} catch (e: any) {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
e?.response?.data?.message ||
|
||||
e?.response?.data?.error ||
|
||||
e?.message ||
|
||||
"Request failed";
|
||||
setError(String(message));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-6">
|
||||
<h1 className="text-lg font-semibold text-foreground">User2 Demo</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Calls <code className="font-mono">GET /api/test_user2</code> (requires{' '}
|
||||
<code className="font-mono">can:demo:user2</code>).
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading ? <p className="text-sm text-muted-foreground">Loading...</p> : null}
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !error ? (
|
||||
<pre className="mt-3 overflow-auto rounded-xl border border-border bg-background p-4 text-xs text-foreground">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemoUser2Page;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
|
||||
const DepartmentsPage = () => {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Departments"
|
||||
description="Organize internal departments and associate user administration with freight business units."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DepartmentsPage;
|
||||
@@ -1,321 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
|
||||
interface LocaleText {
|
||||
en?: string;
|
||||
am?: string;
|
||||
}
|
||||
|
||||
interface EmployeeUserRecord {
|
||||
id: string;
|
||||
name?: LocaleText;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface EmployeePositionSummary {
|
||||
id: string;
|
||||
position?: {
|
||||
id: string;
|
||||
name?: LocaleText;
|
||||
key?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EmployeeRecord {
|
||||
id: string;
|
||||
name?: LocaleText;
|
||||
status?: string;
|
||||
user?: EmployeeUserRecord;
|
||||
employeePositions?: EmployeePositionSummary[];
|
||||
}
|
||||
|
||||
interface ListResponse<T> {
|
||||
count?: number;
|
||||
items?: T[];
|
||||
data?: T[];
|
||||
}
|
||||
|
||||
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
|
||||
value?.en ?? value?.am ?? fallback;
|
||||
|
||||
const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
|
||||
if (!payload) {
|
||||
return [] as T[];
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return payload.items ?? payload.data ?? [];
|
||||
};
|
||||
|
||||
const getEmployeeDisplayName = (employee: EmployeeRecord) =>
|
||||
getLocaleLabel(
|
||||
employee.name ?? employee.user?.name,
|
||||
employee.user?.email ?? employee.user?.username ?? employee.id,
|
||||
);
|
||||
|
||||
const EmployeesPage = () => {
|
||||
const { user } = useAuth();
|
||||
const [employees, setEmployees] = useState<EmployeeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<EmployeeRecord | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const organizationIds = Array.from(
|
||||
new Set(
|
||||
(user?.employee ?? [])
|
||||
.map((employee) => employee.organizationId)
|
||||
.filter((organizationId): organizationId is string => Boolean(organizationId)),
|
||||
),
|
||||
);
|
||||
|
||||
const loadEmployees = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!organizationIds.length) {
|
||||
setEmployees([]);
|
||||
setErrorMessage("No employee organization scope is available for this account.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
organizationIds.map((organizationId) =>
|
||||
api.get<ListResponse<EmployeeRecord>>(
|
||||
`/backoffice/organizations/${organizationId}/employees`,
|
||||
{
|
||||
params: {
|
||||
skip: 0,
|
||||
take: 1000,
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueEmployees = Array.from(
|
||||
new Map(
|
||||
responses
|
||||
.flatMap((response) => getItems(response.data))
|
||||
.map((employee) => [employee.id, employee]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
setEmployees(
|
||||
uniqueEmployees.sort((left, right) =>
|
||||
getEmployeeDisplayName(left).localeCompare(getEmployeeDisplayName(right)),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load employees."
|
||||
: "Unable to load employees.",
|
||||
);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadEmployees();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<section className="p-6">
|
||||
<div className="space-y-6 rounded-2xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
User Management
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-foreground">Employees</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
|
||||
Browse employees within your accessible scope and open a record to review contact and position details.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
|
||||
{employees.length} employees
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading employees...
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-8 text-center text-sm text-red-700 dark:border-red-950 dark:bg-red-950/30 dark:text-red-300">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : employees.length ? (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="min-w-[980px] w-full border-collapse text-left text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
<th className="px-4 py-3 font-medium">Username</th>
|
||||
<th className="px-4 py-3 font-medium">Email</th>
|
||||
<th className="px-4 py-3 font-medium">Phone</th>
|
||||
<th className="px-4 py-3 font-medium">Status</th>
|
||||
<th className="px-4 py-3 font-medium">Positions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => {
|
||||
const positions = employee.employeePositions?.map((item) => getLocaleLabel(item.position?.name, item.position?.key ?? "Unnamed")) ?? [];
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={employee.id}
|
||||
onClick={() => setSelectedEmployee(employee)}
|
||||
className="cursor-pointer border-t border-border bg-background transition hover:bg-accent/20"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">{getEmployeeDisplayName(employee)}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{employee.user?.username ?? "-"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{employee.user?.email ?? "-"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{employee.user?.phoneNumber ?? "-"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{employee.status ?? "-"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{positions.join(", ") || "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No employees are available in your scope.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(selectedEmployee)} onOpenChange={(open) => !open && setSelectedEmployee(null)}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedEmployee ? getEmployeeDisplayName(selectedEmployee) : "Employee details"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedEmployee
|
||||
? "Review the employee profile, contact information, and assigned positions."
|
||||
: undefined}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedEmployee ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 rounded-2xl border border-border bg-muted/40 p-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Employee name
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-foreground">
|
||||
{getEmployeeDisplayName(selectedEmployee)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Status
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-foreground">
|
||||
{selectedEmployee.status ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Username
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-foreground">{selectedEmployee.user?.username ?? "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Email
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-foreground">{selectedEmployee.user?.email ?? "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Phone
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-foreground">{selectedEmployee.user?.phoneNumber ?? "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Employee ID
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-sm text-foreground">{selectedEmployee.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Assigned positions</h2>
|
||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{selectedEmployee.employeePositions?.length ?? 0} positions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEmployee.employeePositions?.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{selectedEmployee.employeePositions.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-2xl border border-border/70 bg-background/80 p-4 shadow-sm"
|
||||
>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{getLocaleLabel(item.position?.name, item.position?.key ?? "Unnamed")}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground/90">
|
||||
{item.position?.key ?? "-"}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No positions are assigned to this employee.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeesPage;
|
||||
@@ -1,265 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
interface LocaleText {
|
||||
en?: string;
|
||||
am?: string;
|
||||
}
|
||||
|
||||
interface PermissionRecord {
|
||||
id: string;
|
||||
key: string;
|
||||
name?: LocaleText;
|
||||
applicationId?: string | null;
|
||||
}
|
||||
|
||||
interface ApplicationRecord {
|
||||
id: string;
|
||||
key: string;
|
||||
name?: LocaleText;
|
||||
}
|
||||
|
||||
interface ListResponse<T> {
|
||||
count?: number;
|
||||
items?: T[];
|
||||
data?: T[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 2000;
|
||||
const ALL_APPLICATIONS_VALUE = "all";
|
||||
const SYSTEM_APPLICATION_VALUE = "system";
|
||||
|
||||
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
|
||||
value?.en ?? value?.am ?? fallback;
|
||||
|
||||
const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
|
||||
if (!payload) {
|
||||
return [] as T[];
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return payload.items ?? payload.data ?? [];
|
||||
};
|
||||
|
||||
const sortPermissions = (items: PermissionRecord[]) =>
|
||||
[...items].sort((left, right) =>
|
||||
getLocaleLabel(left.name, left.key).localeCompare(
|
||||
getLocaleLabel(right.name, right.key),
|
||||
),
|
||||
);
|
||||
|
||||
const PermissionsPage = () => {
|
||||
const [permissions, setPermissions] = useState<PermissionRecord[]>([]);
|
||||
const [applications, setApplications] = useState<ApplicationRecord[]>([]);
|
||||
const [selectedApplication, setSelectedApplication] = useState(ALL_APPLICATIONS_VALUE);
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPageData = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const [permissionsResponse, applicationsResponse] = await Promise.all([
|
||||
api.get<ListResponse<PermissionRecord>>("/permissions", {
|
||||
params: {
|
||||
skip: 0,
|
||||
take: PAGE_SIZE,
|
||||
},
|
||||
}),
|
||||
api.get<ListResponse<ApplicationRecord>>("/applications"),
|
||||
]);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = sortPermissions(getItems(permissionsResponse.data));
|
||||
const applicationItems = [...getItems(applicationsResponse.data)].sort((left, right) =>
|
||||
getLocaleLabel(left.name, left.key).localeCompare(
|
||||
getLocaleLabel(right.name, right.key),
|
||||
),
|
||||
);
|
||||
|
||||
setPermissions(items);
|
||||
setApplications(applicationItems);
|
||||
setCount(permissionsResponse.data.count ?? items.length);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load permissions."
|
||||
: "Unable to load permissions.",
|
||||
);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadPageData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasMore = count > permissions.length;
|
||||
const filteredPermissions = permissions.filter((permission) => {
|
||||
if (selectedApplication === ALL_APPLICATIONS_VALUE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (selectedApplication === SYSTEM_APPLICATION_VALUE) {
|
||||
return !permission.applicationId;
|
||||
}
|
||||
|
||||
return permission.applicationId === selectedApplication;
|
||||
});
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
setLoadingMore(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<PermissionRecord>>("/permissions", {
|
||||
params: {
|
||||
skip: permissions.length,
|
||||
take: PAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
const nextItems = sortPermissions(getItems(response.data));
|
||||
|
||||
setPermissions((current) => [...current, ...nextItems]);
|
||||
setCount(response.data.count ?? permissions.length + nextItems.length);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load more permissions."
|
||||
: "Unable to load more permissions.",
|
||||
);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="p-6">
|
||||
<div className="space-y-6 rounded-2xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
User Management
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-foreground">Permissions</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
|
||||
Browse the full IAM permission catalog for the freight backoffice environment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
|
||||
{filteredPermissions.length} permissions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4 md:grid-cols-[minmax(0,260px)_1fr] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Application
|
||||
</p>
|
||||
<Select value={selectedApplication} onValueChange={setSelectedApplication}>
|
||||
<SelectTrigger className="w-full rounded-xl bg-background">
|
||||
<SelectValue placeholder="Select application" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_APPLICATIONS_VALUE}>All applications</SelectItem>
|
||||
<SelectItem value={SYSTEM_APPLICATION_VALUE}>System permissions</SelectItem>
|
||||
{applications.map((application) => (
|
||||
<SelectItem key={application.id} value={application.id}>
|
||||
{getLocaleLabel(application.name, application.key)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filter the IAM permission catalog by application, or view the shared system permissions that do not belong to any application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-8 text-center text-sm text-red-700 dark:border-red-950 dark:bg-red-950/30 dark:text-red-300">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : filteredPermissions.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{filteredPermissions.map((permission) => (
|
||||
<article
|
||||
key={permission.id}
|
||||
className="rounded-2xl border border-border/70 bg-background/80 p-4 shadow-sm transition hover:border-border hover:bg-accent/20"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="truncate text-sm font-semibold leading-5 text-foreground">
|
||||
{getLocaleLabel(permission.name, permission.key)}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground/90">
|
||||
{permission.key}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No permissions match the selected application.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-background px-4 py-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {permissions.length} of {count} permissions.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLoadMore()}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionsPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,293 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { Shield } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
interface LocaleText {
|
||||
en?: string;
|
||||
am?: string;
|
||||
}
|
||||
|
||||
interface RoleRecord {
|
||||
id: string;
|
||||
key: string;
|
||||
name?: LocaleText;
|
||||
}
|
||||
|
||||
interface PermissionRecord {
|
||||
id: string;
|
||||
key: string;
|
||||
name?: LocaleText;
|
||||
}
|
||||
|
||||
interface ListResponse<T> {
|
||||
items?: T[];
|
||||
data?: T[];
|
||||
}
|
||||
|
||||
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") =>
|
||||
value?.en ?? value?.am ?? fallback;
|
||||
|
||||
const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
|
||||
if (!payload) {
|
||||
return [] as T[];
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return payload.items ?? payload.data ?? [];
|
||||
};
|
||||
|
||||
const RolesPage = () => {
|
||||
const [roles, setRoles] = useState<RoleRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<RoleRecord | null>(null);
|
||||
const [rolePermissions, setRolePermissions] = useState<PermissionRecord[]>([]);
|
||||
const [rolePermissionsLoading, setRolePermissionsLoading] = useState(false);
|
||||
const [rolePermissionsError, setRolePermissionsError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadRoles = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<RoleRecord>>("/roles");
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRoles(
|
||||
getItems(response.data).sort((left, right) =>
|
||||
getLocaleLabel(left.name, left.key).localeCompare(
|
||||
getLocaleLabel(right.name, right.key),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load roles."
|
||||
: "Unable to load roles.",
|
||||
);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadRoles();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRole) {
|
||||
setRolePermissions([]);
|
||||
setRolePermissionsError(null);
|
||||
setRolePermissionsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadRolePermissions = async () => {
|
||||
setRolePermissionsLoading(true);
|
||||
setRolePermissionsError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<PermissionRecord>>(
|
||||
`/role-permissions/given-first/${selectedRole.id}`,
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRolePermissions(
|
||||
getItems(response.data).sort((left, right) =>
|
||||
getLocaleLabel(left.name, left.key).localeCompare(
|
||||
getLocaleLabel(right.name, right.key),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRolePermissionsError(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load role details."
|
||||
: "Unable to load role details.",
|
||||
);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setRolePermissionsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadRolePermissions();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [selectedRole]);
|
||||
|
||||
return (
|
||||
<section className="p-6">
|
||||
<div className="space-y-6 rounded-2xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
User Management
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-foreground">Roles</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
|
||||
Browse freight backoffice roles and their internal keys in a simple grid view.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
|
||||
{roles.length} roles
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading roles...
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-8 text-center text-sm text-red-700 dark:border-red-950 dark:bg-red-950/30 dark:text-red-300">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : roles.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
key={role.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedRole(role)}
|
||||
className="rounded-2xl border border-border bg-background p-5 text-left transition hover:border-border/80 hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-sky-50 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300">
|
||||
<Shield className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{getLocaleLabel(role.name, role.key)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{role.key}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No roles available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(selectedRole)} onOpenChange={(open) => !open && setSelectedRole(null)}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedRole ? getLocaleLabel(selectedRole.name, selectedRole.key) : "Role details"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedRole
|
||||
? `Review the permission set assigned to ${getLocaleLabel(selectedRole.name, selectedRole.key)}.`
|
||||
: undefined}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedRole ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 rounded-2xl border border-border bg-muted/40 p-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Role name
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-foreground">
|
||||
{getLocaleLabel(selectedRole.name, selectedRole.key)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Role key
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-sm text-foreground">{selectedRole.key}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Permissions</h2>
|
||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{rolePermissions.length} permissions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rolePermissionsLoading ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading role details...
|
||||
</div>
|
||||
) : rolePermissionsError ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-8 text-center text-sm text-red-700 dark:border-red-950 dark:bg-red-950/30 dark:text-red-300">
|
||||
{rolePermissionsError}
|
||||
</div>
|
||||
) : rolePermissions.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{rolePermissions.map((permission) => (
|
||||
<article
|
||||
key={permission.id}
|
||||
className="rounded-2xl border border-border/70 bg-background/80 p-4 shadow-sm"
|
||||
>
|
||||
<p className="truncate text-sm font-semibold leading-5 text-foreground">
|
||||
{getLocaleLabel(permission.name, permission.key)}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground/90">
|
||||
{permission.key}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No permissions are assigned to this role.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default RolesPage;
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import {
|
||||
UserManagementApp,
|
||||
type UserManagementRuntimeOptions,
|
||||
type UserManagementSessionSeed,
|
||||
} from '@tria-plc/iamui';
|
||||
import { iamConfig } from './iamConfig';
|
||||
|
||||
function readCookieValue(name: string): string | null {
|
||||
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
|
||||
const match = document.cookie.match(
|
||||
new RegExp(`(?:^|; )${escaped}=([^;]*)`),
|
||||
);
|
||||
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function readInitialSession(): UserManagementSessionSeed | null {
|
||||
const token =
|
||||
localStorage.getItem('fhc-backoffice-auth-token') ??
|
||||
readCookieValue('auth-token');
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshToken =
|
||||
localStorage.getItem('fhc-backoffice-auth-refresh-token') ??
|
||||
readCookieValue('refresh-token') ??
|
||||
undefined;
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
rememberMe: true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function UserManagementHostPage() {
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = useRef<Root | null>(null);
|
||||
const unmountTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mountNode = mountRef.current;
|
||||
|
||||
if (!mountNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unmountTimerRef.current !== null) {
|
||||
window.clearTimeout(unmountTimerRef.current);
|
||||
unmountTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!rootRef.current) {
|
||||
rootRef.current = createRoot(mountNode);
|
||||
}
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, '');
|
||||
const runtime: UserManagementRuntimeOptions = {
|
||||
basename: '/um',
|
||||
apiBaseUrl,
|
||||
apiUrl: `${apiBaseUrl}/api`,
|
||||
recordApiUrl: `${apiBaseUrl}/api`,
|
||||
chronicleUrl: `${apiBaseUrl}/api`,
|
||||
auditApiUrl: `${apiBaseUrl}/api`,
|
||||
};
|
||||
|
||||
rootRef.current.render(
|
||||
<UserManagementApp
|
||||
config={iamConfig}
|
||||
runtime={runtime}
|
||||
session={{
|
||||
initialSession: readInitialSession(),
|
||||
enableEmbeddedAuthBridge: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
unmountTimerRef.current = window.setTimeout(() => {
|
||||
rootRef.current?.unmount();
|
||||
rootRef.current = null;
|
||||
unmountTimerRef.current = null;
|
||||
}, 0);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} style={{ position: 'fixed', inset: 0 }} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,825 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Badge,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@edr/ui-common";
|
||||
import { Network, RefreshCw, Search, UserCheck, UserMinus, Users } from "lucide-react";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
|
||||
interface LocaleText {
|
||||
en?: string;
|
||||
am?: string;
|
||||
}
|
||||
|
||||
interface OrganizationRecord {
|
||||
id: string;
|
||||
key: string;
|
||||
name: LocaleText;
|
||||
}
|
||||
|
||||
interface EmployeeUserRecord {
|
||||
id: string;
|
||||
name?: LocaleText;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface EmployeePositionSummary {
|
||||
id: string;
|
||||
position?: {
|
||||
id: string;
|
||||
name?: LocaleText;
|
||||
};
|
||||
}
|
||||
|
||||
interface EmployeeRecord {
|
||||
id: string;
|
||||
name?: LocaleText;
|
||||
user?: EmployeeUserRecord;
|
||||
status?: string;
|
||||
employeePositions?: EmployeePositionSummary[];
|
||||
}
|
||||
|
||||
interface RoleRecord {
|
||||
id: string;
|
||||
key: string;
|
||||
name: LocaleText;
|
||||
}
|
||||
|
||||
interface UserFormState {
|
||||
nameEn: string;
|
||||
nameAm: string;
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
assignOrganizationAdmin: boolean;
|
||||
}
|
||||
|
||||
interface ListResponse<T> {
|
||||
items?: T[];
|
||||
data?: T[];
|
||||
}
|
||||
|
||||
const RESERVED_ROLE_KEYS = new Set(["super_admin", "organization_admin", "unit_admin"]);
|
||||
|
||||
const emptyUserForm: UserFormState = {
|
||||
nameEn: "",
|
||||
nameAm: "",
|
||||
email: "",
|
||||
username: "",
|
||||
phoneNumber: "",
|
||||
assignOrganizationAdmin: false,
|
||||
};
|
||||
|
||||
const inputClassName =
|
||||
"w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950";
|
||||
const buttonClassName =
|
||||
"inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value.en ?? value.am ?? fallback;
|
||||
};
|
||||
|
||||
const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
|
||||
if (!payload) {
|
||||
return [] as T[];
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return payload.items ?? payload.data ?? [];
|
||||
};
|
||||
|
||||
const mergeEmployeesByUser = (employees: EmployeeRecord[]) => {
|
||||
const employeesByUserId = new Map<string, EmployeeRecord>();
|
||||
|
||||
for (const employee of employees) {
|
||||
const userId = employee.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
employeesByUserId.set(employee.id, employee);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = employeesByUserId.get(userId);
|
||||
|
||||
if (!existing) {
|
||||
employeesByUserId.set(userId, employee);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingPositions = existing.employeePositions ?? [];
|
||||
const nextPositions = employee.employeePositions ?? [];
|
||||
const mergedPositions = Array.from(
|
||||
new Map(
|
||||
[...existingPositions, ...nextPositions].map((position) => [position.id, position]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
employeesByUserId.set(userId, {
|
||||
...existing,
|
||||
...employee,
|
||||
id: existing.id,
|
||||
name: existing.name ?? employee.name,
|
||||
status: existing.status ?? employee.status,
|
||||
user: existing.user ?? employee.user,
|
||||
employeePositions: mergedPositions,
|
||||
});
|
||||
}
|
||||
|
||||
return [...employeesByUserId.values()];
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
if (Array.isArray(message) && typeof message[0] === "string") {
|
||||
return message[0];
|
||||
}
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
};
|
||||
|
||||
const Field = ({ label, children }: { label: string; children: ReactNode }) => (
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
const ManagementDialog = ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const UsersPage = () => {
|
||||
const { user } = useAuth();
|
||||
const [organizations, setOrganizations] = useState<OrganizationRecord[]>([]);
|
||||
const [orgEmployees, setOrgEmployees] = useState<EmployeeRecord[]>([]);
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||
const [orgUserSearch, setOrgUserSearch] = useState("");
|
||||
const [createUserForm, setCreateUserForm] = useState<UserFormState>(emptyUserForm);
|
||||
const [availableRoles, setAvailableRoles] = useState<RoleRecord[]>([]);
|
||||
const [roleIds, setRoleIds] = useState<string[]>([]);
|
||||
const [selectedRoleUser, setSelectedRoleUser] = useState<EmployeeRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orgEmployeesLoading, setOrgEmployeesLoading] = useState(false);
|
||||
const [rolesLoading, setRolesLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [isCreateUserOpen, setIsCreateUserOpen] = useState(false);
|
||||
const [isManageRolesOpen, setIsManageRolesOpen] = useState(false);
|
||||
|
||||
const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin"));
|
||||
const allowedOrgIds = useMemo(
|
||||
() => new Set((user?.employee ?? []).map((employee) => employee.organizationId).filter(Boolean)),
|
||||
[user?.employee],
|
||||
);
|
||||
|
||||
const visibleOrganizations = useMemo(() => {
|
||||
if (isSuperAdmin) {
|
||||
return organizations;
|
||||
}
|
||||
|
||||
return organizations.filter((organization) => allowedOrgIds.has(organization.id));
|
||||
}, [allowedOrgIds, isSuperAdmin, organizations]);
|
||||
|
||||
const selectedOrganization = useMemo(
|
||||
() => visibleOrganizations.find((item) => item.id === selectedOrgId) ?? null,
|
||||
[selectedOrgId, visibleOrganizations],
|
||||
);
|
||||
|
||||
const filteredOrgEmployees = useMemo(() => {
|
||||
const query = orgUserSearch.trim().toLowerCase();
|
||||
|
||||
return orgEmployees.filter((employee) => {
|
||||
const label = getLocaleLabel(
|
||||
employee.name ?? employee.user?.name,
|
||||
employee.user?.email ?? employee.id,
|
||||
).toLowerCase();
|
||||
const email = employee.user?.email?.toLowerCase() ?? "";
|
||||
const username = employee.user?.username?.toLowerCase() ?? "";
|
||||
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return label.includes(query) || email.includes(query) || username.includes(query);
|
||||
});
|
||||
}, [orgEmployees, orgUserSearch]);
|
||||
|
||||
const resetMessages = () => {
|
||||
setActionError(null);
|
||||
setActionSuccess(null);
|
||||
};
|
||||
|
||||
const loadOrganizations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<OrganizationRecord>>("/organizations");
|
||||
setOrganizations(getItems(response.data));
|
||||
} catch (error) {
|
||||
setLoadError(getErrorMessage(error, "Failed to load organizations."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadOrgEmployees = useCallback(async (organizationId: string) => {
|
||||
setOrgEmployeesLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<EmployeeRecord>>(
|
||||
`/backoffice/organizations/${organizationId}/employees`,
|
||||
);
|
||||
setOrgEmployees(mergeEmployeesByUser(getItems(response.data)));
|
||||
} catch {
|
||||
setOrgEmployees([]);
|
||||
} finally {
|
||||
setOrgEmployeesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrganizations();
|
||||
}, [loadOrganizations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleOrganizations.length) {
|
||||
setSelectedOrgId(null);
|
||||
setOrgEmployees([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedOrgId(visibleOrganizations[0]?.id ?? null);
|
||||
}, [selectedOrgId, visibleOrganizations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOrgId) {
|
||||
setOrgEmployees([]);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadOrgEmployees(selectedOrgId);
|
||||
}, [loadOrgEmployees, selectedOrgId]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
resetMessages();
|
||||
await Promise.all([
|
||||
loadOrganizations(),
|
||||
selectedOrgId ? loadOrgEmployees(selectedOrgId) : Promise.resolve(),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSelectOrganization = async (organizationId: string) => {
|
||||
setSelectedOrgId(organizationId);
|
||||
setOrgEmployees([]);
|
||||
resetMessages();
|
||||
|
||||
try {
|
||||
await loadOrgEmployees(organizationId);
|
||||
} catch {
|
||||
// Loader already handles fallback state.
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateUserDialog = () => {
|
||||
if (!selectedOrgId) {
|
||||
setActionError("Select an organization before adding a user.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateUserForm(emptyUserForm);
|
||||
resetMessages();
|
||||
setIsCreateUserOpen(true);
|
||||
};
|
||||
|
||||
const openManageRolesDialog = async (employee: EmployeeRecord) => {
|
||||
if (!selectedOrgId || !employee.user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRolesLoading(true);
|
||||
resetMessages();
|
||||
setSelectedRoleUser(employee);
|
||||
setIsManageRolesOpen(true);
|
||||
|
||||
try {
|
||||
const [rolesResponse, assignedResponse] = await Promise.all([
|
||||
api.get<ListResponse<RoleRecord>>("/roles"),
|
||||
api.get<RoleRecord[]>(`/backoffice/organizations/${selectedOrgId}/employee-users/${employee.user.id}/roles`),
|
||||
]);
|
||||
const roles = getItems(rolesResponse.data).filter((role) => !RESERVED_ROLE_KEYS.has(role.key));
|
||||
setAvailableRoles(roles);
|
||||
setRoleIds(getItems(assignedResponse.data).map((role) => role.id));
|
||||
} catch (error) {
|
||||
setActionError(getErrorMessage(error, "Failed to load user roles."));
|
||||
setAvailableRoles([]);
|
||||
setRoleIds([]);
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedOrgId) {
|
||||
setActionError("Select an organization before adding a user.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
resetMessages();
|
||||
|
||||
try {
|
||||
const response = await api.post<EmployeeRecord>(
|
||||
`/backoffice/organizations/${selectedOrgId}/users`,
|
||||
{
|
||||
username: createUserForm.username.trim(),
|
||||
phoneNumber: createUserForm.phoneNumber.trim(),
|
||||
email: createUserForm.email.trim(),
|
||||
name: {
|
||||
am: createUserForm.nameAm.trim(),
|
||||
en: createUserForm.nameEn.trim(),
|
||||
},
|
||||
assignOrganizationAdmin: createUserForm.assignOrganizationAdmin,
|
||||
},
|
||||
);
|
||||
|
||||
const shouldAssignOrganizationAdmin = createUserForm.assignOrganizationAdmin;
|
||||
setCreateUserForm(emptyUserForm);
|
||||
setIsCreateUserOpen(false);
|
||||
setActionSuccess(
|
||||
shouldAssignOrganizationAdmin
|
||||
? "User created as organization admin. Default password: 12345678."
|
||||
: "User created. Default password: 12345678.",
|
||||
);
|
||||
await loadOrgEmployees(selectedOrgId);
|
||||
|
||||
if (!shouldAssignOrganizationAdmin) {
|
||||
await openManageRolesDialog(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
setActionError(getErrorMessage(error, "Failed to create user."));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRoles = async () => {
|
||||
if (!selectedOrgId || !selectedRoleUser?.user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
resetMessages();
|
||||
|
||||
try {
|
||||
await api.put(
|
||||
`/backoffice/organizations/${selectedOrgId}/employee-users/${selectedRoleUser.user.id}/roles`,
|
||||
{ roleIds },
|
||||
);
|
||||
setActionSuccess("User roles updated.");
|
||||
setIsManageRolesOpen(false);
|
||||
} catch (error) {
|
||||
setActionError(getErrorMessage(error, "Failed to update user roles."));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserActivation = async (employee: EmployeeRecord) => {
|
||||
if (!employee.user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInactive = employee.status?.toLowerCase() === "inactive";
|
||||
setSubmitting(true);
|
||||
resetMessages();
|
||||
|
||||
try {
|
||||
await api.patch(`/users/${isInactive ? "activate-user" : "deactivate-user"}/${employee.user.id}`);
|
||||
setActionSuccess(isInactive ? "User activated." : "User deactivated.");
|
||||
if (selectedOrgId) {
|
||||
await loadOrgEmployees(selectedOrgId);
|
||||
}
|
||||
} catch (error) {
|
||||
setActionError(getErrorMessage(error, "Failed to update user status."));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6 bg-background p-6 text-foreground">
|
||||
<div className="rounded-3xl border border-border bg-linear-to-br from-emerald-100 via-card to-background p-6 shadow-sm dark:from-emerald-950/30 dark:via-card dark:to-background">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
|
||||
<Network className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-300">
|
||||
User management
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Users</h1>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Create organization users, activate or deactivate access, and assign organization-scoped roles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionSuccess ? (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
{actionSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loadError ? (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200">
|
||||
{loadError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-3xl border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
||||
Loading users workspace...
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 xl:grid-cols-4">
|
||||
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-lg font-semibold text-card-foreground">Organization</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isSuperAdmin ? "All organizations" : "Assigned organizations"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{visibleOrganizations.length ? (
|
||||
<ul className="space-y-2">
|
||||
{visibleOrganizations.map((organization) => {
|
||||
const isActive = selectedOrgId === organization.id;
|
||||
const isDisabled = !isSuperAdmin;
|
||||
|
||||
return (
|
||||
<li key={organization.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => void handleSelectOrganization(organization.id)}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isActive
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
: "border-border bg-card text-card-foreground hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:bg-slate-900"
|
||||
} ${isDisabled ? "cursor-default" : ""}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{getLocaleLabel(organization.name, organization.key)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{organization.key}</div>
|
||||
</div>
|
||||
{!isSuperAdmin ? <Badge className="bg-sky-100 text-sky-700">Assigned</Badge> : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No organizations available.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedOrgId || submitting}
|
||||
onClick={openCreateUserDialog}
|
||||
className={`${buttonClassName} mt-4 w-full bg-emerald-600 text-white hover:bg-emerald-700`}
|
||||
>
|
||||
Add user to organization
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="rounded-3xl border border-border bg-card p-5 shadow-sm xl:col-span-3">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-card-foreground">Users</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedOrganization
|
||||
? `Manage users in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}`
|
||||
: "Select an organization"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
|
||||
{orgEmployees.length} users
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className={`${inputClassName} pl-9`}
|
||||
value={orgUserSearch}
|
||||
onChange={(event) => setOrgUserSearch(event.target.value)}
|
||||
placeholder="Search users"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{orgEmployeesLoading ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading users...
|
||||
</div>
|
||||
) : filteredOrgEmployees.length ? (
|
||||
<div className="space-y-3">
|
||||
{filteredOrgEmployees.map((employee) => {
|
||||
const userId = employee.user?.id;
|
||||
const displayName = getLocaleLabel(
|
||||
employee.name ?? employee.user?.name,
|
||||
employee.user?.email ?? employee.id,
|
||||
);
|
||||
const assignedPositions = employee.employeePositions
|
||||
?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? ""))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<article
|
||||
key={employee.id}
|
||||
className="rounded-2xl border border-border bg-background p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-foreground">{displayName}</h3>
|
||||
{employee.status ? (
|
||||
<Badge className="bg-gray-100 text-gray-700">{employee.status}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{employee.user?.email || employee.user?.username || "No contact info"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{assignedPositions ? `Current positions: ${assignedPositions}` : "No positions assigned."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!userId || submitting}
|
||||
onClick={() => void handleToggleUserActivation(employee)}
|
||||
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
|
||||
>
|
||||
{employee.status?.toLowerCase() === "inactive" ? (
|
||||
<>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Activate
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
Deactivate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!userId || !selectedOrgId || submitting}
|
||||
onClick={() => void openManageRolesDialog(employee)}
|
||||
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Manage roles
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{selectedOrganization
|
||||
? "No users found for this organization."
|
||||
: "Select an organization to load users."}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ManagementDialog
|
||||
open={isCreateUserOpen}
|
||||
onOpenChange={setIsCreateUserOpen}
|
||||
title="Add user"
|
||||
description={
|
||||
selectedOrganization
|
||||
? `Create a loginable user in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}. Default password: 12345678.`
|
||||
: "Create a loginable user in the selected organization."
|
||||
}
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleCreateUser}>
|
||||
<Field label="English name">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={createUserForm.nameEn}
|
||||
onChange={(event) => setCreateUserForm((current) => ({ ...current, nameEn: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Amharic name">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={createUserForm.nameAm}
|
||||
onChange={(event) => setCreateUserForm((current) => ({ ...current, nameAm: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="email"
|
||||
value={createUserForm.email}
|
||||
onChange={(event) => setCreateUserForm((current) => ({ ...current, email: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Username">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={createUserForm.username}
|
||||
onChange={(event) => setCreateUserForm((current) => ({ ...current, username: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Phone number">
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={createUserForm.phoneNumber}
|
||||
onChange={(event) => setCreateUserForm((current) => ({ ...current, phoneNumber: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createUserForm.assignOrganizationAdmin}
|
||||
onChange={(event) =>
|
||||
setCreateUserForm((current) => ({
|
||||
...current,
|
||||
assignOrganizationAdmin: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Create as organization admin</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Also assigns built-in org admin access and the freight org manager role.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateUserOpen(false)}
|
||||
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={submitting} className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}>
|
||||
Create user
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ManagementDialog>
|
||||
|
||||
<ManagementDialog
|
||||
open={isManageRolesOpen}
|
||||
onOpenChange={setIsManageRolesOpen}
|
||||
title="Manage roles"
|
||||
description={
|
||||
selectedRoleUser
|
||||
? `Assign organization-scoped roles for ${getLocaleLabel(selectedRoleUser.name ?? selectedRoleUser.user?.name, selectedRoleUser.user?.email ?? selectedRoleUser.id)}.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{rolesLoading ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading roles...
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
|
||||
{availableRoles.map((role) => (
|
||||
<label
|
||||
key={role.id}
|
||||
className="flex items-center gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roleIds.includes(role.id)}
|
||||
onChange={(event) => {
|
||||
setRoleIds((current) =>
|
||||
event.target.checked
|
||||
? [...current, role.id]
|
||||
: current.filter((currentRoleId) => currentRoleId !== role.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{getLocaleLabel(role.name, role.key)}</div>
|
||||
<div className="text-xs text-muted-foreground">{role.key}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{!availableRoles.length ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No assignable roles available.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsManageRolesOpen(false)}
|
||||
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || rolesLoading}
|
||||
onClick={() => void handleSaveRoles()}
|
||||
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
|
||||
>
|
||||
Save roles
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ManagementDialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
@@ -1,207 +0,0 @@
|
||||
import type { DesignConfig } from "@tria-plc/iamui";
|
||||
|
||||
import {
|
||||
FREIGHT_BRAND,
|
||||
FREIGHT_BRAND_LIGHT,
|
||||
freightBrand,
|
||||
} from "@/theme/freight-brand";
|
||||
|
||||
export const iamConfig: DesignConfig = {
|
||||
brand: {
|
||||
appName: "EDR Freight Backoffice",
|
||||
logoUrl: "/assets/logo.svg",
|
||||
},
|
||||
colors: {
|
||||
primary: FREIGHT_BRAND,
|
||||
primaryForeground: "#ffffff",
|
||||
secondary: "#f4f7fb",
|
||||
background: "#f7f9fb",
|
||||
foreground: "#0f172a",
|
||||
border: "#eef1f4",
|
||||
muted: "#f1f5f9",
|
||||
mutedForeground: "#64748b",
|
||||
card: "#ffffff",
|
||||
sidebar: "#ffffff",
|
||||
danger: "#ef4444",
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
headingFontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
baseFontSize: "15px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
shape: {
|
||||
radius: "1rem",
|
||||
},
|
||||
shadows: {
|
||||
card: "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
dropdown: "0 12px 30px rgba(15, 23, 42, 0.12)",
|
||||
modal: "0 20px 45px rgba(15, 23, 42, 0.2)",
|
||||
},
|
||||
components: {
|
||||
buttonDefaultVariant: "filled",
|
||||
inputDefaultSize: "sm",
|
||||
inputRadius: "md",
|
||||
modalRadius: "lg",
|
||||
tableHighlightOnHover: true,
|
||||
},
|
||||
layout: {
|
||||
userManagementView: "classic",
|
||||
showTopBar: true as any,
|
||||
sidebarWidth: "280px",
|
||||
sidebarCollapsedWidth: "80px",
|
||||
headerHeight: "80px",
|
||||
contentMaxWidth: "none",
|
||||
sidebarBackground: "#ffffff",
|
||||
sidebarColor: "#475569",
|
||||
sidebarMutedColor: "#94a3b8",
|
||||
sidebarActiveBackground:
|
||||
"linear-gradient(135deg, rgba(45, 191, 149, 0.14) 0%, rgba(27, 158, 122, 0.06) 100%)",
|
||||
sidebarActiveColor: FREIGHT_BRAND,
|
||||
sidebarHoverBackground: "#f5f7fa",
|
||||
sidebarBorder: "#eef1f4",
|
||||
sidebarRail: `linear-gradient(180deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
|
||||
sidebarBrandLabel: "EDR Freight",
|
||||
sidebarBrandSublabel: "Backoffice Console",
|
||||
menuBackground: "#ffffff",
|
||||
menuActiveColor: FREIGHT_BRAND,
|
||||
menuActiveBorderColor: FREIGHT_BRAND,
|
||||
menuColor: "#64748b",
|
||||
menuHoverColor: "#0f172a",
|
||||
modalAccentColor: `linear-gradient(135deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
|
||||
modalHeaderBackground: "#ffffff",
|
||||
modalHeaderEditBackground: "#ffffff",
|
||||
modalIconBackground: freightBrand.mutedBg,
|
||||
modalIconColor: FREIGHT_BRAND,
|
||||
modalTitleColor: "#0f172a",
|
||||
modalFocusColor: FREIGHT_BRAND,
|
||||
modalSurface: "#ffffff",
|
||||
},
|
||||
appearance: {
|
||||
colorScheme: "light",
|
||||
slots: {
|
||||
root: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
color: "#0f172a",
|
||||
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
},
|
||||
},
|
||||
page: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "24px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
card: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "20px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "16px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
"sidebar-brand": {
|
||||
styles: {
|
||||
minHeight: "80px",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
},
|
||||
},
|
||||
topbar: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "16px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
"topbar-panel": {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "12px",
|
||||
},
|
||||
},
|
||||
"topbar-user-summary": {
|
||||
styles: {
|
||||
borderRadius: "14px",
|
||||
},
|
||||
},
|
||||
table: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "20px",
|
||||
overflow: "hidden",
|
||||
},
|
||||
},
|
||||
"table-header": {
|
||||
styles: {
|
||||
background: "#f8fafc",
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
styles: {
|
||||
borderRadius: "24px",
|
||||
overflow: "hidden",
|
||||
},
|
||||
},
|
||||
"modal-header": {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
borderBottom: "1px solid #eef1f4",
|
||||
},
|
||||
},
|
||||
},
|
||||
customCss: `
|
||||
[data-um-app="user-management"] {
|
||||
--um-page-gap: 20px;
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] h1,
|
||||
[data-um-app="user-management"] h2,
|
||||
[data-um-app="user-management"] h3,
|
||||
[data-um-app="user-management"] h4,
|
||||
[data-um-app="user-management"] h5,
|
||||
[data-um-app="user-management"] h6 {
|
||||
letter-spacing: -0.02em;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] [data-um-slot="sidebar-item"][aria-current="page"] {
|
||||
box-shadow: inset 3px 0 0 ${FREIGHT_BRAND};
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] button,
|
||||
[data-um-app="user-management"] input,
|
||||
[data-um-app="user-management"] select,
|
||||
[data-um-app="user-management"] textarea {
|
||||
font-family: 'Outfit', var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
`,
|
||||
},
|
||||
};
|
||||
@@ -57,6 +57,7 @@ import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||
|
||||
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
|
||||
@@ -546,6 +547,7 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n
|
||||
const LastMilePage = () => {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
@@ -1031,6 +1033,12 @@ const LastMilePage = () => {
|
||||
const handleAdvanceStatus = (record: LastMileRecord) => {
|
||||
const next = NEXT_STATUS[record.status];
|
||||
if (!next) return;
|
||||
// Completing a delivery requires proof of delivery — open the capture modal
|
||||
// instead of advancing straight to DELIVERED.
|
||||
if (next === "DELIVERED") {
|
||||
setPodRecord(record);
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate(
|
||||
{ id: record.id, data: { status: next } },
|
||||
{
|
||||
@@ -2080,6 +2088,17 @@ const LastMilePage = () => {
|
||||
onClose={() => setDetentionRecord(null)}
|
||||
record={detentionRecord}
|
||||
/>
|
||||
|
||||
<ProofOfDeliveryModal
|
||||
opened={Boolean(podRecord)}
|
||||
onClose={() => setPodRecord(null)}
|
||||
lastMileId={podRecord?.id ?? null}
|
||||
reference={podRecord?.booking?.reference ?? null}
|
||||
onDone={() => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
@@ -13,10 +12,10 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
@@ -286,18 +285,16 @@ export default function ArrivalQueuePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||
breadcrumbs={[{ label: 'Arrival queue' }]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="warehouse"
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||
/>
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
@@ -428,8 +425,7 @@ export default function ArrivalQueuePage() {
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Alert,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -26,13 +24,12 @@ import {
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
ActivityTimeline,
|
||||
InventoryMovementHistoryTable,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
@@ -289,23 +286,16 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Djibouti Arrival / Unloading Queue' }]} />
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Djibouti Arrival / Unloading Queue"
|
||||
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
|
||||
breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<PageHeader
|
||||
title="Djibouti Arrival / Unloading Queue"
|
||||
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
|
||||
/>
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="container"
|
||||
title="Export Unloading at Djibouti Port"
|
||||
subtitle="Review arrived trains, auto unload eligible export items, then generate the EDR and Djibouti Port signed interchange document."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived export train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -451,8 +441,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(historyInventoryId)}
|
||||
@@ -475,6 +464,6 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
</Tabs>
|
||||
) : null}
|
||||
</Modal>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import {
|
||||
@@ -323,6 +325,132 @@ export default function InterchangeDocumentsPage() {
|
||||
run(() => dispute.mutateAsync({ id: document.id, remarks }), 'Interchange document disputed');
|
||||
};
|
||||
|
||||
const documentColumns: ColumnDef<InterchangeDocument>[] = [
|
||||
{
|
||||
id: 'documentNo',
|
||||
header: 'Document No',
|
||||
cell: ({ row }) => (
|
||||
<Text fw={700} size="sm">
|
||||
{row.original.documentNo}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ 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: '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 },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={statusColor[row.original.status]}>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'signedBy',
|
||||
header: 'Signed By',
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{row.original.generatedBy ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.acknowledgedBy ?? 'Awaiting Djibouti Port'}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{ id: 'generatedAt', header: 'Generated At', cell: ({ row }) => formatDate(row.original.generatedAt) },
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => {
|
||||
const doc = row.original;
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => setViewId(doc.id)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{doc.status !== 'ACKNOWLEDGED' && doc.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="green"
|
||||
variant="light"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
onClick={() => acknowledgeDocument(doc)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
) : null}
|
||||
{doc.status === 'ACKNOWLEDGED' ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => run(() => printDocument(doc), 'Print view opened')}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => run(() => downloadDocument(doc), 'Document downloaded')}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{doc.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
variant="light"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => disputeDocument(doc)}
|
||||
>
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -342,143 +470,19 @@ export default function InterchangeDocumentsPage() {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : documents.length === 0 ? (
|
||||
{!isLoading && documents.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No interchange documents"
|
||||
description="Generated freight handover documents appear here."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1060}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Document No</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Train No / Schedule</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Handover Location</Table.Th>
|
||||
<Table.Th>Handover From</Table.Th>
|
||||
<Table.Th>Handover To</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Signed By</Table.Th>
|
||||
<Table.Th>Generated At</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{documents.map((document) => (
|
||||
<Table.Tr key={document.id}>
|
||||
<Table.Td>
|
||||
<Text fw={700} size="sm">
|
||||
{document.documentNo}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{document.direction}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{document.trainNo ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{document.scheduleId?.slice(0, 8) ?? '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{document.routeId?.slice(0, 8) ?? '-'}</Table.Td>
|
||||
<Table.Td>{document.handoverLocation}</Table.Td>
|
||||
<Table.Td>{document.handoverFrom}</Table.Td>
|
||||
<Table.Td>{document.handoverTo}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={statusColor[document.status]}>
|
||||
{document.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{document.generatedBy ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{document.acknowledgedBy ?? 'Awaiting Djibouti Port'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatDate(document.generatedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => setViewId(document.id)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{document.status !== 'ACKNOWLEDGED' && document.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="green"
|
||||
variant="light"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
onClick={() => acknowledgeDocument(document)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
) : null}
|
||||
{document.status === 'ACKNOWLEDGED' ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => run(() => printDocument(document), 'Print view opened')}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => run(() => downloadDocument(document), 'Document downloaded')}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{document.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
variant="light"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => disputeDocument(document)}
|
||||
>
|
||||
Dispute
|
||||
</Button>
|
||||
) : null}
|
||||
{document.status === 'DRAFT' || document.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<XCircle size={14} />}
|
||||
onClick={() =>
|
||||
run(() => cancel.mutateAsync(document.id), 'Interchange document cancelled')
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
VisualEmptyState,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
|
||||
@@ -74,6 +75,8 @@ export default function LoadingQueuePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card>
|
||||
<Tabs defaultValue="ready">
|
||||
<Tabs.List>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
||||
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
@@ -58,58 +58,49 @@ export default function WarehouseDashboardPage() {
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Center py="xl">
|
||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
padding="lg"
|
||||
onClick={() => navigate(metric.to)}
|
||||
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Center py="xl">
|
||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
padding="lg"
|
||||
onClick={() => navigate(metric.to)}
|
||||
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
InventoryWorkbench,
|
||||
WarehouseStatusBadge,
|
||||
WarehouseTypeBadge,
|
||||
ZoneOccupancyHeatmap,
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
@@ -298,6 +299,8 @@ export default function WarehouseDetailPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ZoneOccupancyHeatmap yardId={selectedYardId ?? undefined} />
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Select a yard to view its zones.
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { AccrualDashboard } from '@/components/warehouses';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
@@ -122,6 +123,13 @@ export default function WarehouseInvoicesPage() {
|
||||
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text fw={700} size="sm" tt="uppercase" c="dimmed">
|
||||
Accruing now
|
||||
</Text>
|
||||
<AccrualDashboard />
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
|
||||
@@ -6,13 +6,11 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
@@ -20,6 +18,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
@@ -209,6 +209,48 @@ function AllocationRules() {
|
||||
}
|
||||
};
|
||||
|
||||
const allocationColumns: ColumnDef<AllocationRule>[] = [
|
||||
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
|
||||
{ id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
||||
{
|
||||
id: 'targetYard',
|
||||
header: 'Target yard',
|
||||
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Active',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={row.original.isActive ? 'green' : 'gray'} variant="light">
|
||||
{row.original.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(row.original)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
@@ -226,62 +268,13 @@ function AllocationRules() {
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Priority</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th>
|
||||
<Table.Th>Cargo code</Table.Th>
|
||||
<Table.Th>Target yard</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((rule) => (
|
||||
<Table.Tr key={rule.id}>
|
||||
<Table.Td>{rule.priority}</Table.Td>
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{rule.targetYardCode}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
|
||||
{rule.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<DataTable
|
||||
columns={allocationColumns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
@@ -576,6 +569,75 @@ function FeeRules() {
|
||||
}
|
||||
};
|
||||
|
||||
const feeColumns: ColumnDef<FeeRule>[] = [
|
||||
{
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={RULE_TYPE_COLOR[row.original.ruleType] ?? 'gray'} variant="light">
|
||||
{FEE_RULE_TYPE_LABELS[row.original.ruleType] ?? row.original.ruleType}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
|
||||
{ id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
||||
{ id: 'container', header: 'Container', cell: ({ row }) => row.original.containerType ?? dash },
|
||||
{
|
||||
id: 'scope',
|
||||
header: 'Location scope',
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original;
|
||||
const hasScope = [rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean);
|
||||
if (!hasScope) return dash;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
|
||||
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
|
||||
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
|
||||
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ id: 'freeDays', header: 'Free days', cell: ({ row }) => row.original.freeDays },
|
||||
{
|
||||
id: 'rate',
|
||||
header: 'Rate / day',
|
||||
cell: ({ row }) => `${Number(row.original.ratePerDay).toLocaleString()} ${row.original.currency}`,
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Active',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={row.original.isActive ? 'green' : 'gray'} variant="light">
|
||||
{row.original.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(row.original)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
@@ -587,83 +649,13 @@ function FeeRules() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Location scope</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((rule) => (
|
||||
<Table.Tr key={rule.id}>
|
||||
<Table.Td>
|
||||
<Badge color={RULE_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
|
||||
{FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.containerType ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
{[rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean) ? (
|
||||
<Stack gap={2}>
|
||||
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
|
||||
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
|
||||
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
|
||||
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
|
||||
</Stack>
|
||||
) : (
|
||||
dash
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.freeDays}</Table.Td>
|
||||
<Table.Td>
|
||||
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
|
||||
{rule.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<DataTable
|
||||
columns={feeColumns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No storage or demurrage fee rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||
<Stack gap="sm">
|
||||
|
||||
Reference in New Issue
Block a user