mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
fix ui
This commit is contained in:
@@ -1,268 +1,268 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/common/ui/card";
|
} from "@/shared/common/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
} from "@/shared/common/ui/table";
|
} from "@/shared/common/ui/table";
|
||||||
import { Input } from "@/shared/common/ui/input";
|
import { Input } from "@/shared/common/ui/input";
|
||||||
import { Button } from "@/shared/common/ui/button";
|
import { Button } from "@/shared/common/ui/button";
|
||||||
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { listAuditLogExtensions } from "@/shared/services/audit/audit.api";
|
import { listAuditLogExtensions } from "@/shared/services/audit/audit.api";
|
||||||
|
|
||||||
interface ActivityLog {
|
interface ActivityLog {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
entityName: string;
|
entityName: string;
|
||||||
queryMethod: "INSERT" | "UPDATE" | "DELETE" | string;
|
queryMethod: "INSERT" | "UPDATE" | "DELETE" | string;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
name: {
|
name: {
|
||||||
am: string;
|
am: string;
|
||||||
en: string;
|
en: string;
|
||||||
};
|
};
|
||||||
email: string;
|
email: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 10;
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
export default function ActivityLogPage() {
|
export default function ActivityLogPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [allLogs, setAllLogs] = useState<ActivityLog[]>([]);
|
const [allLogs, setAllLogs] = useState<ActivityLog[]>([]);
|
||||||
const [filteredLogs, setFilteredLogs] = useState<ActivityLog[]>([]);
|
const [filteredLogs, setFilteredLogs] = useState<ActivityLog[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const fetchAuditLogs = useCallback(async () => {
|
const fetchAuditLogs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch all 1000 records from super admin endpoint
|
// Fetch all 1000 records from super admin endpoint
|
||||||
const data = await listAuditLogExtensions(
|
const data = await listAuditLogExtensions(
|
||||||
"/audit-log-extensions/audit/superAdmin",
|
"/audit-log-extensions/audit/superAdmin",
|
||||||
{
|
{
|
||||||
skip: 0,
|
skip: 0,
|
||||||
take: 1000,
|
take: 1000,
|
||||||
orderBy: "createdAt:DESC",
|
orderBy: "createdAt:DESC",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const logs = (data.items || []) as ActivityLog[];
|
const logs = (data.items || []) as ActivityLog[];
|
||||||
setAllLogs(logs);
|
setAllLogs(logs);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error fetching audit logs:", error);
|
console.error("Error fetching audit logs:", error);
|
||||||
setAllLogs([]);
|
setAllLogs([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAuditLogs();
|
fetchAuditLogs();
|
||||||
// Refresh every 30 seconds
|
// Refresh every 30 seconds
|
||||||
const interval = setInterval(fetchAuditLogs, 30000);
|
const interval = setInterval(fetchAuditLogs, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchAuditLogs]);
|
}, [fetchAuditLogs]);
|
||||||
|
|
||||||
// Paginate the logs whenever currentPage changes
|
// Paginate the logs whenever currentPage changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||||
let filtered = allLogs.slice(startIndex, endIndex);
|
let filtered = allLogs.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(
|
||||||
(log) =>
|
(log) =>
|
||||||
log.entityName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
log.entityName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
log.user.name.am.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
log.user.name.am.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
log.user.name.en.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
log.user.name.en.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
log.user.email.toLowerCase().includes(searchQuery.toLowerCase()),
|
log.user.email.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilteredLogs(filtered);
|
setFilteredLogs(filtered);
|
||||||
}, [allLogs, currentPage, searchQuery]);
|
}, [allLogs, currentPage, searchQuery]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(allLogs.length / ITEMS_PER_PAGE);
|
const totalPages = Math.ceil(allLogs.length / ITEMS_PER_PAGE);
|
||||||
const hasNextPage = currentPage < totalPages;
|
const hasNextPage = currentPage < totalPages;
|
||||||
const hasPrevPage = currentPage > 1;
|
const hasPrevPage = currentPage > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<CardTitle className="text-xl font-semibold">
|
<CardTitle className="text-xl font-semibold">
|
||||||
{t("activityLogPage.title")}
|
{t("activityLogPage.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={fetchAuditLogs}
|
onClick={fetchAuditLogs}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
{t("common.refresh", "Refresh")}
|
{t("common.refresh", "Refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("activityLogPage.searchPlaceholder")}
|
placeholder={t("activityLogPage.searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-gray-200 dark:border-gray-700">
|
<div className="rounded-md border border-gray-200 dark:border-gray-700">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[180px]">
|
<TableHead className="w-[180px]">
|
||||||
{t("activityLogPage.table.time")}
|
{t("activityLogPage.table.time")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[400px]">
|
<TableHead className="w-[400px]">
|
||||||
{t("activityLogPage.table.description")}
|
{t("activityLogPage.table.description")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("activityLogPage.table.performedBy")}
|
{t("activityLogPage.table.performedBy")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="h-24 text-center">
|
<TableCell colSpan={5} className="h-24 text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
<span>{t("common.loading", "Loading...")}</span>
|
<span>{t("common.loading", "Loading...")}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : filteredLogs.length > 0 ? (
|
) : filteredLogs.length > 0 ? (
|
||||||
filteredLogs.map((log) => {
|
filteredLogs.map((log) => {
|
||||||
const userName =
|
const userName =
|
||||||
i18n.language === "am" ? log.user.name.am : log.user.name.en;
|
i18n.language === "am" ? log.user.name.am : log.user.name.en;
|
||||||
const entityLabel = log.entityName.replace(/_/g, " ");
|
const entityLabel = log.entityName.replace(/_/g, " ");
|
||||||
const actionVerb =
|
const actionVerb =
|
||||||
log.queryMethod.toLowerCase() === "insert"
|
log.queryMethod.toLowerCase() === "insert"
|
||||||
? "created"
|
? "created"
|
||||||
: log.queryMethod.toLowerCase() === "update"
|
: log.queryMethod.toLowerCase() === "update"
|
||||||
? "updated"
|
? "updated"
|
||||||
: log.queryMethod.toLowerCase() === "delete"
|
: log.queryMethod.toLowerCase() === "delete"
|
||||||
? "deleted"
|
? "deleted"
|
||||||
: log.queryMethod.toLowerCase();
|
: log.queryMethod.toLowerCase();
|
||||||
const timestamp = new Date(log.createdAt).toLocaleString(
|
const timestamp = new Date(log.createdAt).toLocaleString(
|
||||||
i18n.language,
|
i18n.language,
|
||||||
{
|
{
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={log.id}>
|
<TableRow key={log.id}>
|
||||||
<TableCell className="font-medium">{timestamp}</TableCell>
|
<TableCell className="font-medium">{timestamp}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<p className="text-sm text-gray-900 dark:text-gray-100">
|
<p className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
<span className="font-semibold">{userName}</span>
|
<span className="font-semibold">{userName}</span>
|
||||||
{" "}
|
{" "}
|
||||||
<span className="text-gray-600 dark:text-gray-300">
|
<span className="text-gray-600 dark:text-gray-300">
|
||||||
{actionVerb}
|
{actionVerb}
|
||||||
</span>
|
</span>
|
||||||
{" "}
|
{" "}
|
||||||
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
||||||
{entityLabel}
|
{entityLabel}
|
||||||
</span>
|
</span>
|
||||||
{" "}
|
{" "}
|
||||||
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
|
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{userName}</p>
|
<p className="font-medium">{userName}</p>
|
||||||
<p className="text-xs text-gray-500">{log.user.email}</p>
|
<p className="text-xs text-gray-500">{log.user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={3}
|
colSpan={3}
|
||||||
className="h-24 text-center text-muted-foreground"
|
className="h-24 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t("activityLogPage.table.noLogs")}
|
{t("activityLogPage.table.noLogs")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination Footer */}
|
{/* Pagination Footer */}
|
||||||
{allLogs.length > 0 && (
|
{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="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">
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{t("common.showing", "Showing")} {(currentPage - 1) * ITEMS_PER_PAGE + 1}-
|
{t("common.showing", "Showing")} {(currentPage - 1) * ITEMS_PER_PAGE + 1}-
|
||||||
{Math.min(currentPage * ITEMS_PER_PAGE, allLogs.length)}{" "}
|
{Math.min(currentPage * ITEMS_PER_PAGE, allLogs.length)}{" "}
|
||||||
{t("common.of", "of")} {allLogs.length}
|
{t("common.of", "of")} {allLogs.length}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentPage(currentPage - 1)}
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
disabled={!hasPrevPage || loading}
|
disabled={!hasPrevPage || loading}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
|
||||||
{currentPage} / {totalPages || 1}
|
{currentPage} / {totalPages || 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentPage(currentPage + 1)}
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
disabled={!hasNextPage || loading}
|
disabled={!hasNextPage || loading}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SitesPage from "@/super-admin/components/sites/SitesPage";
|
import SitesPage from "@/super-admin/components/sites/SitesPage";
|
||||||
|
|
||||||
const AddSitePage = () => {
|
const AddSitePage = () => {
|
||||||
return <SitesPage />;
|
return <SitesPage />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AddSitePage;
|
export default AddSitePage;
|
||||||
|
|||||||
@@ -1,11 +1,141 @@
|
|||||||
// STUB — record-management/routes lazy-imports this page. The advanced dashboard
|
import React, { useState } from "react";
|
||||||
// is not migrated into this backoffice; render a placeholder instead of failing
|
import { Button } from "@/shared/common/ui/button";
|
||||||
// the build. Replace with the real page if it is brought over.
|
import {
|
||||||
export default function AdvancedDashboardPage() {
|
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 (
|
return (
|
||||||
<div className="w-full p-6 text-sm text-gray-500">
|
<div className="space-y-6">
|
||||||
<p className="font-semibold text-gray-700">Advanced Dashboard</p>
|
{/* Dashboard + Position + Employee in a row */}
|
||||||
<p>This page has not been migrated into the backoffice yet.</p>
|
<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>
|
</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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
20
apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx
Normal file
20
apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root landing redirect for the vendored IAM pages. Sends super admins to the
|
||||||
|
* user-management dashboard and everyone else to the freight overview. Minimal
|
||||||
|
* replacement for the source app's @/routes/RootRedirect (not copied over).
|
||||||
|
*/
|
||||||
|
export function RootRedirect(): ReactElement {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const roles = (user?.roles ?? []).map((r) => r.key).filter(Boolean);
|
||||||
|
if (roles.includes("super_admin")) {
|
||||||
|
return <Navigate to="/user-management/dashboard" replace />;
|
||||||
|
}
|
||||||
|
return <Navigate to="/dashboard/overview" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RootRedirect;
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import type { ReactElement } from "react";
|
|
||||||
import { Navigate, Outlet, Route } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
|
||||||
import { isSuperAdmin } from "@/lib/permissions";
|
|
||||||
import { WithPermission } from "@/shared/hooks/useHas";
|
|
||||||
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
|
|
||||||
import TemplatePage from "@/super-admin/components/templates/components/templates";
|
|
||||||
import UserManagementPage from "@/pages/dashboard/user-management/UserManagementPage";
|
|
||||||
|
|
||||||
import CreatePositionPage from "./pages/position-management/create";
|
|
||||||
import EditPositionPage from "./pages/position-management/edit";
|
|
||||||
import PositionManagementPage from "./pages/position-management";
|
|
||||||
import MigratedDataManagementPage from "./pages/position-management/MigratedDataManagementPage";
|
|
||||||
import UserPositionApprovalPage from "./pages/UserPositionApprovalPage";
|
|
||||||
import ViewMigratedDataPage from "./components/MigratedRecords/ViewMigratedDataPage";
|
|
||||||
import ContentManagement from "./components/content/ContentManagement";
|
|
||||||
import { BulkUserUpload } from "./bulkUpload/bulkUpload";
|
|
||||||
import AllRecordsPage from "./all-records/pages/AllRecordsPage";
|
|
||||||
import AllRecordDetailsPage from "./all-records/pages/AllRecordDetailsPage";
|
|
||||||
import Branding from "./web-Management/Branding/Branding";
|
|
||||||
import { WebManagementHomePage } from "./web-Management/webManagement";
|
|
||||||
import { AppLayout } from "./Applayout";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Layout wrapper for the user-management route subtree. Self-contained (the
|
|
||||||
* original vendored AppLayout was removed when the folder was re-copied and it
|
|
||||||
* pulled the un-migrated @/record-management chrome). Just renders the matched
|
|
||||||
* child route; swap in the real IAM shell later if desired.
|
|
||||||
*/
|
|
||||||
function UmLayout(): ReactElement {
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Role gate for the nested super-admin / org-admin route groups. Renders child
|
|
||||||
* routes via <Outlet /> only when the user holds an allowed role (super admins
|
|
||||||
* always pass); otherwise bounces to the overview.
|
|
||||||
*/
|
|
||||||
function PrivateRoute({ allowedRoles }: { allowedRoles: string[] }): ReactElement {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const roleKeys = (user?.roles ?? [])
|
|
||||||
.map((r) => r.key)
|
|
||||||
.filter((k): k is string => Boolean(k));
|
|
||||||
const allowed =
|
|
||||||
isSuperAdmin(user) || allowedRoles.some((r) => roleKeys.includes(r));
|
|
||||||
return allowed ? <Outlet /> : <Navigate to="/dashboard/overview" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UserManagementRedirect = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const roles = user?.roles?.map((r) => r.key) || [];
|
|
||||||
|
|
||||||
if (roles.includes("super_admin")) {
|
|
||||||
return <Navigate to="/user-management/dashboard" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roles.includes("admin") || roles.includes("unit_admin")) {
|
|
||||||
return <Navigate to="/user-management/user_management-dashboard" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Navigate to="/" replace />; // fallback
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for user-management pages present in the source IAM app's route
|
|
||||||
* map but not yet migrated into this backoffice. Keeps the route live and
|
|
||||||
* honest instead of failing the build. See the `TODO(um-migration)` markers.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* User-management route subtree. Mount inside the app's <Routes>:
|
|
||||||
* <Routes>{UserManagementRoutes()}</Routes>
|
|
||||||
* Child paths are relative (RRv6 forbids absolute paths on nested routes), so
|
|
||||||
* they serve at /user-management/* under the app root.
|
|
||||||
*/
|
|
||||||
export function UserManagementRoutes(): ReactElement {
|
|
||||||
return (
|
|
||||||
<Route element={<AppLayout />}>
|
|
||||||
<Route
|
|
||||||
path="user-management"
|
|
||||||
element={
|
|
||||||
<div className="w-full flex flex-col p-4">
|
|
||||||
<UserManagementRedirect />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/user-management/archive/edit/:id"
|
|
||||||
element={<UserProfileEditPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/position-management"
|
|
||||||
element={<PositionManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/migrated-records-management"
|
|
||||||
element={<MigratedDataManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/position-management/edit/:id"
|
|
||||||
element={<EditPositionPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/position-management/new"
|
|
||||||
element={<CreatePositionPage />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Super Admin */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PrivateRoute allowedRoles={["super_admin"]} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route
|
|
||||||
path="user-management/dashboard"
|
|
||||||
element={<DashboardPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/organizations"
|
|
||||||
element={<OrganizationsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/organizations/detail/:id"
|
|
||||||
element={<OrganizationDetailPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="super-admin/organizations/:id"
|
|
||||||
element={<OrganizationDetailPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/organizations/new"
|
|
||||||
element={<NewOrganizationPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/organizations/edit/:id"
|
|
||||||
element={<EditOrganizationPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/external_users"
|
|
||||||
element={<PendingExternalUsers />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/migrated-records-management/view/:id"
|
|
||||||
element={<ViewMigratedDataPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/external_users/view/:id"
|
|
||||||
element={<UploadedDocumentViewPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/external_users/edit/:id"
|
|
||||||
element={<UserProfileEditPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/organization_admins"
|
|
||||||
element={<OrganizationAdminsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/add_admin"
|
|
||||||
element={<AdminRegistrationPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/activity_log"
|
|
||||||
element={<ActivityLogPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/settings"
|
|
||||||
element={<SettingsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/archive-users"
|
|
||||||
element={<SuperAdminArchiveUsersPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/archived-organizations"
|
|
||||||
element={<SuperAdminArchivedPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/templates"
|
|
||||||
element={<TemplatePage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/add-site"
|
|
||||||
element={<AddSitePage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/user-management/web-management/Branding/Branding/:siteId"
|
|
||||||
element={<Branding />}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Org Admin */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PrivateRoute
|
|
||||||
allowedRoles={[
|
|
||||||
"admin",
|
|
||||||
"organization_admin",
|
|
||||||
"unit_admin",
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route
|
|
||||||
path="user-management/user_management-dashboard"
|
|
||||||
element={<OrgAdminDashboard />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/user_management"
|
|
||||||
element={<UserManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/user-position-approval"
|
|
||||||
element={
|
|
||||||
// <WithPermission
|
|
||||||
// perms={["can:activateEmployee"]}
|
|
||||||
// fallback={<Navigate to="/" replace />}
|
|
||||||
// >
|
|
||||||
<UserPositionApprovalPage />
|
|
||||||
// </WithPermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/all-records"
|
|
||||||
element={
|
|
||||||
<WithPermission
|
|
||||||
perms={["can:viewAllRecords"]}
|
|
||||||
fallback={<Navigate to="/" replace />}
|
|
||||||
>
|
|
||||||
<AllRecordsPage />
|
|
||||||
</WithPermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/all-records/:id"
|
|
||||||
element={
|
|
||||||
<WithPermission
|
|
||||||
perms={["can:viewAllRecords"]}
|
|
||||||
fallback={<Navigate to="/" replace />}
|
|
||||||
>
|
|
||||||
<AllRecordDetailsPage />
|
|
||||||
</WithPermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/content-management"
|
|
||||||
element={<ContentManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/bulk-upload"
|
|
||||||
element={<BulkUploadPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/web-management/*"
|
|
||||||
element={<WebManagement />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/archives"
|
|
||||||
element={<ArchiveUsersPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/sector-reports"
|
|
||||||
element={<SectorReportsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/archived"
|
|
||||||
element={<ArchivedUnitsPositionsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/organization-settings"
|
|
||||||
element={<ConfigurationPage />}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import type { ReactElement } from "react";
|
|
||||||
import { Navigate, Outlet, Route } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
|
||||||
import { isSuperAdmin } from "@/lib/permissions";
|
|
||||||
import { WithPermission } from "@/shared/hooks/useHas";
|
|
||||||
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
|
|
||||||
import TemplatePage from "@/super-admin/components/templates/components/templates";
|
|
||||||
import UserManagementPage from "@/pages/dashboard/user-management/UserManagementPage";
|
|
||||||
|
|
||||||
import CreatePositionPage from "./pages/position-management/create";
|
|
||||||
import EditPositionPage from "./pages/position-management/edit";
|
|
||||||
import PositionManagementPage from "./pages/position-management";
|
|
||||||
import MigratedDataManagementPage from "./pages/position-management/MigratedDataManagementPage";
|
|
||||||
import UserPositionApprovalPage from "./pages/UserPositionApprovalPage";
|
|
||||||
import ViewMigratedDataPage from "./components/MigratedRecords/ViewMigratedDataPage";
|
|
||||||
import ContentManagement from "./components/content/ContentManagement";
|
|
||||||
import { BulkUserUpload } from "./bulkUpload/bulkUpload";
|
|
||||||
import AllRecordsPage from "./all-records/pages/AllRecordsPage";
|
|
||||||
import AllRecordDetailsPage from "./all-records/pages/AllRecordDetailsPage";
|
|
||||||
import Branding from "./web-Management/Branding/Branding";
|
|
||||||
import { WebManagementHomePage } from "./web-Management/webManagement";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Layout wrapper for the user-management route subtree. Self-contained (the
|
|
||||||
* original vendored AppLayout was removed when the folder was re-copied and it
|
|
||||||
* pulled the un-migrated @/record-management chrome). Just renders the matched
|
|
||||||
* child route; swap in the real IAM shell later if desired.
|
|
||||||
*/
|
|
||||||
function UmLayout(): ReactElement {
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Role gate for the nested super-admin / org-admin route groups. Renders child
|
|
||||||
* routes via <Outlet /> only when the user holds an allowed role (super admins
|
|
||||||
* always pass); otherwise bounces to the overview.
|
|
||||||
*/
|
|
||||||
function PrivateRoute({ allowedRoles }: { allowedRoles: string[] }): ReactElement {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const roleKeys = (user?.roles ?? [])
|
|
||||||
.map((r) => r.key)
|
|
||||||
.filter((k): k is string => Boolean(k));
|
|
||||||
const allowed =
|
|
||||||
isSuperAdmin(user) || allowedRoles.some((r) => roleKeys.includes(r));
|
|
||||||
return allowed ? <Outlet /> : <Navigate to="/dashboard/overview" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for user-management pages present in the source IAM app's route
|
|
||||||
* map but not yet migrated into this backoffice. Keeps the route live and
|
|
||||||
* honest instead of failing the build. See the `TODO(um-migration)` markers.
|
|
||||||
*/
|
|
||||||
function Placeholder({ name }: { name: string }): ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="w-full p-6 text-sm text-gray-500">
|
|
||||||
<p className="font-semibold text-gray-700">{name}</p>
|
|
||||||
<p>This user-management page has not been migrated into the backoffice yet.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User-management route subtree. Mount inside the app's <Routes>:
|
|
||||||
* <Routes>{UserManagementRoutes()}</Routes>
|
|
||||||
* Child paths are relative (RRv6 forbids absolute paths on nested routes), so
|
|
||||||
* they serve at /user-management/* under the app root.
|
|
||||||
*/
|
|
||||||
export function UserManagementRoutes(): ReactElement {
|
|
||||||
return (
|
|
||||||
<Route element={<UmLayout />}>
|
|
||||||
<Route
|
|
||||||
path="user-management"
|
|
||||||
element={
|
|
||||||
<div className="w-full flex flex-col p-4">
|
|
||||||
{/* TODO(um-migration): UserManagementRedirect not migrated */}
|
|
||||||
<Placeholder name="UserManagementRedirect" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): UserProfileEditPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/archive/edit/:id"
|
|
||||||
element={<Placeholder name="UserProfileEditPage" />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/position-management"
|
|
||||||
element={<PositionManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/migrated-records-management"
|
|
||||||
element={<MigratedDataManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/position-management/edit/:id"
|
|
||||||
element={<EditPositionPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/position-management/new"
|
|
||||||
element={<CreatePositionPage />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Super Admin */}
|
|
||||||
<Route element={<PrivateRoute allowedRoles={["super_admin"]} />}>
|
|
||||||
{/* TODO(um-migration): DashboardPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/dashboard"
|
|
||||||
element={<Placeholder name="DashboardPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): OrganizationsPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/organizations"
|
|
||||||
element={<Placeholder name="OrganizationsPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): OrganizationDetailPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/organizations/detail/:id"
|
|
||||||
element={<Placeholder name="OrganizationDetailPage" />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="super-admin/organizations/:id"
|
|
||||||
element={<Placeholder name="OrganizationDetailPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): NewOrganizationPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/organizations/new"
|
|
||||||
element={<Placeholder name="NewOrganizationPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): EditOrganizationPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/organizations/edit/:id"
|
|
||||||
element={<Placeholder name="EditOrganizationPage" />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/external_users"
|
|
||||||
element={<PendingExternalUsers />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/migrated-records-management/view/:id"
|
|
||||||
element={<ViewMigratedDataPage />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): UploadedDocumentViewPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/external_users/view/:id"
|
|
||||||
element={<Placeholder name="UploadedDocumentViewPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): UserProfileEditPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/external_users/edit/:id"
|
|
||||||
element={<Placeholder name="UserProfileEditPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): OrganizationAdminsPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/organization_admins"
|
|
||||||
element={<Placeholder name="OrganizationAdminsPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): AdminRegistrationPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/add_admin"
|
|
||||||
element={<Placeholder name="AdminRegistrationPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): ActivityLogPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/activity_log"
|
|
||||||
element={<Placeholder name="ActivityLogPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): SettingsPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/settings"
|
|
||||||
element={<Placeholder name="SettingsPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): SuperAdminArchiveUsersPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/archive-users"
|
|
||||||
element={<Placeholder name="SuperAdminArchiveUsersPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): SuperAdminArchivedPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/archived-organizations"
|
|
||||||
element={<Placeholder name="SuperAdminArchivedPage" />}
|
|
||||||
/>
|
|
||||||
<Route path="user-management/templates" element={<TemplatePage />} />
|
|
||||||
{/* TODO(um-migration): AddSitePage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/add-site"
|
|
||||||
element={<Placeholder name="AddSitePage" />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/web-management/Branding/Branding/:siteId"
|
|
||||||
element={<Branding />}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Org Admin */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PrivateRoute
|
|
||||||
allowedRoles={["admin", "organization_admin", "unit_admin"]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* TODO(um-migration): OrgAdminDashboard not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/user_management-dashboard"
|
|
||||||
element={<Placeholder name="OrgAdminDashboard" />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/user_management"
|
|
||||||
element={<UserManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/user-position-approval"
|
|
||||||
element={<UserPositionApprovalPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/all-records"
|
|
||||||
element={
|
|
||||||
<WithPermission
|
|
||||||
perms={["can:viewAllRecords"]}
|
|
||||||
fallback={<Navigate to="/" replace />}
|
|
||||||
>
|
|
||||||
<AllRecordsPage />
|
|
||||||
</WithPermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/all-records/:id"
|
|
||||||
element={
|
|
||||||
<WithPermission
|
|
||||||
perms={["can:viewAllRecords"]}
|
|
||||||
fallback={<Navigate to="/" replace />}
|
|
||||||
>
|
|
||||||
<AllRecordDetailsPage />
|
|
||||||
</WithPermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/content-management"
|
|
||||||
element={<ContentManagement />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/bulk-upload"
|
|
||||||
element={<BulkUserUpload />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="user-management/web-management/*"
|
|
||||||
element={<WebManagementHomePage />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): ArchiveUsersPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/archives"
|
|
||||||
element={<Placeholder name="ArchiveUsersPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): SectorReportsPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/sector-reports"
|
|
||||||
element={<Placeholder name="SectorReportsPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): ArchivedUnitsPositionsPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/archived"
|
|
||||||
element={<Placeholder name="ArchivedUnitsPositionsPage" />}
|
|
||||||
/>
|
|
||||||
{/* TODO(um-migration): ConfigurationPage not migrated */}
|
|
||||||
<Route
|
|
||||||
path="user-management/organization-settings"
|
|
||||||
element={<Placeholder name="ConfigurationPage" />}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,28 @@ import AllRecordDetailsPage from "./all-records/pages/AllRecordDetailsPage";
|
|||||||
import Branding from "./web-Management/Branding/Branding";
|
import Branding from "./web-Management/Branding/Branding";
|
||||||
import { WebManagementHomePage } from "./web-Management/webManagement";
|
import { WebManagementHomePage } from "./web-Management/webManagement";
|
||||||
import { AppLayout } from "./Applayout";
|
import { AppLayout } from "./Applayout";
|
||||||
|
import ActivityLogPage from "@/pages/ActivityLogPage";
|
||||||
|
import AdminRegistrationPage from "@/pages/Organizations/AdminRegistrationPage";
|
||||||
|
import OrganizationAdminsPage from "@/pages/OrganizationAdminsPage";
|
||||||
|
import UserProfileEditPage from "@/pages/UserProfileEditPage";
|
||||||
|
import UploadedDocumentViewPage from "@/pages/UploadedDocumentViewPage";
|
||||||
|
import EditOrganizationPage from "@/pages/Organizations/EditOrganizationPage";
|
||||||
|
import NewOrganizationPage from "@/pages/Organizations/NewOrganizationPage";
|
||||||
|
import OrganizationDetailPage from "@/pages/Organizations/OrganizationDetailPage";
|
||||||
|
import OrganizationsPage from "@/pages/Organizations/OrganizationsPage";
|
||||||
|
import DashboardPage from "@/pages/DashboardPage";
|
||||||
|
import AddSitePage from "@/pages/AddSitePage";
|
||||||
|
import SuperAdminArchivedPage from "@/pages/SuperAdminArchivedPage";
|
||||||
|
import SuperAdminArchiveUsersPage from "@/pages/SuperAdminArchiveUsersPage";
|
||||||
|
import SettingsPage from "@/pages/SettingsPage";
|
||||||
|
import OrgAdminDashboard from "@/pages/OrgAdminDashboard";
|
||||||
|
import ContentManagementPage from "@/pages/ContentManagementPage";
|
||||||
|
import BulkUploadPage from "@/pages/BulkUploadPage";
|
||||||
|
import WebManagement from "@/pages/WebManagementPage";
|
||||||
|
import ArchiveUsersPage from "@/pages/ArchiveUsersPage";
|
||||||
|
import SectorReportsPage from "@/record-management/pages/User/sectorReports";
|
||||||
|
import ArchivedUnitsPositionsPage from "@/pages/ArchivedUnitsPositionsPage";
|
||||||
|
import ConfigurationPage from "@/pages/ConfigurationPage";
|
||||||
import { SidebarProvider } from "@/shared/common/ui/sidebar";
|
import { SidebarProvider } from "@/shared/common/ui/sidebar";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,9 +51,6 @@ import { SidebarProvider } from "@/shared/common/ui/sidebar";
|
|||||||
* pulled the un-migrated @/record-management chrome). Just renders the matched
|
* pulled the un-migrated @/record-management chrome). Just renders the matched
|
||||||
* child route; swap in the real IAM shell later if desired.
|
* child route; swap in the real IAM shell later if desired.
|
||||||
*/
|
*/
|
||||||
function UmLayout(): ReactElement {
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role gate for the nested super-admin / org-admin route groups. Renders child
|
* Role gate for the nested super-admin / org-admin route groups. Renders child
|
||||||
@@ -48,20 +67,26 @@ function PrivateRoute({ allowedRoles }: { allowedRoles: string[] }): ReactElemen
|
|||||||
return allowed ? <Outlet /> : <Navigate to="/dashboard/overview" replace />;
|
return allowed ? <Outlet /> : <Navigate to="/dashboard/overview" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const UserManagementRedirect = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const roles = user?.roles?.map((r) => r.key) || [];
|
||||||
|
|
||||||
|
if (roles.includes("super_admin")) {
|
||||||
|
return <Navigate to="/user-management/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles.includes("admin") || roles.includes("unit_admin")) {
|
||||||
|
return <Navigate to="/user-management/user_management-dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to="/" replace />; // fallback
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder for user-management pages present in the source IAM app's route
|
* Placeholder for user-management pages present in the source IAM app's route
|
||||||
* map but not yet migrated into this backoffice. Keeps the route live and
|
* map but not yet migrated into this backoffice. Keeps the route live and
|
||||||
* honest instead of failing the build. See the `TODO(um-migration)` markers.
|
* honest instead of failing the build. See the `TODO(um-migration)` markers.
|
||||||
*/
|
*/
|
||||||
function Placeholder({ name }: { name: string }): ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="w-full p-6 text-sm text-gray-500">
|
|
||||||
<p className="font-semibold text-gray-700">{name}</p>
|
|
||||||
<p>This user-management page has not been migrated into the backoffice yet.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-management route subtree. Mount inside the app's <Routes>:
|
* User-management route subtree. Mount inside the app's <Routes>:
|
||||||
* <Routes>{UserManagementRoutes()}</Routes>
|
* <Routes>{UserManagementRoutes()}</Routes>
|
||||||
@@ -71,13 +96,13 @@ function Placeholder({ name }: { name: string }): ReactElement {
|
|||||||
export function UserManagementRoutes(): ReactElement {
|
export function UserManagementRoutes(): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppLayout />
|
<AppLayout />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management"
|
path="user-management"
|
||||||
element={
|
element={
|
||||||
<div className="w-full flex flex-col p-4">
|
<div className="w-full flex flex-col p-4">
|
||||||
@@ -88,7 +113,7 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
<Route
|
<Route
|
||||||
path="/user-management/archive/edit/:id"
|
path="/user-management/archive/edit/:id"
|
||||||
element={<UserProfileEditPage />}
|
element={<UserProfileEditPage />}
|
||||||
/> */}
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="user-management/position-management"
|
path="user-management/position-management"
|
||||||
element={<PositionManagementPage />}
|
element={<PositionManagementPage />}
|
||||||
@@ -112,15 +137,15 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
<PrivateRoute allowedRoles={["super_admin"]} />
|
<PrivateRoute allowedRoles={["super_admin"]} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/dashboard"
|
path="user-management/dashboard"
|
||||||
element={<DashboardPage />}
|
element={<DashboardPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="user-management/organizations"
|
path="user-management/organizations"
|
||||||
element={<OrganizationsPage />}
|
element={<OrganizationsPage />}
|
||||||
/> */}
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/organizations/detail/:id"
|
path="user-management/organizations/detail/:id"
|
||||||
element={<OrganizationDetailPage />}
|
element={<OrganizationDetailPage />}
|
||||||
/>
|
/>
|
||||||
@@ -131,11 +156,11 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
<Route
|
<Route
|
||||||
path="user-management/organizations/new"
|
path="user-management/organizations/new"
|
||||||
element={<NewOrganizationPage />}
|
element={<NewOrganizationPage />}
|
||||||
/> */}
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/organizations/edit/:id"
|
path="user-management/organizations/edit/:id"
|
||||||
element={<EditOrganizationPage />}
|
element={<EditOrganizationPage />}
|
||||||
/> */}
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="user-management/external_users"
|
path="user-management/external_users"
|
||||||
element={<PendingExternalUsers />}
|
element={<PendingExternalUsers />}
|
||||||
@@ -144,15 +169,15 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
path="user-management/migrated-records-management/view/:id"
|
path="user-management/migrated-records-management/view/:id"
|
||||||
element={<ViewMigratedDataPage />}
|
element={<ViewMigratedDataPage />}
|
||||||
/>
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/external_users/view/:id"
|
path="user-management/external_users/view/:id"
|
||||||
element={<UploadedDocumentViewPage />}
|
element={<UploadedDocumentViewPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="user-management/external_users/edit/:id"
|
path="user-management/external_users/edit/:id"
|
||||||
element={<UserProfileEditPage />}
|
element={<UserProfileEditPage />}
|
||||||
/> */}
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/organization_admins"
|
path="user-management/organization_admins"
|
||||||
element={<OrganizationAdminsPage />}
|
element={<OrganizationAdminsPage />}
|
||||||
/>
|
/>
|
||||||
@@ -163,8 +188,8 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
<Route
|
<Route
|
||||||
path="user-management/activity_log"
|
path="user-management/activity_log"
|
||||||
element={<ActivityLogPage />}
|
element={<ActivityLogPage />}
|
||||||
/> */}
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/settings"
|
path="user-management/settings"
|
||||||
element={<SettingsPage />}
|
element={<SettingsPage />}
|
||||||
/>
|
/>
|
||||||
@@ -175,15 +200,15 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
<Route
|
<Route
|
||||||
path="user-management/archived-organizations"
|
path="user-management/archived-organizations"
|
||||||
element={<SuperAdminArchivedPage />}
|
element={<SuperAdminArchivedPage />}
|
||||||
/> */}
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="user-management/templates"
|
path="user-management/templates"
|
||||||
element={<TemplatePage />}
|
element={<TemplatePage />}
|
||||||
/>
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/add-site"
|
path="user-management/add-site"
|
||||||
element={<AddSitePage />}
|
element={<AddSitePage />}
|
||||||
/> */}
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/user-management/web-management/Branding/Branding/:siteId"
|
path="/user-management/web-management/Branding/Branding/:siteId"
|
||||||
element={<Branding />}
|
element={<Branding />}
|
||||||
@@ -202,10 +227,10 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/user_management-dashboard"
|
path="user-management/user_management-dashboard"
|
||||||
element={<OrgAdminDashboard />}
|
element={<OrgAdminDashboard />}
|
||||||
/> */}
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="user-management/user_management"
|
path="user-management/user_management"
|
||||||
element={<UserManagementPage />}
|
element={<UserManagementPage />}
|
||||||
@@ -243,7 +268,7 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
</WithPermission>
|
</WithPermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/content-management"
|
path="user-management/content-management"
|
||||||
element={<ContentManagementPage />}
|
element={<ContentManagementPage />}
|
||||||
/>
|
/>
|
||||||
@@ -254,8 +279,8 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
<Route
|
<Route
|
||||||
path="user-management/web-management/*"
|
path="user-management/web-management/*"
|
||||||
element={<WebManagement />}
|
element={<WebManagement />}
|
||||||
/> */}
|
/>
|
||||||
{/* <Route
|
<Route
|
||||||
path="user-management/archives"
|
path="user-management/archives"
|
||||||
element={<ArchiveUsersPage />}
|
element={<ArchiveUsersPage />}
|
||||||
/>
|
/>
|
||||||
@@ -270,7 +295,7 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
<Route
|
<Route
|
||||||
path="user-management/organization-settings"
|
path="user-management/organization-settings"
|
||||||
element={<ConfigurationPage />}
|
element={<ConfigurationPage />}
|
||||||
/> */}
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user