This commit is contained in:
natib21
2026-07-10 12:09:27 +00:00
40 changed files with 8348 additions and 1422 deletions

View File

@@ -1,268 +1,268 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/shared/common/ui/table";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { listAuditLogExtensions } from "@/shared/services/audit/audit.api";
interface ActivityLog {
id: string;
createdAt: string;
entityName: string;
queryMethod: "INSERT" | "UPDATE" | "DELETE" | string;
user: {
id: string;
name: {
am: string;
en: string;
};
email: string;
username?: string;
};
}
const ITEMS_PER_PAGE = 10;
export default function ActivityLogPage() {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [allLogs, setAllLogs] = useState<ActivityLog[]>([]);
const [filteredLogs, setFilteredLogs] = useState<ActivityLog[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const fetchAuditLogs = useCallback(async () => {
setLoading(true);
try {
// Fetch all 1000 records from super admin endpoint
const data = await listAuditLogExtensions(
"/audit-log-extensions/audit/superAdmin",
{
skip: 0,
take: 1000,
orderBy: "createdAt:DESC",
}
);
const logs = (data.items || []) as ActivityLog[];
setAllLogs(logs);
setCurrentPage(1);
} catch (error: any) {
console.error("Error fetching audit logs:", error);
setAllLogs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAuditLogs();
// Refresh every 30 seconds
const interval = setInterval(fetchAuditLogs, 30000);
return () => clearInterval(interval);
}, [fetchAuditLogs]);
// Paginate the logs whenever currentPage changes
useEffect(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
let filtered = allLogs.slice(startIndex, endIndex);
// Apply search filter
if (searchQuery) {
filtered = filtered.filter(
(log) =>
log.entityName.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.user.name.am.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.user.name.en.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.user.email.toLowerCase().includes(searchQuery.toLowerCase()),
);
}
setFilteredLogs(filtered);
}, [allLogs, currentPage, searchQuery]);
const totalPages = Math.ceil(allLogs.length / ITEMS_PER_PAGE);
const hasNextPage = currentPage < totalPages;
const hasPrevPage = currentPage > 1;
return (
<div className="p-6 space-y-6">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="text-xl font-semibold">
{t("activityLogPage.title")}
</CardTitle>
<Button
variant="outline"
onClick={fetchAuditLogs}
disabled={loading}
className="flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
{t("common.refresh", "Refresh")}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
<Input
placeholder={t("activityLogPage.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="rounded-md border border-gray-200 dark:border-gray-700">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">
{t("activityLogPage.table.time")}
</TableHead>
<TableHead className="w-[400px]">
{t("activityLogPage.table.description")}
</TableHead>
<TableHead>
{t("activityLogPage.table.performedBy")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
<div className="flex items-center justify-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>{t("common.loading", "Loading...")}</span>
</div>
</TableCell>
</TableRow>
) : filteredLogs.length > 0 ? (
filteredLogs.map((log) => {
const userName =
i18n.language === "am" ? log.user.name.am : log.user.name.en;
const entityLabel = log.entityName.replace(/_/g, " ");
const actionVerb =
log.queryMethod.toLowerCase() === "insert"
? "created"
: log.queryMethod.toLowerCase() === "update"
? "updated"
: log.queryMethod.toLowerCase() === "delete"
? "deleted"
: log.queryMethod.toLowerCase();
const timestamp = new Date(log.createdAt).toLocaleString(
i18n.language,
{
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}
);
return (
<TableRow key={log.id}>
<TableCell className="font-medium">{timestamp}</TableCell>
<TableCell>
<p className="text-sm text-gray-900 dark:text-gray-100">
<span className="font-semibold">{userName}</span>
{" "}
<span className="text-gray-600 dark:text-gray-300">
{actionVerb}
</span>
{" "}
<span className="text-gray-900 dark:text-gray-100 font-medium">
{entityLabel}
</span>
{" "}
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
</p>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{userName}</p>
<p className="text-xs text-gray-500">{log.user.email}</p>
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell
colSpan={3}
className="h-24 text-center text-muted-foreground"
>
{t("activityLogPage.table.noLogs")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination Footer */}
{allLogs.length > 0 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-600 dark:text-gray-400">
{t("common.showing", "Showing")} {(currentPage - 1) * ITEMS_PER_PAGE + 1}-
{Math.min(currentPage * ITEMS_PER_PAGE, allLogs.length)}{" "}
{t("common.of", "of")} {allLogs.length}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={!hasPrevPage || loading}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
{currentPage} / {totalPages || 1}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!hasNextPage || loading}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/shared/common/ui/table";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { listAuditLogExtensions } from "@/shared/services/audit/audit.api";
interface ActivityLog {
id: string;
createdAt: string;
entityName: string;
queryMethod: "INSERT" | "UPDATE" | "DELETE" | string;
user: {
id: string;
name: {
am: string;
en: string;
};
email: string;
username?: string;
};
}
const ITEMS_PER_PAGE = 10;
export default function ActivityLogPage() {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [allLogs, setAllLogs] = useState<ActivityLog[]>([]);
const [filteredLogs, setFilteredLogs] = useState<ActivityLog[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const fetchAuditLogs = useCallback(async () => {
setLoading(true);
try {
// Fetch all 1000 records from super admin endpoint
const data = await listAuditLogExtensions(
"/audit-log-extensions/audit/superAdmin",
{
skip: 0,
take: 1000,
orderBy: "createdAt:DESC",
}
);
const logs = (data.items || []) as ActivityLog[];
setAllLogs(logs);
setCurrentPage(1);
} catch (error: any) {
console.error("Error fetching audit logs:", error);
setAllLogs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAuditLogs();
// Refresh every 30 seconds
const interval = setInterval(fetchAuditLogs, 30000);
return () => clearInterval(interval);
}, [fetchAuditLogs]);
// Paginate the logs whenever currentPage changes
useEffect(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
let filtered = allLogs.slice(startIndex, endIndex);
// Apply search filter
if (searchQuery) {
filtered = filtered.filter(
(log) =>
log.entityName.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.user.name.am.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.user.name.en.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.user.email.toLowerCase().includes(searchQuery.toLowerCase()),
);
}
setFilteredLogs(filtered);
}, [allLogs, currentPage, searchQuery]);
const totalPages = Math.ceil(allLogs.length / ITEMS_PER_PAGE);
const hasNextPage = currentPage < totalPages;
const hasPrevPage = currentPage > 1;
return (
<div className="p-6 space-y-6">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="text-xl font-semibold">
{t("activityLogPage.title")}
</CardTitle>
<Button
variant="outline"
onClick={fetchAuditLogs}
disabled={loading}
className="flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
{t("common.refresh", "Refresh")}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
<Input
placeholder={t("activityLogPage.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="rounded-md border border-gray-200 dark:border-gray-700">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">
{t("activityLogPage.table.time")}
</TableHead>
<TableHead className="w-[400px]">
{t("activityLogPage.table.description")}
</TableHead>
<TableHead>
{t("activityLogPage.table.performedBy")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
<div className="flex items-center justify-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>{t("common.loading", "Loading...")}</span>
</div>
</TableCell>
</TableRow>
) : filteredLogs.length > 0 ? (
filteredLogs.map((log) => {
const userName =
i18n.language === "am" ? log.user.name.am : log.user.name.en;
const entityLabel = log.entityName.replace(/_/g, " ");
const actionVerb =
log.queryMethod.toLowerCase() === "insert"
? "created"
: log.queryMethod.toLowerCase() === "update"
? "updated"
: log.queryMethod.toLowerCase() === "delete"
? "deleted"
: log.queryMethod.toLowerCase();
const timestamp = new Date(log.createdAt).toLocaleString(
i18n.language,
{
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}
);
return (
<TableRow key={log.id}>
<TableCell className="font-medium">{timestamp}</TableCell>
<TableCell>
<p className="text-sm text-gray-900 dark:text-gray-100">
<span className="font-semibold">{userName}</span>
{" "}
<span className="text-gray-600 dark:text-gray-300">
{actionVerb}
</span>
{" "}
<span className="text-gray-900 dark:text-gray-100 font-medium">
{entityLabel}
</span>
{" "}
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
</p>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{userName}</p>
<p className="text-xs text-gray-500">{log.user.email}</p>
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell
colSpan={3}
className="h-24 text-center text-muted-foreground"
>
{t("activityLogPage.table.noLogs")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination Footer */}
{allLogs.length > 0 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-600 dark:text-gray-400">
{t("common.showing", "Showing")} {(currentPage - 1) * ITEMS_PER_PAGE + 1}-
{Math.min(currentPage * ITEMS_PER_PAGE, allLogs.length)}{" "}
{t("common.of", "of")} {allLogs.length}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={!hasPrevPage || loading}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
{currentPage} / {totalPages || 1}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!hasNextPage || loading}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import SitesPage from "@/super-admin/components/sites/SitesPage";
const AddSitePage = () => {
return <SitesPage />;
};
export default AddSitePage;
import SitesPage from "@/super-admin/components/sites/SitesPage";
const AddSitePage = () => {
return <SitesPage />;
};
export default AddSitePage;

View File

@@ -1,11 +1,141 @@
// STUB — record-management/routes lazy-imports this page. The advanced dashboard
// is not migrated into this backoffice; render a placeholder instead of failing
// the build. Replace with the real page if it is brought over.
export default function AdvancedDashboardPage() {
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
import {
PermittedDashboardItem,
DashaboardParam,
} from "@/record-management/services/api/advancedDashboardService";
import { useAdvancedDashboardHook } from "@/shared/hooks/useAdvancedDashboardHook";
import useSettings from "@/record-management/components/hooks/useSettings";
import { useLocalizedName } from "@/shared/common/localizedName";
import { getFormattedDashboardLabel } from "@/performance-management/utils/dashboardUtils";
import { PositionDto } from "@/record-management/dto/userRecords/usersDto";
const AdvancedDashboardPage = () => {
const [selectedDashboard, setSelectedDashboard] =
useState<PermittedDashboardItem | null>(null);
const [selectedParameters, setSelectedParameters] = useState<
Record<string, any>
>({});
const localizedName = useLocalizedName();
const {
permittedDashboards,
dashboardUrl,
isLoadingPermitted,
isLoadingUrl,
} = useAdvancedDashboardHook(selectedDashboard?.id);
const { allScopedDepartments, allScopedEmployees } = useSettings();
const positionOptions = Array.isArray(allScopedDepartments)
? allScopedDepartments.map((pos: PositionDto) => ({
label: localizedName(pos.name) || "N/A",
value: pos.id,
}))
: [];
const employeeOptions = Array.isArray(allScopedEmployees)
? allScopedEmployees.map((emp: any) => ({
label: localizedName(emp.name) || "N/A",
value: emp.id,
}))
: [];
const handleParameterSelect = (key: string, value: string) => {
setSelectedParameters((prev) => ({
...prev,
[key]: value,
}));
};
return (
<div className="w-full p-6 text-sm text-gray-500">
<p className="font-semibold text-gray-700">Advanced Dashboard</p>
<p>This page has not been migrated into the backoffice yet.</p>
<div className="space-y-6">
{/* Dashboard + Position + Employee in a row */}
<div className="flex flex-wrap gap-4 items-start">
{/* Dashboard Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="flex items-center justify-between w-60"
disabled={isLoadingPermitted}>
{isLoadingPermitted
? "Loading dashboards..."
: getFormattedDashboardLabel(selectedDashboard?.name, "am") ||
"Select Dashboard"}
<ChevronDown className="ml-2 h-4 w-4 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-60">
<DropdownMenuLabel>Available Dashboards</DropdownMenuLabel>
{permittedDashboards?.items.map((dashboard) => (
<DropdownMenuItem
key={dashboard.id}
onClick={() => {
setSelectedDashboard(dashboard);
setSelectedParameters({}); // reset parameters
}}>
{getFormattedDashboardLabel(dashboard.name, "am")}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Position Dropdown */}
{selectedDashboard?.params?.includes(DashaboardParam.POSITION) && (
<select
className="border rounded-md w-60 p-2"
value={selectedParameters["position"] || ""}
onChange={(e) => handleParameterSelect("position", e.target.value)}>
<option value="">Select Position</option>
{positionOptions.map((pos) => (
<option key={pos.value} value={pos.value}>
{pos.label}
</option>
))}
</select>
)}
{/* Employee Dropdown */}
{selectedDashboard?.params?.includes(DashaboardParam.EMPLOYEE) && (
<select
className="border rounded-md w-60 p-2"
value={selectedParameters["employee"] || ""}
onChange={(e) => handleParameterSelect("employee", e.target.value)}>
<option value="">Select Employee</option>
{employeeOptions.map((emp) => (
<option key={emp.value} value={emp.value}>
{emp.label}
</option>
))}
</select>
)}
</div>
{/* Dashboard iframe */}
{isLoadingUrl ? (
<p>Loading dashboard...</p>
) : dashboardUrl ? (
<iframe
src={dashboardUrl?.iframeUrl}
width="100%"
height="800"
className="rounded-xl border"
title="Advanced Dashboard"
/>
) : (
<p className="text-gray-500">Select a dashboard to view</p>
)}
</div>
);
}
};
export default AdvancedDashboardPage;

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

View File

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

View 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 (AZ)</SelectItem>
<SelectItem value="user:DESC">User (ZA)</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;

View File

@@ -0,0 +1,7 @@
import { BulkUserUpload } from "@/user-management/bulkUpload/bulkUpload";
const BulkUploadPage = () => {
return <BulkUserUpload />;
};
export default BulkUploadPage;

View File

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

View File

@@ -0,0 +1,7 @@
import ContentManagement from "@/user-management/components/content/ContentManagement";
const ContentManagementPage = () => {
return <ContentManagement />;
};
export default ContentManagementPage;

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

View File

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

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

View File

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

View 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 }}>
&copy; {t("landingPage.copyright")} {new Date().getFullYear()}{" "}
{t("landingPage.rightsReserved")}
</p>
</div>
</div>
</footer>
);
};
export default Footer;

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,7 @@
import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins";
const OrganizationAdminsPage = () => {
return <OrganizationsAdmins />;
};
export default OrganizationAdminsPage;

View File

@@ -0,0 +1,9 @@
import { AdminRegistrationForm } from "@/super-admin/components/organizations/AdminRegistrationForm";
export default function AdminRegistrationPage() {
return (
<div className="p-6">
<AdminRegistrationForm />
</div>
);
}

View File

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

View File

@@ -0,0 +1,7 @@
import { NewOrganizationForm } from "@/super-admin/components/organizations/NewOrganizationForm";
const NewOrganizationPage = () => {
return <NewOrganizationForm />;
};
export default NewOrganizationPage;

View File

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

View File

@@ -0,0 +1,7 @@
import Organizations from "@/super-admin/components/organizations/Organizations";
const OrganizationsPage = () => {
return <Organizations />;
};
export default OrganizationsPage;

View File

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,9 @@
import UserManagementTree from "../user-management/userManagement/UserManagementTree";
export default function UserManagementPage() {
return (
<div className="p-6">
<UserManagementTree />
</div>
);
}

View File

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

View File

@@ -0,0 +1,7 @@
import { WebManagementHomePage } from "@/user-management/web-Management/webManagement";
const WebManagement = () => {
return <WebManagementHomePage />;
};
export default WebManagement;

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

View File

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

View File

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

View File

@@ -21,6 +21,28 @@ import AllRecordDetailsPage from "./all-records/pages/AllRecordDetailsPage";
import Branding from "./web-Management/Branding/Branding";
import { WebManagementHomePage } from "./web-Management/webManagement";
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";
/**
@@ -29,9 +51,6 @@ import { SidebarProvider } from "@/shared/common/ui/sidebar";
* 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
@@ -48,20 +67,26 @@ function PrivateRoute({ allowedRoles }: { allowedRoles: string[] }): ReactElemen
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.
*/
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>
@@ -71,13 +96,13 @@ function Placeholder({ name }: { name: string }): ReactElement {
export function UserManagementRoutes(): ReactElement {
return (
<Route
element={
<SidebarProvider>
<AppLayout />
</SidebarProvider>
}
>
{/* <Route
element={
<SidebarProvider>
<AppLayout />
</SidebarProvider>
}
>
<Route
path="user-management"
element={
<div className="w-full flex flex-col p-4">
@@ -88,7 +113,7 @@ export function UserManagementRoutes(): ReactElement {
<Route
path="/user-management/archive/edit/:id"
element={<UserProfileEditPage />}
/> */}
/>
<Route
path="user-management/position-management"
element={<PositionManagementPage />}
@@ -112,15 +137,15 @@ export function UserManagementRoutes(): ReactElement {
<PrivateRoute allowedRoles={["super_admin"]} />
}
>
{/* <Route
<Route
path="user-management/dashboard"
element={<DashboardPage />}
/>
<Route
path="user-management/organizations"
element={<OrganizationsPage />}
/> */}
{/* <Route
/>
<Route
path="user-management/organizations/detail/:id"
element={<OrganizationDetailPage />}
/>
@@ -131,11 +156,11 @@ export function UserManagementRoutes(): ReactElement {
<Route
path="user-management/organizations/new"
element={<NewOrganizationPage />}
/> */}
{/* <Route
/>
<Route
path="user-management/organizations/edit/:id"
element={<EditOrganizationPage />}
/> */}
/>
<Route
path="user-management/external_users"
element={<PendingExternalUsers />}
@@ -144,15 +169,15 @@ export function UserManagementRoutes(): ReactElement {
path="user-management/migrated-records-management/view/:id"
element={<ViewMigratedDataPage />}
/>
{/* <Route
<Route
path="user-management/external_users/view/:id"
element={<UploadedDocumentViewPage />}
/>
<Route
path="user-management/external_users/edit/:id"
element={<UserProfileEditPage />}
/> */}
{/* <Route
/>
<Route
path="user-management/organization_admins"
element={<OrganizationAdminsPage />}
/>
@@ -163,8 +188,8 @@ export function UserManagementRoutes(): ReactElement {
<Route
path="user-management/activity_log"
element={<ActivityLogPage />}
/> */}
{/* <Route
/>
<Route
path="user-management/settings"
element={<SettingsPage />}
/>
@@ -175,15 +200,15 @@ export function UserManagementRoutes(): ReactElement {
<Route
path="user-management/archived-organizations"
element={<SuperAdminArchivedPage />}
/> */}
/>
<Route
path="user-management/templates"
element={<TemplatePage />}
/>
{/* <Route
<Route
path="user-management/add-site"
element={<AddSitePage />}
/> */}
/>
<Route
path="/user-management/web-management/Branding/Branding/:siteId"
element={<Branding />}
@@ -202,10 +227,10 @@ export function UserManagementRoutes(): ReactElement {
/>
}
>
{/* <Route
<Route
path="user-management/user_management-dashboard"
element={<OrgAdminDashboard />}
/> */}
/>
<Route
path="user-management/user_management"
element={<UserManagementPage />}
@@ -243,7 +268,7 @@ export function UserManagementRoutes(): ReactElement {
</WithPermission>
}
/>
{/* <Route
<Route
path="user-management/content-management"
element={<ContentManagementPage />}
/>
@@ -254,8 +279,8 @@ export function UserManagementRoutes(): ReactElement {
<Route
path="user-management/web-management/*"
element={<WebManagement />}
/> */}
{/* <Route
/>
<Route
path="user-management/archives"
element={<ArchiveUsersPage />}
/>
@@ -270,7 +295,7 @@ export function UserManagementRoutes(): ReactElement {
<Route
path="user-management/organization-settings"
element={<ConfigurationPage />}
/> */}
/>
</Route>
</Route>
);