mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix
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-indigo-500 focus:border-indigo-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-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<UserSquare2 className="h-5 w-5 text-blue-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-blue-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-purple-600 dark:text-purple-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 />;
|
||||
};
|
||||
347
apps/edr-freight-web/backoffice/src/pages/OrgAdminDashboard.tsx
Normal file
347
apps/edr-freight-web/backoffice/src/pages/OrgAdminDashboard.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
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 = () => [
|
||||
{
|
||||
id: "employees",
|
||||
title: t("dashboard.totalEmployees"),
|
||||
value: activeReport?.employeesCount?.toLocaleString() || "0",
|
||||
icon: Users,
|
||||
color: "from-blue-500 to-blue-600",
|
||||
},
|
||||
...(!isUnitAdmin
|
||||
? [{
|
||||
id: "units",
|
||||
title: t("dashboard.totalUnits"),
|
||||
value: report?.unitsCount?.toLocaleString() || "0",
|
||||
icon: Building2,
|
||||
color: "from-primary-500 to-primary-600",
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
id: "positions",
|
||||
title: t("dashboard.totalPositions"),
|
||||
value: activeReport?.positionsCount?.toLocaleString() || "0",
|
||||
icon: FileText,
|
||||
color: "from-purple-500 to-purple-600",
|
||||
},
|
||||
];
|
||||
|
||||
// Use report activities if available, otherwise use static data
|
||||
|
||||
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-blue-500 to-cyan-600",
|
||||
},
|
||||
{
|
||||
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-purple-500 to-indigo-600",
|
||||
},
|
||||
{
|
||||
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-600",
|
||||
},
|
||||
{
|
||||
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-orange-500 to-red-600",
|
||||
},
|
||||
{
|
||||
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-teal-500 to-cyan-600",
|
||||
},
|
||||
];
|
||||
|
||||
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-blue-500 dark:bg-gray-800 dark:border-gray-700 dark:border-l-blue-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;
|
||||
Reference in New Issue
Block a user