This commit is contained in:
natib21
2026-07-10 11:25:59 +00:00
parent 2696ca7498
commit e6e44e773b
1146 changed files with 200266 additions and 90527 deletions

View File

@@ -4,10 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5183 --clearScreen false",
"dev": "vite --port 3000 --clearScreen false",
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
"build": "vite build",
"preview": "vite preview --port 5183",
"preview": "vite preview --port 3000",
"lint": "eslint src",
"test": "vitest run",
"type-check": "tsc -b"
@@ -16,23 +16,84 @@
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^5.4.0",
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-collapsible": "^1.1.13",
"@radix-ui/react-context-menu": "^2.3.0",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-hover-card": "^1.1.16",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-navigation-menu": "^1.2.15",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-progress": "^1.1.9",
"@radix-ui/react-radio-group": "^1.4.0",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slider": "^1.4.0",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-toast": "^1.2.16",
"@radix-ui/react-toggle-group": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.9",
"@react-pdf-viewer/core": "^3.12.0",
"@react-pdf-viewer/default-layout": "^3.12.0",
"@react-pdf-viewer/zoom": "^3.12.0",
"@react-pdf/renderer": "^4.5.1",
"@reduxjs/toolkit": "^2.12.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-table": "^8.21.3",
"@tinymce/tinymce-react": "^6.3.0",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
"@vis.gl/react-google-maps": "^1.8.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"ethiopian-calendar-date-converter": "^2.1.6",
"file-type": "^18.7.0",
"framer-motion": "^12.40.0",
"html2canvas": "^1.4.1",
"i18next": "^26.3.5",
"jquery": "^3.7.1",
"js-cookie": "^3.0.8",
"jspdf": "^3.0.4",
"libphonenumber-js": "^1.12.24",
"lodash": "^4.18.1",
"lucide-react": "^1.14.0",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1",
"prop-types": "^15.8.1",
"qs": "^6.15.2",
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-css-nocode-editor": "^1.0.13",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.6",
"react-dropzone": "^14.4.1",
"react-hook-form": "^7.77.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.8",
"react-icons": "^5.6.0",
"react-image-crop": "^11.0.10",
"react-intersection-observer": "^9.16.0",
"react-pdf": "^10.4.1",
"react-pdf-html": "^2.1.5",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.27.0",
"react-signature-canvas": "1.1.0-alpha.2",
"recharts": "^3.8.1",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
@@ -40,6 +101,7 @@
"tailwind-merge": "^3.6.0",
"tinymce": "^8.6.0",
"xlsx": "^0.18.5",
"zod": "^3.25.76",
"zustand": "^5.0.0"
},
"devDependencies": {
@@ -47,6 +109,9 @@
"@edr/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@types/google.maps": "^3.65.2",
"@types/jquery": "^3.5.34",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.24",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",

View File

@@ -132,6 +132,7 @@ import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -667,6 +668,7 @@ const App = () => {
return (
<Routes>
{UserManagementRoutes()}
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
@@ -1232,18 +1234,18 @@ const App = () => {
/>
{/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} />
{/* <Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route
path="user-management/position-types"
element={<PositionTypesPage />}
/>
/> */}
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route
{/* <Route
path="user-management/permissions"
element={<PermissionsPage />}
/>
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="user-management/roles" element={<RolesPage />} /> */}
<Route
path="file-settings"

View File

@@ -0,0 +1,12 @@
// STUB — see DMS/pages/_shared/utils.ts. DMS documents API, not migrated.
// Returns empty lists so document lists render empty instead of crashing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function listMyDocuments(..._args: any[]): Promise<any[]> {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function getMyShares(..._args: any[]): Promise<any[]> {
return [];
}

View File

@@ -0,0 +1,24 @@
// STUB — see DMS/pages/_shared/utils.ts. DMS HTTP client, not migrated.
// Returns empty payloads so any DMS call degrades to "no results" rather than
// crashing. Wire the real @/DMS/api/dms.http if DMS is brought over.
function warn(method: string, url: string) {
// eslint-disable-next-line no-console
console.warn(`[DMS stub] ${method} ${url} — DMS is not migrated; returning empty.`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function empty<T = any>(): Promise<{ data: T }> {
return { data: {} as T };
}
export const dmsHttp = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get: <T = any>(url: string): Promise<{ data: T }> => (warn("GET", url), empty<T>()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
post: <T = any>(url: string): Promise<{ data: T }> => (warn("POST", url), empty<T>()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
put: <T = any>(url: string): Promise<{ data: T }> => (warn("PUT", url), empty<T>()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete: <T = any>(url: string): Promise<{ data: T }> => (warn("DELETE", url), empty<T>()),
};

View File

@@ -0,0 +1,12 @@
// STUB — see DMS/pages/_shared/utils.ts. DMS folders API, not migrated.
// Returns empty lists so folder pickers render empty instead of crashing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function getMySubFolders(..._args: any[]): Promise<any[]> {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function getMyCollaborationsSubFolders(..._args: any[]): Promise<any[]> {
return [];
}

View File

@@ -0,0 +1,21 @@
// STUB — see DMS/pages/_shared/utils.ts. DMS is not migrated; inert placeholder.
export type FileKind =
| "word"
| "excel"
| "ppt"
| "pdf"
| "image"
| "video"
| "file";
export function detectFileKind(nameOrMime?: string | null): FileKind {
const s = (nameOrMime ?? "").toLowerCase();
if (/\.(docx?|word)/.test(s)) return "word";
if (/\.(xlsx?|csv)/.test(s)) return "excel";
if (/\.(pptx?)/.test(s)) return "ppt";
if (/\.pdf/.test(s)) return "pdf";
if (/\.(png|jpe?g|gif|webp|svg|image)/.test(s)) return "image";
if (/\.(mp4|mov|avi|video)/.test(s)) return "video";
return "file";
}

View File

@@ -0,0 +1,14 @@
// STUB — see DMS/pages/_shared/utils.ts. DMS file-type icons, not migrated.
// Each renders nothing so document components resolve without the real assets.
import type { SVGProps } from "react";
const Empty = (_props: SVGProps<SVGSVGElement>) => null;
export const ODWinFolderSvg = Empty;
export const ODIconWord = Empty;
export const ODIconExcel = Empty;
export const ODIconPpt = Empty;
export const ODIconPdf = Empty;
export const ODIconImage = Empty;
export const ODIconVideo = Empty;
export const ODIconFile = Empty;

View File

@@ -0,0 +1,20 @@
// STUB — @/DMS is not migrated into this backoffice. These placeholders exist
// only so record-management's document components resolve; the DMS feature is
// inert here. Replace with the real @/DMS module if DMS is ever brought over.
export function formatBytes(bytes?: number | null): string {
if (!bytes || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i] ?? "B"}`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function resolveOwnerName(owner?: any): string {
return owner?.name ?? owner?.username ?? owner?.email ?? "";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function pickName(item?: any): string {
return item?.name ?? item?.title ?? item?.fileName ?? "";
}

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import Header from "@/layout/components/Header";
import Footer from "@/layout/components/Footer";
interface ComplaintPageLayoutProps {
children: ReactNode;
heroTitle: string;
heroSubtitle?: string;
}
export function ComplaintPageLayout({
children,
heroTitle,
heroSubtitle,
}: ComplaintPageLayoutProps) {
return (
<div>
<Header />
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex flex-col pt-16 md:pt-20">
<div className="bg-primary py-12 md:py-16 text-center text-white relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10" />
<h1 className="text-3xl md:text-5xl font-bold mb-3 relative z-10">
{heroTitle}
</h1>
{heroSubtitle && (
<p className="max-w-2xl mx-auto text-base md:text-lg opacity-90 relative z-10 px-4">
{heroSubtitle}
</p>
)}
</div>
<div className="flex-1 flex justify-center p-4 md:p-6">{children}</div>
</div>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { Navigate, Outlet } from "react-router-dom";
import { getComplaintVerification } from "../utils/complaintVerificationStorage";
export function ComplaintVerificationGuard() {
const session = getComplaintVerification();
if (!session) {
return <Navigate to="/complaints" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,69 @@
import { useTranslation } from "react-i18next";
import { BadgeCheck, Building2, IdCard, User } from "lucide-react";
import {
PortalFieldLabel,
PortalReadOnlyField,
} from "@/external-portal/components/shared/PortalFormPrimitives";
import type { ComplaintVerificationSession } from "../types/complaint.types";
interface ComplaintVerifiedInfoPanelProps {
session: ComplaintVerificationSession;
}
export function ComplaintVerifiedInfoPanel({
session,
}: ComplaintVerifiedInfoPanelProps) {
const { t } = useTranslation();
const isTinComplaint = session.method === "tin";
return (
<section className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50/80 p-5 md:p-6">
<h3 className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-emerald-800">
<BadgeCheck className="h-4 w-4" />
{isTinComplaint
? t("complaint.tin.verifiedInfo")
: t("complaint.fayda.verifiedInfo")}
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{isTinComplaint ? (
<>
<PortalReadOnlyField
label={t("complaint.tin.organizationName")}
value={session.organization?.organizationName}
icon={Building2}
/>
<PortalReadOnlyField
label={t("complaint.tin.tinLabel")}
value={session.organization?.tin}
icon={IdCard}
mono
/>
{session.organization?.tradeName ? (
<PortalReadOnlyField
label={t("complaint.tin.tradeName")}
value={session.organization.tradeName}
icon={Building2}
/>
) : null}
{session.organization?.licenseNumber ? (
<PortalReadOnlyField
label={t("complaint.tin.licenseNumber")}
value={session.organization.licenseNumber}
icon={IdCard}
mono
/>
) : null}
</>
) : (
<>
<PortalReadOnlyField
label={t("complaint.fullName")}
value={session.citizen?.fullName}
icon={User}
/>
</>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { useAuth } from "@/shared/context/AuthContext";
import {
isComplaintFaydaState,
} from "@/shared/utils/faydaOidc";
import {
mapRegisterWithFaydaCitizen,
persistFaydaRegistrationAuth,
} from "@/shared/utils/faydaAuthSession";
import {
FaydaOidcError,
verifyComplaintUser,
} from "../services/complaintVerificationService";
import {
clearComplaintVerification,
storeComplaintVerification,
} from "../utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes";
export default function ComplaintCallbackPage() {
const { t, i18n } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setUser, setSelectedPositionId } = useAuth();
const [error, setError] = useState<string | null>(null);
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
const code = searchParams.get("code");
const state = searchParams.get("state");
const oidcError = searchParams.get("error");
const oidcErrorDescription = searchParams.get("error_description");
if (oidcError) {
clearComplaintVerification();
const message =
oidcErrorDescription ||
t("complaint.fayda.verificationFailed");
setError(message);
return;
}
if (!code) {
clearComplaintVerification();
setError(t("complaint.fayda.missingCode"));
return;
}
if (state && !isComplaintFaydaState(state)) {
clearComplaintVerification();
setError(t("complaint.fayda.verificationFailed"));
return;
}
const processCallback = async () => {
try {
const result = await verifyComplaintUser(
{ code, state },
i18n.language,
);
if (!result.verified || !result.registration) {
throw new Error(t("complaint.fayda.verificationFailed"));
}
const profile = await persistFaydaRegistrationAuth(result.registration);
if (profile) {
setUser(profile);
const firstPositionId =
profile.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) {
setSelectedPositionId(firstPositionId);
}
}
const citizen = mapRegisterWithFaydaCitizen(
result.registration,
i18n.language,
profile,
);
if (!citizen.fullName && !profile) {
throw new FaydaOidcError("FAYDA_PROFILE_INCOMPLETE");
}
storeComplaintVerification({
verified: true,
method: "fayda",
citizen,
verifiedAt: result.verifiedAt,
});
navigate(COMPLAINT_RECORDS_PATH, {
replace: true,
state: { fromComplaintVerification: true },
});
} catch (err: unknown) {
clearComplaintVerification();
if (err instanceof FaydaOidcError) {
const translationKey = `complaint.fayda.errors.${err.code}`;
const translated = t(translationKey);
setError(
translated !== translationKey
? translated
: t("complaint.fayda.verificationFailed"),
);
return;
}
const message =
err instanceof Error
? err.message
: t("complaint.fayda.verificationFailed");
setError(message);
}
};
processCallback();
}, [
searchParams,
navigate,
t,
i18n.language,
setUser,
setSelectedPositionId,
]);
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-red-50 px-4">
<p className="text-red-600 mb-4 text-center max-w-md">{error}</p>
<Button
onClick={() =>
navigate(
`/complaints?error=${encodeURIComponent(error)}`,
{ replace: true },
)
}>
{t("complaint.fayda.backToComplaints")}
</Button>
</div>
);
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-white">
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
<p className="text-primary text-lg font-medium text-center px-4">
{t("complaint.fayda.verifying")}
</p>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Navigate } from "react-router-dom";
import { getComplaintVerification } from "../utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes";
export default function ComplaintFormPage() {
const session = getComplaintVerification();
if (!session) {
return <Navigate to="/complaints" replace />;
}
return <Navigate to={COMPLAINT_RECORDS_PATH} replace />;
}

View File

@@ -0,0 +1,94 @@
import { useEffect } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertCircle, Building2, Search, UserRound } from "lucide-react";
import { toast } from "sonner";
import { startComplaintFaydaAuth } from "@/shared/utils/faydaOidc";
import { ComplaintPageLayout } from "../components/ComplaintPageLayout";
import { clearComplaintVerification } from "../utils/complaintVerificationStorage";
export default function ComplaintMethodChoicePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const error = searchParams.get("error");
useEffect(() => {
clearComplaintVerification();
}, []);
useEffect(() => {
if (error) {
toast.error(error);
setSearchParams({}, { replace: true });
}
}, [error, setSearchParams]);
const authOptions = [
{
provider: "fayda" as const,
title: t("registration.auth.continueWithFayda"),
description: t("registration.auth.faydaDescription"),
icon: UserRound,
onClick: () => startComplaintFaydaAuth(),
},
{
provider: "etrade" as const,
title: t("registration.auth.continueWithEtrade"),
description: t("registration.auth.etradeDescription"),
icon: Building2,
onClick: () => navigate("/complaints/tin"),
},
];
return (
<ComplaintPageLayout
heroTitle={t("complaint.choice.title")}
heroSubtitle={t("complaint.choice.subtitle")}>
<div className="w-full max-w-3xl space-y-8">
{error && (
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<p className="text-center text-sm text-gray-600">
{t("complaint.choice.prompt")}
</p>
<div className="grid gap-6 md:grid-cols-2">
{authOptions.map((option) => {
const Icon = option.icon;
return (
<button
key={option.provider}
type="button"
onClick={option.onClick}
className="group rounded-2xl border border-gray-200 bg-white p-6 text-left transition hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary transition group-hover:bg-primary group-hover:text-white">
<Icon className="h-6 w-6" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
{option.title}
</h3>
<p className="mt-2 text-sm leading-6 text-gray-600">
{option.description}
</p>
</button>
);
})}
</div>
<div className="border-t border-gray-100 pt-6 text-center">
<Link
to="/follow-complaint"
className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline">
<Search className="h-4 w-4" />
{t("complaint.followTitle")}
</Link>
</div>
</div>
</ComplaintPageLayout>
);
}

View File

@@ -0,0 +1,44 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { CheckCircle, Search } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { ComplaintPageLayout } from "../components/ComplaintPageLayout";
import { clearComplaintVerification } from "../utils/complaintVerificationStorage";
export default function ComplaintSuccessPage() {
const { t } = useTranslation();
const handleDone = () => {
clearComplaintVerification();
};
return (
<ComplaintPageLayout
heroTitle={t("complaint.success")}
heroSubtitle={t("complaint.fayda.successSubtitle")}>
<div className="w-full max-w-lg bg-white shadow-2xl rounded-2xl p-8 border border-gray-100 text-center">
<CheckCircle className="mx-auto mb-4 h-16 w-16 text-primary" />
<h2 className="mb-2 text-xl font-semibold text-gray-900">
{t("complaint.fayda.successMessage")}
</h2>
<p className="mb-6 text-gray-600">
{t("complaint.fayda.successNote")}
</p>
<div className="flex flex-col gap-3">
<Button asChild onClick={handleDone}>
<Link to="/">{t("complaint.fayda.backToHome")}</Link>
</Button>
<Button asChild variant="outline" onClick={handleDone}>
<Link
to="/follow-complaint"
className="inline-flex items-center gap-2">
<Search className="h-4 w-4" />
{t("complaint.followTitle")}
</Link>
</Button>
</div>
</div>
</ComplaintPageLayout>
);
}

View File

@@ -0,0 +1,308 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertCircle, ArrowLeft, Building2, Loader2 } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useAuth } from "@/shared/context/AuthContext";
import { registerWithEtrade } from "@/shared/services/authService";
import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession";
import { ComplaintPageLayout } from "../components/ComplaintPageLayout";
import { TinRegistrationNotFoundError } from "../services/complaintVerificationService";
import { storeComplaintVerification } from "../utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes";
import type { VerifiedOrganization } from "../types/complaint.types";
import {
EtradeBusinessLicenseOption,
fetchRegistrationByTin,
mapEtradeBusinessLicenses,
mapEtradeRegistration,
resolveEtradeLanguage,
resolveEtradePhoneForSignup,
} from "../services/etradeTinService";
import { EtradeLicensePicker } from "@/shared/components/etrade/EtradeLicensePicker";
type VerificationStep = "tin" | "select_license";
export default function ComplaintTinVerificationPage() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { setUser, setSelectedPositionId } = useAuth();
const { handleError, getErrorMessage } = useErrorHandler(t);
const [step, setStep] = useState<VerificationStep>("tin");
const [tin, setTin] = useState("");
const [organization, setOrganization] = useState<VerifiedOrganization | null>(
null,
);
const [organizationName, setOrganizationName] = useState("");
const [licenseOptions, setLicenseOptions] = useState<
EtradeBusinessLicenseOption[]
>([]);
const [selectedLicenseNumber, setSelectedLicenseNumber] = useState("");
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [isContinuing, setIsContinuing] = useState(false);
const handleVerify = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
const normalizedTin = tin.trim();
if (!normalizedTin) {
setError(t("complaint.tin.tinRequired"));
return;
}
if (!/^\d{10}$/.test(normalizedTin)) {
setError(t("complaint.tin.tinInvalid"));
return;
}
try {
setIsVerifying(true);
const registration = await fetchRegistrationByTin(
normalizedTin,
resolveEtradeLanguage(i18n.language),
);
const verifiedOrganization = mapEtradeRegistration(
registration,
normalizedTin,
);
if (!verifiedOrganization.organizationName && !verifiedOrganization.tin) {
setError(t("complaint.tin.notFound"));
return;
}
const businesses = mapEtradeBusinessLicenses(
registration,
resolveEtradeLanguage(i18n.language),
);
if (businesses.length === 0) {
setError(t("registration.etrade.noLicensesFound"));
return;
}
setOrganization(verifiedOrganization);
setOrganizationName(verifiedOrganization.organizationName);
setLicenseOptions(businesses);
setSelectedLicenseNumber(businesses[0].licenseNumber);
setStep("select_license");
} catch (verifyError) {
if (verifyError instanceof TinRegistrationNotFoundError) {
setError(t("complaint.tin.notFound"));
return;
}
if (
verifyError instanceof Error &&
verifyError.message === "ETRADE_REFERER_REJECTED"
) {
setError(t("complaint.tin.proxyError"));
return;
}
setError(await getErrorMessage(verifyError));
} finally {
setIsVerifying(false);
}
};
const handleLicenseContinue = async () => {
if (!organization || !selectedLicenseNumber) return;
const selectedLicense = licenseOptions.find(
(option) => option.licenseNumber === selectedLicenseNumber,
);
if (!selectedLicense) return;
try {
setIsContinuing(true);
setError(null);
let phoneNumber: string | undefined;
try {
phoneNumber =
(await resolveEtradePhoneForSignup(
selectedLicense.licenseNumber,
organization.tin,
resolveEtradeLanguage(i18n.language),
)) ?? undefined;
} catch (phoneError) {
console.warn(
"[complaint:eTrade] Failed to resolve phone from GetBusinessByLicenseNo",
phoneError,
);
}
if (!phoneNumber) {
setError(t("registration.etrade.phoneLookupFailed"));
return;
}
const registrationResponse = await registerWithEtrade({
tin: organization.tin,
licenseNumber: selectedLicense.licenseNumber,
phoneNumber,
});
const registration = registrationResponse.data;
if (!registration?.token?.trim()) {
setError(t("complaint.tin.registrationFailed"));
return;
}
const profile = await persistFaydaRegistrationAuth(registration);
if (profile) {
setUser(profile);
const firstPositionId =
profile.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) {
setSelectedPositionId(firstPositionId);
}
}
storeComplaintVerification({
verified: true,
method: "tin",
organization: {
...organization,
licenseNumber: selectedLicense.licenseNumber,
tradeName: selectedLicense.tradeName,
mainGuid: selectedLicense.mainGuid,
},
verifiedAt: new Date().toISOString(),
registration,
});
navigate(COMPLAINT_RECORDS_PATH, {
replace: true,
state: { fromComplaintVerification: true },
});
} catch (continueError) {
handleError(continueError);
} finally {
setIsContinuing(false);
}
};
return (
<ComplaintPageLayout
heroTitle={t("registration.auth.continueWithEtrade")}
heroSubtitle={t("complaint.tin.subtitle")}>
<div className="w-full max-w-xl rounded-2xl border border-gray-200 bg-white p-6 md:p-8">
<Link
to="/complaints"
className="mb-6 inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-primary">
<ArrowLeft className="h-4 w-4" />
{t("complaint.back")}
</Link>
<div className="mb-6 flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-amber-50 text-amber-600">
<Building2 className="h-6 w-6" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">
{step === "tin"
? t("complaint.tin.formTitle")
: t("registration.etrade.selectLicenseTitle")}
</h2>
<p className="mt-1 text-sm text-gray-600">
{step === "tin"
? t("complaint.tin.formDescription")
: t("registration.etrade.selectLicenseDescription")}
</p>
</div>
</div>
{error && (
<div className="mb-6 flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
<p className="text-sm text-red-700">{error}</p>
</div>
)}
{step === "tin" ? (
<form onSubmit={handleVerify} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="tin">{t("complaint.tin.tinLabel")}</Label>
<Input
id="tin"
inputMode="numeric"
pattern="\d*"
maxLength={10}
value={tin}
onChange={(event) =>
setTin(event.target.value.replace(/\D/g, "").slice(0, 10))
}
placeholder={t("complaint.tin.tinPlaceholder")}
className="font-mono text-lg tracking-wide"
autoComplete="off"
disabled={isVerifying}
required
/>
<p className="text-xs text-gray-500">
{t("complaint.tin.tinHint")}
</p>
</div>
<Button
type="submit"
disabled={isVerifying || tin.trim().length !== 10}
className="w-full py-6 text-base font-semibold">
{isVerifying ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
{t("complaint.tin.verifying")}
</span>
) : (
t("complaint.tin.verify")
)}
</Button>
</form>
) : (
<div className="space-y-6">
<EtradeLicensePicker
organizationName={organizationName}
options={licenseOptions}
selectedLicenseNumber={selectedLicenseNumber}
onSelect={setSelectedLicenseNumber}
/>
<div className="flex justify-center gap-4">
<Button
type="button"
variant="outline"
onClick={() => {
setStep("tin");
setSelectedLicenseNumber("");
setLicenseOptions([]);
setOrganization(null);
setOrganizationName("");
}}
disabled={isContinuing}>
{t("registration.etrade.back")}
</Button>
<Button
type="button"
onClick={handleLicenseContinue}
disabled={!selectedLicenseNumber || isContinuing}
className="min-w-[8rem]">
{isContinuing ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("registration.etrade.loading")}
</span>
) : (
t("registration.etrade.continue")
)}
</Button>
</div>
</div>
)}
</div>
</ComplaintPageLayout>
);
}

View File

@@ -0,0 +1,72 @@
import type {
ComplaintVerificationResult,
FaydaCallbackData,
} from "../types/complaint.types";
import {
FaydaOidcError,
registerFaydaCitizenFromCode,
} from "./faydaOidcService";
import {
fetchRegistrationByTin,
mapEtradeRegistration,
resolveEtradeLanguage,
TinRegistrationNotFoundError,
} from "./etradeTinService";
/**
* Authenticates or registers a citizen via FAYDA OIDC using a single flow.
*/
export async function verifyComplaintUser(
callbackData: FaydaCallbackData,
language = "en",
): Promise<ComplaintVerificationResult> {
if (!callbackData.code?.trim()) {
throw new Error("Missing authorization code from FAYDA");
}
const { registration } = await registerFaydaCitizenFromCode(
callbackData.code,
language,
);
return {
verified: true,
method: "fayda",
verifiedAt: new Date().toISOString(),
registration,
};
}
/**
* Verifies an organization via the eTrade TIN registration API.
*/
export async function verifyComplaintTin(
tin: string,
language: string,
): Promise<ComplaintVerificationResult> {
const normalizedTin = tin.trim();
if (!/^\d{10}$/.test(normalizedTin)) {
throw new Error("INVALID_TIN");
}
const registration = await fetchRegistrationByTin(
normalizedTin,
resolveEtradeLanguage(language),
);
const organization = mapEtradeRegistration(registration, normalizedTin);
if (!organization.organizationName && !organization.tin) {
throw new TinRegistrationNotFoundError();
}
return {
verified: true,
method: "tin",
organization,
verifiedAt: new Date().toISOString(),
};
}
export { TinRegistrationNotFoundError };
export { FaydaOidcError };

View File

@@ -0,0 +1,365 @@
export type EtradeLanguage = "en" | "am";
export interface EtradeBusinessSubGroup {
Code?: number;
Description?: string;
}
export interface EtradeBusiness {
MainGuid?: string;
OwnerTIN?: string;
DateRegistered?: string;
TradeNameAmh?: string;
TradesName?: string;
LicenceNumber?: string;
LicenseNumber?: string;
RenewalDate?: string;
RenewedFrom?: string;
RenewedTo?: string;
SubGroups?: EtradeBusinessSubGroup[];
}
export interface EtradeBusinessLicenseOption {
mainGuid: string;
licenseNumber: string;
tradeName: string;
activities: string[];
renewedTo?: string;
}
export interface EtradeRegistrationInfo {
Tin?: string;
BusinessName?: string;
BusinessNameAmh?: string;
RegNo?: string;
Businesses?: EtradeBusiness[];
tin?: string;
businessName?: string;
businessNameAmh?: string;
regNo?: string;
businesses?: EtradeBusiness[];
[key: string]: unknown;
}
export class TinRegistrationNotFoundError extends Error {
readonly code = "TIN_NOT_FOUND" as const;
constructor() {
super("TIN_NOT_FOUND");
this.name = "TinRegistrationNotFoundError";
}
}
const ETRADE_API_BASE =
import.meta.env.VITE_ETRADE_API_BASE?.trim() || "/api/etrade";
export function resolveEtradeLanguage(language: string): EtradeLanguage {
return language.toLowerCase().startsWith("am") ? "am" : "en";
}
function hasRegistrationData(data: unknown): data is EtradeRegistrationInfo {
if (data == null) return false;
if (typeof data !== "object") return false;
if (Array.isArray(data)) return data.length > 0;
const record = data as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length === 0) return false;
const tin = String(record.Tin ?? record.tin ?? "").trim();
const businessName = String(
record.BusinessName ?? record.businessName ?? "",
).trim();
return Boolean(tin || businessName);
}
function normalizeEtradeBusinesses(
data: EtradeRegistrationInfo,
): EtradeBusiness[] {
const businesses = data.Businesses ?? data.businesses;
return Array.isArray(businesses) ? businesses : [];
}
export function mapEtradeBusinessLicenses(
data: EtradeRegistrationInfo,
language: EtradeLanguage,
): EtradeBusinessLicenseOption[] {
const companyName = String(
data.BusinessName ?? data.businessName ?? "",
).trim();
const companyNameAmh = String(
data.BusinessNameAmh ?? data.businessNameAmh ?? "",
).trim();
return normalizeEtradeBusinesses(data).flatMap((business) => {
const licenseNumber = String(
business.LicenceNumber ?? business.LicenseNumber ?? "",
).trim();
if (!licenseNumber) return [];
const tradeName = String(business.TradesName ?? "").trim();
const tradeNameAmh = String(business.TradeNameAmh ?? "").trim();
const activities = (business.SubGroups ?? [])
.map((group) => String(group.Description ?? "").trim())
.filter(Boolean);
const displayTradeName =
language === "am"
? tradeNameAmh || tradeName || companyNameAmh || companyName
: tradeName || tradeNameAmh || companyName || companyNameAmh;
const option: EtradeBusinessLicenseOption = {
mainGuid: String(business.MainGuid ?? licenseNumber).trim(),
licenseNumber,
tradeName: displayTradeName || licenseNumber,
activities,
};
const renewedTo = String(business.RenewedTo ?? "").trim();
if (renewedTo) {
option.renewedTo = renewedTo;
}
return [option];
});
}
export function mapEtradeRegistration(
data: EtradeRegistrationInfo,
fallbackTin: string,
) {
const tin = String(data.Tin ?? data.tin ?? fallbackTin).trim();
const organizationName = String(
data.BusinessName ?? data.businessName ?? "",
).trim();
const regNo = String(data.RegNo ?? data.regNo ?? "").trim();
return {
organizationName: organizationName || tin,
tin,
...(regNo ? { regNo } : {}),
};
}
export interface EtradeBusinessByLicense {
MainGuid?: string;
OwnerTIN?: string;
TradeName?: string;
LicenceNumber?: string;
LicenseNumber?: string;
AssociateShortInfos?: Array<{
Position?: string;
ManagerName?: string;
ManagerNameEng?: string;
MobilePhone?: string;
RegularPhone?: string;
}>;
AddressInfo?: {
Region?: string;
Zone?: string;
Woreda?: string;
Kebele?: string;
HouseNo?: string;
MobilePhone?: string;
RegularPhone?: string;
};
[key: string]: unknown;
}
/**
* Normalize Ethiopian phone numbers for IAM auth.
* Accepts 09/07 mobiles and common 0X landline forms; returns +251...
*/
export function normalizeEtradePhoneNumber(
raw: string | null | undefined,
): string | null {
if (!raw?.trim()) return null;
let digits = raw.trim().replace(/[^\d+]/g, "");
if (digits.startsWith("+")) {
digits = digits.slice(1);
}
// Already international without +
if (digits.startsWith("251") && digits.length >= 12) {
return `+${digits}`;
}
// Local with leading 0 (mobile or landline): 09xxxxxxxx / 07xxxxxxxx / 0Xxxxxxxx
if (digits.startsWith("0") && digits.length >= 9) {
return `+251${digits.slice(1)}`;
}
// Mobile without leading 0: 9xxxxxxxx / 7xxxxxxxx
if (/^[97]\d{8}$/.test(digits)) {
return `+251${digits}`;
}
// Bare landline-ish digits (e.g. 52543000) — prefix country code only if 89 digits
if (/^\d{8,9}$/.test(digits)) {
return `+251${digits}`;
}
return null;
}
function pickRawPhoneFromBusiness(data: EtradeBusinessByLicense): string | null {
const associates = Array.isArray(data.AssociateShortInfos)
? data.AssociateShortInfos
: [];
// Prefer RegularPhone — eTrade MobilePhone is often a landline/invalid value
// (e.g. "52543000", "222400000") while RegularPhone holds the mobile (09...).
for (const associate of associates) {
const regular = String(associate.RegularPhone ?? "").trim();
if (regular) return regular;
}
const addressRegular = String(data.AddressInfo?.RegularPhone ?? "").trim();
if (addressRegular) return addressRegular;
for (const associate of associates) {
const mobile = String(associate.MobilePhone ?? "").trim();
if (mobile) return mobile;
}
const addressMobile = String(data.AddressInfo?.MobilePhone ?? "").trim();
if (addressMobile) return addressMobile;
return null;
}
export function extractEtradePhoneNumber(
data: EtradeBusinessByLicense,
): string | null {
return normalizeEtradePhoneNumber(pickRawPhoneFromBusiness(data));
}
export async function fetchBusinessByLicenseNo(
licenseNo: string,
tin: string,
language: EtradeLanguage,
): Promise<EtradeBusinessByLicense> {
const params = new URLSearchParams({
LicenseNo: licenseNo.trim(),
Tin: tin.trim(),
Lang: language,
});
const url = `${ETRADE_API_BASE}/BusinessMain/GetBusinessByLicenseNo?${params.toString()}`;
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
},
});
} catch (error) {
console.error("[eTrade:License] Network request failed", error);
throw error;
}
const responseText = (await response.text()).trim();
if (response.status === 417) {
console.error(
"[eTrade:License] Referer rejected by eTrade. Ensure /api/etrade proxy is configured.",
responseText,
);
throw new Error("ETRADE_REFERER_REJECTED");
}
if (!response.ok && response.status !== 404) {
throw new Error(`ETRADE_HTTP_${response.status}`);
}
if (!responseText) {
throw new Error("ETRADE_BUSINESS_NOT_FOUND");
}
let data: unknown;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error(
"[eTrade:License] Invalid JSON response",
parseError,
responseText,
);
throw new Error("INVALID_ETRADE_RESPONSE");
}
if (data == null || typeof data !== "object") {
throw new Error("ETRADE_BUSINESS_NOT_FOUND");
}
return data as EtradeBusinessByLicense;
}
/**
* Looks up business details by license + TIN and returns a normalized phone
* suitable for IAM signup (`+251...`).
*/
export async function resolveEtradePhoneForSignup(
licenseNo: string,
tin: string,
language: EtradeLanguage = "en",
): Promise<string | null> {
const business = await fetchBusinessByLicenseNo(licenseNo, tin, language);
return extractEtradePhoneNumber(business);
}
export async function fetchRegistrationByTin(
tin: string,
language: EtradeLanguage,
): Promise<EtradeRegistrationInfo> {
const normalizedTin = tin.trim();
const url = `${ETRADE_API_BASE}/Registration/GetRegistrationInfoByTin/${encodeURIComponent(normalizedTin)}/${language}`;
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
},
});
} catch (error) {
console.error("[eTrade:TIN] Network request failed", error);
throw error;
}
const responseText = (await response.text()).trim();
if (response.status === 417) {
console.error(
"[eTrade:TIN] Referer rejected by eTrade. Ensure /api/etrade proxy is configured.",
responseText,
);
throw new Error("ETRADE_REFERER_REJECTED");
}
if (!response.ok && response.status !== 404) {
throw new Error(`ETRADE_HTTP_${response.status}`);
}
if (!responseText) {
throw new TinRegistrationNotFoundError();
}
let data: unknown;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error("[eTrade:TIN] Invalid JSON response", parseError, responseText);
throw new Error("INVALID_ETRADE_RESPONSE");
}
if (data == null || !hasRegistrationData(data)) {
throw new TinRegistrationNotFoundError();
}
return data;
}

View File

@@ -0,0 +1,75 @@
import axios from "axios";
import {
registerWithFayda,
type RegisterWithFaydaResponse,
} from "@/shared/services/authService";
import { getComplaintFaydaRedirectUri } from "@/shared/utils/faydaOidc";
import type { VerifiedCitizen } from "../types/complaint.types";
export class FaydaOidcError extends Error {
constructor(
readonly code: string,
message?: string,
) {
super(message ?? code);
this.name = "FaydaOidcError";
}
}
export interface FaydaRegistrationResult {
registration: RegisterWithFaydaResponse;
citizen?: VerifiedCitizen;
}
function mapRegisterWithFaydaError(error: unknown): never {
if (axios.isAxiosError(error)) {
const payload = error.response?.data;
const serverCode =
payload && typeof payload === "object" && "code" in payload
? String((payload as { code?: string }).code ?? "")
: "";
if (serverCode.startsWith("FAYDA_")) {
throw new FaydaOidcError(serverCode);
}
if (!error.response) {
throw new FaydaOidcError("FAYDA_TOKEN_NETWORK_ERROR");
}
const status = error.response.status;
if (status === 400) {
throw new FaydaOidcError("FAYDA_TOKEN_HTTP_400");
}
if (status === 401) {
throw new FaydaOidcError("FAYDA_TOKEN_HTTP_401");
}
if (status >= 500) {
throw new FaydaOidcError("FAYDA_TOKEN_HTTP_500");
}
}
if (error instanceof FaydaOidcError) {
throw error;
}
throw new FaydaOidcError("FAYDA_EXCHANGE_FAILED");
}
export async function registerFaydaCitizenFromCode(
code: string,
_preferredLocale = "en",
): Promise<FaydaRegistrationResult> {
try {
const response = await registerWithFayda({ code });
const registration = response.data;
if (!registration?.token?.trim()) {
throw new FaydaOidcError("FAYDA_EXCHANGE_FAILED");
}
return { registration };
} catch (error) {
mapRegisterWithFaydaError(error);
}
}

View File

@@ -0,0 +1,42 @@
export type ComplaintVerificationMethod = "fayda" | "tin";
export interface VerifiedCitizen {
fullName: string;
faydaId: string;
}
export interface VerifiedOrganization {
organizationName: string;
tin: string;
regNo?: string;
licenseNumber?: string;
tradeName?: string;
mainGuid?: string;
}
import type { RegisterWithFaydaResponse } from "@/shared/services/authService";
export interface ComplaintVerificationResult {
verified: boolean;
method: ComplaintVerificationMethod;
citizen?: VerifiedCitizen;
organization?: VerifiedOrganization;
verifiedAt: string;
registration?: RegisterWithFaydaResponse;
}
export interface ComplaintVerificationSession extends ComplaintVerificationResult {
verified: true;
method: ComplaintVerificationMethod;
}
export interface FaydaCallbackData {
code: string;
state?: string | null;
}
export interface ComplaintFormDraft {
recipient: string;
subject: string;
description: string;
}

View File

@@ -0,0 +1,4 @@
export const COMPLAINT_SUBMIT_PATH =
"/external-portal/portal-outgoing/submit-letter";
export const COMPLAINT_RECORDS_PATH = "/external-portal/portal-outgoing";

View File

@@ -0,0 +1,109 @@
import type { ComplaintVerificationSession } from "../types/complaint.types";
const STORAGE_KEY = "complaint-verification";
const COMPLAINT_VERIFICATION_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
export function storeComplaintVerification(
session: ComplaintVerificationSession,
): void {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
export function getComplaintVerification(): ComplaintVerificationSession | null {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const session = JSON.parse(raw) as ComplaintVerificationSession;
if (!session.verified) {
return null;
}
const method = session.method ?? "fayda";
if (method === "fayda" && !session.citizen) {
return null;
}
if (method === "tin" && !session.organization?.tin) {
return null;
}
return { ...session, method };
} catch {
clearComplaintVerification();
return null;
}
}
export function clearComplaintVerification(): void {
sessionStorage.removeItem(STORAGE_KEY);
}
export function hasComplaintVerification(): boolean {
return getComplaintVerification() !== null;
}
export function isComplaintAuthContext(pathname = ""): boolean {
return (
pathname.startsWith("/complaints") ||
pathname === "/complaint-form" ||
pathname === "/follow-complaint" ||
pathname === "/callback"
);
}
export function setupComplaintVerificationIdleCleanup(
timeoutMs = COMPLAINT_VERIFICATION_IDLE_TIMEOUT_MS,
): () => void {
let timeoutId: number | null = null;
const scheduleCleanup = () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
clearComplaintVerification();
}, timeoutMs);
};
const handleActivity = () => {
if (!sessionStorage.getItem(STORAGE_KEY)) {
if (timeoutId) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
return;
}
scheduleCleanup();
};
const events: (keyof WindowEventMap)[] = [
"mousemove",
"mousedown",
"keydown",
"scroll",
"touchstart",
"click",
"focus",
];
events.forEach((eventName) => {
window.addEventListener(eventName, handleActivity, { passive: true });
});
document.addEventListener("visibilitychange", handleActivity);
handleActivity();
return () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
timeoutId = null;
}
events.forEach((eventName) => {
window.removeEventListener(eventName, handleActivity);
});
document.removeEventListener("visibilitychange", handleActivity);
};
}

View File

@@ -0,0 +1,250 @@
import NotificationList from "@/record-management/components/NotificationList";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDown, User, Key, LogOut } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { useTranslation } from "react-i18next";
import { FiBell, FiChevronDown } from "react-icons/fi";
import { useNavigate } from "react-router-dom";
import { useNotifications } from "@/shared/hooks/useNotification";
import { cn } from "@/shared/common/ui/fileUploader/utils";
import { useUser } from "@/shared/context/UserContext";
import {
UI_LANGUAGE_OPTIONS,
getUiLanguageLabel,
getUiLanguageShortLabel,
resolveUiLanguage,
} from "@/shared/i18n/uiLanguages";
export const ExternalPortal = ({
mobileView = false,
onItemClick,
}: {
mobileView?: boolean;
onItemClick?: () => void;
}) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const userDetails = useUser();
const { logout } = useAuthUser();
const fullName = userDetails?.name?.en || t("header.user");
const splittedName = fullName.trim().split(" ");
const initials =
splittedName.length === 1
? splittedName[0][0]
: `${splittedName[0][0]}${splittedName[1][0]}`;
const currentLanguage = resolveUiLanguage(i18n.language);
const changeLanguage = (lng: string) => i18n.changeLanguage(lng);
const handleLogout = () => {
logout("/external-portal/signin");
};
const { unseenCount } = useNotifications({
take: 10,
skip: 0,
orderBy: "updatedAt:DESC",
});
const [openNotifications, setOpenNotifications] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setOpenNotifications(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Handle navigation with optional callback for mobile
const handleNavigation = (path: string) => {
navigate(path);
if (onItemClick) onItemClick();
};
return (
<>
<div
className={`flex items-center ${
mobileView
? "flex-col space-y-4 w-full"
: "space-x-1 md:space-x-3 ml-auto"
}`}>
{/* Notifications */}
<div
className={`relative ${mobileView ? "w-full" : ""}`}
ref={dropdownRef}>
<Button
variant="ghost"
size={mobileView ? "default" : "icon"}
className={cn(
"relative text-gray-500 hover:bg-primary-50 hover:text-primary-600",
mobileView
? "w-full justify-start px-4 py-3 text-base"
: "h-8 w-8 rounded-full md:h-9 md:w-9",
openNotifications &&
"bg-primary-50 text-primary-700 ring-1 ring-inset ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60",
)}
aria-label={t("header.notifications")}
onClick={() => setOpenNotifications((prev) => !prev)}>
<FiBell
className={cn(
"h-4 w-4 transition-colors md:h-5 md:w-5",
mobileView && "mr-3",
openNotifications &&
"fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300",
)}
/>
{mobileView && <span>{t("header.notifications")}</span>}
{unseenCount > 0 && (
<span
className={`absolute ${
mobileView ? "top-3 right-4" : "-top-1 -right-1"
} bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full`}>
{unseenCount}
</span>
)}
</Button>
{openNotifications && (
<div
className={
mobileView
? "fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl border bg-white shadow-2xl"
: "absolute right-0 z-50 mt-2 w-[24rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border bg-white shadow-2xl"
}>
<NotificationList />
</div>
)}
</div>
{/* Language Switcher */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
variant="ghost"
size={mobileView ? "default" : "sm"}
className={`${
mobileView
? "w-full justify-start px-4 py-3 text-base"
: "gap-1 md:gap-1.5 text-xs md:text-sm h-8 px-2 md:px-3"
} font-medium text-gray-700 hover:bg-primary-50 hover:text-primary-600`}
onClick={(e) => e.preventDefault()}>
<FiChevronDown
className={`${
mobileView ? "mr-3" : ""
} h-3 w-3 md:h-4 md:w-4 opacity-50`}
/>
<span>{getUiLanguageLabel(currentLanguage, t)}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={`w-40 bg-white shadow-xl rounded-lg p-1 z-50 ${
mobileView ? "ml-4" : ""
}`}
align={mobileView ? "start" : "end"}
sideOffset={5}>
{UI_LANGUAGE_OPTIONS.map((lang) => (
<DropdownMenu.Item
key={lang.value}
onSelect={() => {
changeLanguage(lang.value);
if (onItemClick) onItemClick();
}}
className={cn(
"flex items-center justify-between text-sm text-gray-700 hover:bg-primary-100 rounded-md px-3 py-2 cursor-pointer",
currentLanguage === lang.value && "bg-primary-50",
)}
>
<span>{getUiLanguageLabel(lang.value, t)}</span>
{currentLanguage === lang.value && (
<span className="text-xs">
{getUiLanguageShortLabel(lang.value, t)}
</span>
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/* User Menu */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
variant="ghost"
className={`flex items-center ${
mobileView
? "w-full justify-start px-4 py-3 text-base"
: "h-8 md:h-10 px-1 md:px-2"
} space-x-1 md:space-x-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900`}
aria-label={t("header.userMenu")}>
<div
className={`flex items-center justify-center ${
mobileView ? "w-10 h-10" : "w-7 h-7 md:w-8 md:h-8"
} rounded-full bg-gray-200`}>
<span
className={`${
mobileView ? "text-base" : "text-xs md:text-sm"
} font-medium`}>
{initials.toUpperCase()}
</span>
</div>
{!mobileView && (
<div className="hidden sm:flex flex-col items-start">
<span className="text-xs font-medium leading-none">
{userDetails?.name?.en}
</span>
<span className="text-[10px] text-gray-500 leading-none mt-1">
External Organization
</span>
</div>
)}
{mobileView && (
<div className="flex flex-col items-start ml-3">
<span className="text-sm font-medium leading-none">
{userDetails?.name?.en}
</span>
<span className="text-xs text-gray-500 leading-none mt-1">
External Organization
</span>
</div>
)}
<ChevronDown className="h-3 w-3 md:h-4 md:w-4 text-gray-500" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={`w-48 p-1 bg-white shadow-xl rounded-md ${
mobileView ? "ml-4" : ""
}`}
align={mobileView ? "start" : "end"}>
<DropdownMenu.Item
className="flex items-center px-3 py-2.5 text-sm text-gray-700 hover:bg-primary-100 rounded-md cursor-pointer"
onClick={() => handleNavigation("/profile")}>
<User className="mr-2.5 h-4 w-4 text-gray-500" />
<span>{t("header.viewProfile")}</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className="flex items-center px-3 py-2.5 text-sm text-gray-700 hover:bg-primary-100 rounded-md cursor-pointer"
onClick={() =>
handleNavigation("/record-management/change-password")
}>
<Key className="mr-2.5 h-4 w-4 text-gray-500" />
<span>{t("header.changePassword")}</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-200" />
<DropdownMenu.Item
className="flex items-center px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 rounded-md cursor-pointer"
onClick={handleLogout}>
<LogOut className="mr-2.5 h-4 w-4" />
<span>{t("header.signOut")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</>
);
};

View File

@@ -0,0 +1,49 @@
"use client";
import Header from "../../../layout/components/Header";
import React from "react";
import { AlertCircle, Mail, PhoneCall } from "lucide-react";
import { useTranslation } from "react-i18next";
interface VerificationPendingProps {
hasCompletedRegistration: boolean;
contactNumber?: string;
email?:string;
}
const VerificationPending: React.FC<VerificationPendingProps> = ({
hasCompletedRegistration,
contactNumber,
email,
}) => {
const { t } = useTranslation();
if (hasCompletedRegistration) {
return null; // ✅ Nothing to show if user has finished registration
}
return (
<div className="min-h-screen flex items-center justify-center bg-primary-50 px-6">
<Header />
<div className="max-w-lg w-full bg-white rounded-2xl shadow-lg p-8 border border-primary-200">
<div className="flex items-center gap-3 mb-4">
<AlertCircle className="text-primary-600 w-6 h-6" />
<h1 className="text-xl font-semibold text-primary-700">
{t("verification.pending")}
</h1>
</div>
<p className="text-gray-700 mb-4">
{t("verification.message")}{" "}
<span className="font-medium text-primary-700">
<Mail/>{email} or <PhoneCall/>{contactNumber} </span>.
</p>
</div>
</div>
);
};
export default VerificationPending;

View File

@@ -0,0 +1,5 @@
import UserTypeSelection from "./UserTypeSelection";
export default function ExternalAuthPage() {
return <UserTypeSelection />;
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useRegisterExternalPortalUser } from "@/external-portal/hooks/useRegisterExternalPortalUser";
import { Button } from "@/shared/common/ui/button";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { getDefaultFaydaRedirectUri } from "@/shared/utils/faydaOidc";
import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
export default function ExternalPortalCallback() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { handleError } = useErrorHandler(t);
const { isFayidaRegistering, registerExternalFayidaUser } =
useRegisterExternalPortalUser();
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code) {
toast.error("Missing authorization code");
setError("Missing authorization code");
setLoading(false);
navigate("/external-portal/signin", { replace: true });
return;
}
const loginWithFayda = async () => {
try {
const res = await registerExternalFayidaUser({
code,
redirectUri: getDefaultFaydaRedirectUri(),
});
if (!res || !res.response?.data) {
throw new Error("Invalid response from Fayda login");
}
const data = res.response.data;
const userId = data?.userId ?? data?.user?.id ?? data?.user?.userId;
const token = data?.token;
const refreshToken = data?.refreshToken;
if (!userId) {
throw new Error("Failed to get user ID from server.");
}
await persistFaydaRegistrationAuth({
...data,
token,
refreshToken,
});
navigate(`/verify-otp?userId=${userId}&isExternalOrg=false`, {
replace: true,
});
} catch (err: unknown) {
console.error("LoginWithFayda error:", err);
handleError(err);
setError(
err instanceof Error
? err.message
: t("registration.auth.faydaSigninFailed"),
);
} finally {
setLoading(false);
}
};
loginWithFayda();
}, [searchParams, navigate, handleError, registerExternalFayidaUser, t]);
if (loading || isFayidaRegistering) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-white">
<Loader2 className="h-10 w-10 text-primary-600 animate-spin mb-4" />
<p className="text-primary-700 text-lg font-medium">
{t("registration.auth.faydaProcessing")}
</p>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-red-50">
<p className="text-red-600 mb-4">{error}</p>
<Button
onClick={() =>
navigate("/external-portal/signin", { replace: true })
}>
{t("registration.auth.backToAuth")}
</Button>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,249 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import {
FileText,
ImageIcon,
FileIcon,
VideoIcon,
DownloadIcon,
XIcon,
Loader2,
} from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { useToast } from "@/shared/common/ui/use-toast";
type FileType = "image" | "pdf" | "video" | "other";
interface FilePreviewProps {
file: File | string; // Can accept File object or URL string
type?: FileType; // Optional type hint
className?: string;
onRemove?: () => void;
showDownload?: boolean;
}
export const FilePreview = ({
file,
type,
className,
onRemove,
showDownload = true,
}: FilePreviewProps) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [detectedType, setDetectedType] = useState<FileType>("other");
const { toast } = useToast();
useEffect(() => {
const determineFileType = (): FileType => {
if (type) return type;
if (typeof file === "string") {
const extension = file.split(".").pop()?.toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension || "")) {
return "image";
}
if (extension === "pdf") return "pdf";
if (["mp4", "webm", "ogg"].includes(extension || "")) return "video";
return "other";
}
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("video/")) return "video";
return "other";
};
const generatePreview = async () => {
setIsLoading(true);
setDetectedType(determineFileType());
try {
if (typeof file === "string") {
setPreviewUrl(file);
} else {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
}
} catch (error) {
console.error("Error generating preview:", error);
toast({
title: "Error",
description: "Could not generate file preview",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
generatePreview();
return () => {
if (previewUrl && typeof file !== "string") {
URL.revokeObjectURL(previewUrl);
}
};
}, [file, type]);
const handleDownload = () => {
if (!previewUrl) return;
const link = document.createElement("a");
link.href = previewUrl;
link.download = typeof file === "string" ? "download" : file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const renderPreview = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
switch (detectedType) {
case "image":
return (
<img
src={previewUrl || ""}
alt="Preview"
className="object-contain w-full h-full"
onLoad={() => setIsLoading(false)}
/>
);
case "pdf":
return (
<div className="flex flex-col items-center justify-center h-full p-4">
<FileText className="h-16 w-16 text-red-500 dark:text-red-400" />
<span className="mt-2 text-sm font-medium truncate text-foreground">
{typeof file === "string" ? "PDF Document" : file.name}
</span>
</div>
);
case "video":
return (
<video
controls
className="w-full h-full"
onLoadedData={() => setIsLoading(false)}>
<source src={previewUrl || ""} type="video/mp4" />
Your browser does not support the video tag.
</video>
);
default:
return (
<div className="flex flex-col items-center justify-center h-full p-4">
<FileIcon className="h-16 w-16 text-gray-400 dark:text-gray-500" />
<span className="mt-2 text-sm font-medium truncate text-foreground">
{typeof file === "string" ? "File" : file.name}
</span>
</div>
);
}
};
return (
<>
<div
className={cn(
"relative border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-800 dark:border-gray-700 w-full h-40",
className
)}>
{renderPreview()}
<div className="absolute top-2 right-2 flex gap-2">
{onRemove && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-background/80 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}>
<XIcon className="h-4 w-4" />
</Button>
)}
{showDownload && previewUrl && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-background/80 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}>
<DownloadIcon className="h-4 w-4" />
</Button>
)}
</div>
<Button
variant="ghost"
className="absolute inset-0 w-full h-full opacity-0 hover:opacity-100 hover:bg-background/20 dark:hover:bg-background/40"
onClick={() => setIsDialogOpen(true)}>
<span className="sr-only">View fullscreen</span>
</Button>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-[90vw] max-h-[90vh]">
<DialogHeader>
<DialogTitle>
{typeof file === "string" ? "File Preview" : file.name}
</DialogTitle>
</DialogHeader>
<div className="relative h-[70vh]">
{detectedType === "image" && (
<img
src={previewUrl || ""}
alt="Fullscreen preview"
className="object-contain w-full h-full"
/>
)}
{detectedType === "pdf" && (
<iframe
src={previewUrl || ""}
className="w-full h-full"
title="PDF Preview"
/>
)}
{detectedType === "video" && (
<video controls autoPlay className="w-full h-full">
<source src={previewUrl || ""} type="video/mp4" />
</video>
)}
{detectedType === "other" && (
<div className="flex items-center justify-center h-full">
<FileIcon className="h-16 w-16 text-gray-400 dark:text-gray-500" />
</div>
)}
</div>
<div className="flex justify-end gap-2">
{showDownload && (
<Button onClick={handleDownload} variant="outline">
<DownloadIcon className="mr-2 h-4 w-4" />
Download
</Button>
)}
<Button onClick={() => setIsDialogOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,227 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import ReactCrop, {
centerCrop,
makeAspectCrop,
Crop,
PixelCrop,
convertToPixelCrop,
} from "react-image-crop";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { RotateCw, RotateCcw, Rotate3D } from "lucide-react";
import { canvasPreview } from "./canvasPreview";
import { useDebounceEffect } from "./useDebounceEffect";
import "react-image-crop/dist/ReactCrop.css";
interface ImageCropperProps {
file: File;
onCropComplete: (croppedFile: File) => void;
aspectRatio?: number;
}
export function ImageCropper({
file,
onCropComplete,
aspectRatio = 3 / 4,
}: ImageCropperProps) {
const [imgSrc, setImgSrc] = useState("");
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [scale, setScale] = useState(1);
const [rotate, setRotate] = useState(0);
const rotateClockwise = useCallback(() => {
setRotate((prev) => (prev + 90) % 360);
}, []);
const rotateCounterClockwise = useCallback(() => {
setRotate((prev) => (prev - 90) % 360);
}, []);
const rotateSmall = useCallback((degrees: number) => {
setRotate((prev) => (prev + degrees) % 360);
}, []);
React.useEffect(() => {
const reader = new FileReader();
reader.addEventListener("load", () => {
setImgSrc(reader.result?.toString() || "");
});
reader.readAsDataURL(file);
}, [file]);
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { width, height } = e.currentTarget;
setCrop(centerAspectCrop(width, height, aspectRatio));
}
function centerAspectCrop(
mediaWidth: number,
mediaHeight: number,
aspect: number
) {
return centerCrop(
makeAspectCrop(
{
unit: "%",
width: 90,
},
aspect,
mediaWidth,
mediaHeight
),
mediaWidth,
mediaHeight
);
}
async function handleCropComplete() {
const image = imgRef.current;
const previewCanvas = previewCanvasRef.current;
if (!image || !previewCanvas || !completedCrop) {
throw new Error("Crop canvas does not exist");
}
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const offscreen = new OffscreenCanvas(
completedCrop.width * scaleX,
completedCrop.height * scaleY
);
const ctx = offscreen.getContext("2d");
if (!ctx) {
throw new Error("No 2d context");
}
ctx.drawImage(
previewCanvas,
0,
0,
previewCanvas.width,
previewCanvas.height,
0,
0,
offscreen.width,
offscreen.height
);
const blob = await offscreen.convertToBlob({
type: file.type || "image/png",
});
const croppedFile = new File([blob], file.name, {
type: blob.type,
lastModified: Date.now(),
});
onCropComplete(croppedFile);
}
useDebounceEffect(
async () => {
if (
completedCrop?.width &&
completedCrop?.height &&
imgRef.current &&
previewCanvasRef.current
) {
canvasPreview(
imgRef.current,
previewCanvasRef.current,
completedCrop,
scale,
rotate
);
}
},
100,
[completedCrop, scale, rotate]
);
return (
<div className="flex flex-col gap-4">
{!!imgSrc && (
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(c) => setCompletedCrop(c)}
aspect={aspectRatio}
minHeight={100}>
<img
ref={imgRef}
alt="Crop me"
src={imgSrc}
style={{ transform: `rotate(${rotate}deg)` }}
onLoad={onImageLoad}
className="max-h-[400px] object-contain"
/>
</ReactCrop>
)}
<div className="flex items-center gap-2">
<Label htmlFor="rotate-input">Rotate:</Label>
<Button
variant="outline"
size="sm"
onClick={rotateCounterClockwise}
title="Rotate counter-clockwise">
<RotateCcw className="h-4 w-4" />
</Button>
<Input
id="rotate-input"
type="number"
value={rotate}
min="-180"
max="180"
onChange={(e) => setRotate(Number(e.target.value))}
className="w-16"
/>
<Button
variant="outline"
size="sm"
onClick={rotateClockwise}
title="Rotate clockwise">
<RotateCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => rotateSmall(15)}
title="Rotate 15° clockwise">
<Rotate3D className="h-4 w-4 mr-1" /> 15°
</Button>
<Button
variant="outline"
size="sm"
onClick={() => rotateSmall(-15)}
title="Rotate 15° counter-clockwise">
<Rotate3D className="h-4 w-4 mr-1 transform scale-x-[-1]" /> 15°
</Button>
</div>
<Button
onClick={handleCropComplete}
disabled={!completedCrop}
className="self-start">
Apply Crop
</Button>
<canvas
ref={previewCanvasRef}
style={{
display: "none",
border: "1px solid black",
objectFit: "contain",
width: completedCrop?.width,
height: completedCrop?.height,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,544 @@
// RegistrationStepper.tsx
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import { Mail, User, Phone, Text, Building } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Stepper } from "./Stepper";
import VerifyOtp from "../verifyOTP";
import UploadDocumentsStep from "./UploadDocumentsStep";
import { useRegisterExternalPortalUser } from "@/external-portal/hooks/useRegisterExternalPortalUser";
import { submitApplication } from "@/external-portal/services/portalOutgoingService";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
import { FilterEnum } from "@/shared/services/organizationsService";
import { DocumentReq } from "./UploadDocumentsStep";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
import { useDocumentUploads } from "@/external-portal/hooks/useDocumentUploads";
import { DocumentPayloadDto } from "@/shared/dto/External-Portal/External-PortalDto";
import { useDeleteExternalUser } from "@/super-admin/hooks/useExternalUsers";
import Cookies from "js-cookie";
type FilesRecord = Record<string, File | string | null>;
// registration schema
const registrationSchema = z.object({
email: z.string().email("Invalid email address"),
username: z.string().min(3, "Username must be at least 3 characters"),
phoneNumber: z.string().min(10, "Phone number must be at least 10 digits"),
name: z.object({
en: z.string().min(2, "English name must be at least 2 characters"),
am: z.string().min(2, "Amharic name must be at least 2 characters"),
}),
userType: z.enum(["individual", "external_organization"]),
});
type RegistrationFormValues = z.infer<typeof registrationSchema>;
const RegistrationStepper: React.FC = () => {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<RegistrationFormValues | null>(null);
const [uploadedFiles, setUploadedFiles] = useState<FilesRecord>({});
const [submitting, setSubmitting] = useState(false);
const [step1Completed, setStep1Completed] = useState(false);
const { t } = useTranslation();
const { registerExternalPortalUser, isRegistering } =
useRegisterExternalPortalUser();
const { handleError } = useErrorHandler(t);
const { uploadDocuments } = useDocumentUploads();
const form = useForm<RegistrationFormValues>({
resolver: zodResolver(registrationSchema),
defaultValues: {
email: "",
username: "",
phoneNumber: "",
userType: "external_organization",
name: { en: "", am: "" },
},
});
const userType = form.watch("userType");
const mappedType =
userType === "external_organization" ? "organization" : userType;
const filterData: FilterEnum | undefined =
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
? (mappedType as FilterEnum)
: undefined;
// fetch requirement docs to know which are required
const { requirementDoc } = useDocumentRequirement(filterData);
const requiredDocs = useMemo(
() =>
requirementDoc?.items?.filter((d: DocumentReq) => !d.isOptional) ?? [],
[requirementDoc]
);
const attachedCount = useMemo(
() => Object.values(uploadedFiles).filter((f) => f !== null).length,
[uploadedFiles]
);
//validation
const isStep1Complete = useMemo(() => {
if (!form.formState.isValid) return false;
// Check if all required documents are uploaded
const hasAllRequiredDocs = requiredDocs.every(
(doc) => uploadedFiles[doc.id] instanceof File
);
return hasAllRequiredDocs && form.formState.isValid;
}, [form.formState.isValid, requiredDocs, uploadedFiles]);
useEffect(() => {
setStep1Completed(isStep1Complete);
}, [isStep1Complete]);
useEffect(() => {
setStep1Completed(isStep1Complete);
}, [isStep1Complete]);
const resetRegistrationFlow = () => {
setCurrentStep(0);
setFormData(null);
setUploadedFiles({});
setSubmitting(false);
setStep1Completed(false);
form.reset({
email: "",
username: "",
phoneNumber: "",
userType: "external_organization",
name: { en: "", am: "" },
});
};
useEffect(() => {
resetRegistrationFlow();
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
resetRegistrationFlow();
}
};
window.addEventListener("pageshow", handlePageShow);
return () => window.removeEventListener("pageshow", handlePageShow);
}, []);
const onRegistrationSubmit = async (data: RegistrationFormValues) => {
// 1⃣ Validate documents
const missingDocs: string[] = requiredDocs
.filter((doc) => !uploadedFiles[doc.id])
.map((doc) => `${doc.title?.en || "Document"} is required.`);
// 2⃣ Collect form errors from react-hook-form
const formErrorMessages = Object.values(form.formState.errors)
.map((err: any) => err?.message)
.filter(Boolean);
// 3⃣ Combine all errors
const allErrors = [...formErrorMessages, ...missingDocs];
// 4⃣ Stop submission and show toast if errors exist
if (allErrors.length > 0) {
allErrors.forEach((msg) => toast.error(msg));
return;
}
setSubmitting(true);
try {
// 5⃣ Create external portal user
const response = await registerExternalPortalUser(data);
if (!response?.success) {
form.setError("root", {
type: "manual",
message: response?.response?.data?.message,
});
return;
}
// 6⃣ Save tokens
const token = response.response?.data?.token;
const refreshToken = response.response?.data?.refreshToken;
Cookies.set("auth-token", token || "");
Cookies.set("refresh-token", refreshToken || "");
// 7⃣ Upload documents
await uploadAllFiles();
await submitApplication();
toast.success(t("registration.verifyOtp.messages.registrationComplete"));
setFormData(data);
setStep1Completed(true);
setCurrentStep(1); // move to OTP verification
} catch (err) {
if (err instanceof Error && err.message.includes("Duplicate entry")) {
form.setError("root", {
type: "manual",
message: t("registration.verifyOtp.errors.alreadyRegistered"),
});
} else {
handleError(err);
}
} finally {
setSubmitting(false);
}
};
const uploadAllFiles = async (): Promise<void> => {
const filesToUpload = Object.entries(uploadedFiles).filter(
([, file]) => file instanceof File
);
if (filesToUpload.length === 0) {
throw new Error(
"No documents uploaded. Registration requires documents."
);
}
const uploadPromises = filesToUpload.map(async ([docId, file]) => {
if (file instanceof File) {
const payload: DocumentPayloadDto = {
documentId: docId,
type: filterData || FilterEnum.EXTERNAL,
fileInfo: {
size: file.size,
fileName: file.name,
contentType: file.type,
originalname: file.name,
},
};
return uploadDocuments(payload, file).catch((err) => {
throw new Error(
`Failed to upload ${file.name} for document ${docId}: ${err}`
);
});
}
});
await Promise.all(uploadPromises);
};
const onVerifyComplete = async () => {
setSubmitting(true);
try {
toast.success("Application submitted successfully.");
const refreshToken = Cookies.get("refresh-token");
if (!refreshToken) {
throw new Error("No refresh token found");
}
} catch (err) {
console.error("submit error", err);
toast.error("Failed to submit application.");
} finally {
setSubmitting(false);
}
};
const handleStepChange = (step: number) => {
if (step === 1 && !step1Completed) {
toast.error(
"Please complete the registration form and upload all required documents first"
);
return;
}
setCurrentStep(step);
};
const steps = [
{
title: t("registration.steps.registerUpload.title"),
description: t("registration.steps.registerUpload.description"),
content: (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onRegistrationSubmit)}
className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left: form */}
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationEmail")
: t("registration.form.individualEmail")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...field}
className="pl-10"
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationEmailPlaceholder"
)
: t(
"registration.form.individualEmailPlaceholder"
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationUsername")
: t("registration.form.individualUsername")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...field}
className="pl-10"
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationUsernamePlaceholder"
)
: t(
"registration.form.individualUsernamePlaceholder"
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phoneNumber"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationPhone")
: t("registration.form.individualPhone")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...field}
className="pl-10"
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationPhonePlaceholder"
)
: t(
"registration.form.individualPhonePlaceholder"
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name.en"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationNameEn")
: t("registration.form.individualNameEn")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...field}
className="pl-10"
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationNameEnPlaceholder"
)
: t(
"registration.form.individualNameEnPlaceholder"
)
}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name.am"
render={({ field }) => (
<FormItem>
<FormLabel>
{userType === "external_organization"
? t("registration.form.organizationNameAm")
: t("registration.form.individualNameAm")}
<span className="text-red-500" aria-hidden="true">
*
</span>
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={
userType === "external_organization"
? t(
"registration.form.organizationNameAmPlaceholder"
)
: t(
"registration.form.individualNameAmPlaceholder"
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root?.message && (
<div className="text-sm text-destructive mt-2">
{String(form.formState.errors.root.message)}
</div>
)}
</div>
{/* Right: Uploads */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">
{t("registration.documents.requiredDocuments")}
</h3>
<p className="text-sm text-gray-500">
{t("registration.documents.requirementsUpdate")}
</p>
</div>
<div className="text-sm text-gray-600">
{attachedCount} {t("registration.documents.attached")}
</div>
</div>
<div className="p-2 border rounded-md max-h-[60vh] overflow-auto">
<UploadDocumentsStep
registrationType={userType}
uploadedFiles={uploadedFiles}
setUploadedFiles={setUploadedFiles}
/>
</div>
<p className="text-sm text-gray-500">
{t("registration.documents.tip")}
</p>
</div>
{/* Submit Button - Span full width */}
<div className="md:col-span-2">
<Button
type="submit"
disabled={isRegistering || submitting}
className="w-full bg-primary-600">
{isRegistering || submitting
? t("registration.form.processing")
: userType === "external_organization"
? t("registration.form.organizationSubmitButton")
: t("registration.form.individualSubmitButton")}
</Button>
</div>
</div>
</form>
</Form>
),
},
{
title: t("registration.steps.verifyOtp.title"),
description: t("registration.steps.verifyOtp.description"),
content: (
<VerifyOtp
email={formData?.email}
phone={formData?.phoneNumber}
onComplete={onVerifyComplete}
isExternalOrg={true}
/>
),
},
];
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
<Card className="w-full max-w-5xl">
<CardHeader>
<div className="flex items-center justify-center mb-4">
<Building className="h-8 w-8 mr-2" />
<CardTitle className="text-2xl font-bold">Registration</CardTitle>
</div>
<Stepper
steps={steps}
currentStep={currentStep}
setCurrentStep={handleStepChange}
disabled={submitting}
/>
</CardHeader>
<CardContent>{steps[currentStep].content}</CardContent>
</Card>
</div>
);
};
export default RegistrationStepper;

View File

@@ -0,0 +1,79 @@
import React from "react";
import { cn } from "@/shared/lib/utils";
interface StepperProps {
steps: {
title: string;
description: string;
}[];
currentStep: number;
setCurrentStep: (step: number) => void;
disabled?:boolean;
}
export const Stepper = ({
steps,
currentStep,
setCurrentStep,
disabled = false
}: StepperProps) => {
return (
<div className="w-full">
<div className="flex justify-between">
{steps.map((step, index) => (
<div
key={index}
className={cn(
"flex flex-col items-center flex-1",
index < steps.length - 1 && "relative"
)}>
<button
type="button"
onClick={() => {
if (
!disabled &&
(index < currentStep || index === currentStep)
) {
setCurrentStep(index);
}}}
disabled={disabled || index > currentStep}
className={cn(
"flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors duration-300",
currentStep >= index
? "bg-primary-500 border-primary-600 text-white" // Active step (green)
: "bg-white border-gray-300 text-gray-500" // Inactive step
)}>
{index + 1}
</button>
<div className="mt-2 text-center">
<p
className={cn(
"text-sm font-medium",
currentStep >= index
? "text-primary-600" // Active title (darker green)
: "text-gray-500" // Inactive title
)}>
{step.title}
</p>
<p
className={cn(
"text-xs",
currentStep >= index ? "text-primary-500" : "text-gray-400"
)}>
{step.description}
</p>
</div>
{index < steps.length - 1 && (
<div
className={cn(
"absolute top-5 left-1/2 w-full h-0.5 -z-10",
currentStep > index ? "bg-primary-400" : "bg-gray-200"
)}
/>
)}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,280 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { useToast } from "@/shared/common/ui/use-toast";
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
import { useQueries } from "@tanstack/react-query";
import { getMyUploadById } from "@/external-portal/services/portalOutgoingService";
import { useDocumentUploads } from "@/external-portal/hooks/useDocumentUploads";
import { FilterEnum } from "@/shared/services/organizationsService";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import { Check, Trash } from "lucide-react";
import { UploadFileModal } from "./UploadFileModal";
import { useTranslation } from "react-i18next";
import { useUser } from "@/shared/context/UserContext";
export interface LocalizedText {
am?: string;
en?: string;
}
export interface DocumentReq {
id: string;
title: LocalizedText;
description?: LocalizedText;
key?: string;
type?: string;
order?: number;
isActive?: boolean;
isOptional?: boolean;
}
type FilesRecord = Record<string, File | string | null>;
interface UploadDocumentsStepProps {
registrationType?: "individual" | "external_organization" | string;
uploadedFiles: FilesRecord;
setUploadedFiles: React.Dispatch<React.SetStateAction<FilesRecord>>;
}
export const UploadDocumentsStep: React.FC<UploadDocumentsStepProps> = ({
registrationType,
uploadedFiles,
setUploadedFiles,
}) => {
const userDetails = useUser();
const {t} = useTranslation()
// For other registration types, map to FilterEnum
const mappedType =
registrationType === "external_organization"
? "organization"
: (registrationType as string) || userDetails?.userType;
const filterData: FilterEnum | undefined =
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
? (mappedType as FilterEnum)
: undefined;
const { requirementDoc } = useDocumentRequirement(filterData);
const { data: myUploads } = useMyUploads();
const latestUploadIds = useMemo(() => {
if (!requirementDoc?.items?.length) return [];
return requirementDoc.items.map((doc: DocumentReq) => {
const uploadsForDoc = myUploads?.filter(
(u: any) => u.documentId === doc.id
);
if (uploadsForDoc?.length) {
const latest = uploadsForDoc.reduce((prev: any, cur: any) =>
new Date(prev.createdAt) > new Date(cur.createdAt) ? prev : cur
);
return {
docId: doc.id,
uploadId: latest.id,
fileInfo: latest.fileInfo,
};
}
return { docId: doc.id, uploadId: null, fileInfo: null };
});
}, [requirementDoc?.items, myUploads]);
const presignedQueries = useQueries({
queries:
latestUploadIds?.map(({ uploadId }: any) => ({
queryKey: ["myUpload", uploadId],
queryFn: () =>
uploadId ? getMyUploadById(uploadId) : Promise.resolve(null),
enabled: !!uploadId,
})) || [],
});
const prefills = useMemo(() => {
const map: FilesRecord = {};
if (!latestUploadIds?.length) return map;
latestUploadIds.forEach(({ docId, fileInfo }, idx) => {
const q = presignedQueries[idx];
const presigned = q?.data?.presigned;
map[docId] = presigned || fileInfo?.fileName || null;
});
return map;
}, [latestUploadIds, presignedQueries]);
useEffect(() => {
if (
Object.keys(uploadedFiles).length === 0 &&
Object.keys(prefills).length > 0
) {
setUploadedFiles((prev) => ({ ...prefills, ...prev }));
}
}, [prefills, uploadedFiles, setUploadedFiles]);
const [modalOpen, setModalOpen] = React.useState(false);
const [selectedDoc, setSelectedDoc] = React.useState<DocumentReq | null>(null);
// When user clicks "Upload" button
const handleOpenModal = (doc: DocumentReq) => {
setSelectedDoc(doc);
setModalOpen(true);
};
// When file is selected in modal
const handleFileSelect = (file: File | null) => {
if (selectedDoc) {
setUploadedFiles((prev) => ({
...prev,
[selectedDoc.id]: file,
}));
}
setModalOpen(false);
setSelectedDoc(null);
};
const handleRemove = (docId: string) => {
setUploadedFiles((prev: FilesRecord) => ({
...prev,
[docId]: null,
}));
};
const [faydaValue, setFaydaValue] = useState<string>("");
// If registration type is individual, show Fayda input
if (registrationType === "individual") {
return (
<div className="space-y-3">
<label className="block">
<span className="font-medium">
{t("registration.documents.fayda")}
</span>
<Input
type="text"
value={faydaValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setFaydaValue(e.target.value)
}
placeholder={t("registration.documents.faydaMessage")}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</label>
</div>
);
}
return (
<div className="space-y-3">
{requirementDoc?.items?.length === 0 ? (
<div className="text-sm text-gray-500">
{t("registration.documents.noRequirements")}
</div>
) : (
requirementDoc.items.map((doc) => {
const current = uploadedFiles[doc.id];
const isNewFile = current instanceof File;
return (
<div
key={doc.id}
className="flex items-center gap-2 p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{doc.title?.en || doc.id}</div>
{doc.description?.en && (
<div className="text-sm text-muted-foreground">
{doc.description.en}
</div>
)}
{current && (
<div className="text-xs mt-1 flex items-center gap-1">
<Check className="h-3 w-3" />
<span
className={
isNewFile ? "text-blue-600" : "text-primary-600"
}>
{isNewFile
? `Ready to upload: ${current.name}`
: `Previously uploaded: ${current}`}
</span>
{isNewFile && (
<span className="text-orange-500 ml-2">
({t("registration.documents.willUploadOnSubmit")})
</span>
)}
</div>
)}
{!doc.isOptional && !current && (
<div className="text-xs text-red-600 mt-1">
{t("registration.documents.required")}
</div>
)}
</div>
<div className="flex items-center gap-2">
{current && (
<Button
variant="ghost"
size="icon"
onClick={() => handleRemove(doc.id)}
className="h-8 w-8 text-destructive">
<Trash className="h-4 w-4" />
</Button>
)}
<Button
size="sm"
variant={current ? "outline" : "default"}
className={current ? "" : "bg-primary-600"}
onClick={() => handleOpenModal(doc)}>
{current ? "Change" : "Upload"}
</Button>
</div>
</div>
);
})
)}
{modalOpen && selectedDoc && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<UploadFileModal
value={uploadedFiles[selectedDoc.id] || null}
multiple={false}
accept={[
".png",
".jpg",
".jpeg",
".pdf",
".dwg",
".dxf",
".dwt",
".bak",
".sv$",
".dws",
".mxd",
".aprx",
".rar",
".zip",
]}
onChange={(file) => {
handleFileSelect(
file instanceof File
? file
: Array.isArray(file)
? file[0]
: null
);
}}
onClose={() => {
setModalOpen(false);
setSelectedDoc(null);
}}
fileUploadFields={{
requiredDocumentId: selectedDoc.id,
}}
/>
</div>
)}
</div>
);
};
export default UploadDocumentsStep;

View File

@@ -0,0 +1,514 @@
"use client";
import React, { memo, useCallback, useMemo, useRef, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
import {
CloudUpload,
Trash,
FileType,
X,
Info,
FileUp,
Plus,
Check,
} from "lucide-react";
import { ImageCropper } from "./ImageCropper";
import { FilePreview } from "./FilePreview";
import { useToast } from "@/shared/common/ui/use-toast";
import { MAX_FILE_SIZE_MB } from "@/shared/utils/max-file-size";
import {
getAllowedExtensionsFromAccept,
getAllowedMimeTypesFromAccept,
getFinalAllowedFileTypes,
} from "@/shared/utils/get-final-allowed-file-types";
import { ClientSideValidator } from "@/shared/services/validation/ClientSideValidator";
import { FileUploadValidator } from "@/shared/services/validation/FileUploadValidator";
interface UploadFileModalProps {
onChange: (
file: File | File[] | null,
status?: string,
requiredDocumentId?: string
) => void;
value: File | File[] | null | string;
fileUploadFields?: any;
accept?: string[] | null;
multiple?: boolean;
onClose?: () => void;
}
export const UploadFileModal = memo(
({
onChange,
value,
fileUploadFields,
accept,
multiple,
onClose,
}: UploadFileModalProps) => {
const [file, setFile] = useState<File | File[] | null>(null);
const [croppingFile, setCroppingFile] = useState<File | null>(null);
const [croppedFile, setCroppedFile] = useState<File | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [fileErrorMessages, setFileErrorMessages] = useState<any[]>([]);
const openRef = useRef<HTMLInputElement>(null);
const validatorRef = useRef(new ClientSideValidator());
const { toast } = useToast();
const needsCropping = (file: File) => {
const photoConfig = fileUploadFields?.photoConfiguration;
const isPhotoConfigEnabled =
photoConfig === true || photoConfig === "true";
return (
isPhotoConfigEnabled &&
file.type.startsWith("image/") &&
!["image/gif", "image/svg+xml"].includes(file.type)
);
};
const handleChange = useCallback(
async (newFile: File | File[] | null) => {
setErrorMessage(null);
setFileErrorMessages([]);
if (newFile) {
const allowedMimeTypes = accept
? getAllowedMimeTypesFromAccept(accept)
: undefined;
const allowedExtensions = accept
? getAllowedExtensionsFromAccept(accept)
: undefined;
if (Array.isArray(newFile)) {
const validationErrors: any[] = [];
for (let index = 0; index < newFile.length; index++) {
const item = newFile[index];
// Comprehensive file validation
const basicValidation = FileUploadValidator.validateFile(item, {
maxSizeMB: MAX_FILE_SIZE_MB,
allowedMimeTypes:
item.type && allowedMimeTypes?.length
? allowedMimeTypes
: undefined,
allowedExtensions:
allowedExtensions?.length ? allowedExtensions : undefined,
});
if (!basicValidation.isValid) {
validationErrors.push({
fileIndex: index,
error: basicValidation.error || "File validation failed",
});
continue;
}
// Client-side magic byte validation
if (accept) {
const clientValidation = await validatorRef.current.validateUpload(
item,
item.type,
allowedMimeTypes || []
);
if (!clientValidation.isValid) {
validationErrors.push({
fileIndex: index,
error: clientValidation.error || "File validation failed",
});
continue;
}
}
}
if (validationErrors.length > 0) {
setFileErrorMessages(validationErrors);
setErrorMessage(`File validation error`);
return;
}
} else {
// Comprehensive file validation
const basicValidation = FileUploadValidator.validateFile(newFile, {
maxSizeMB: MAX_FILE_SIZE_MB,
allowedMimeTypes:
newFile.type && allowedMimeTypes?.length
? allowedMimeTypes
: undefined,
allowedExtensions:
allowedExtensions?.length ? allowedExtensions : undefined,
});
if (!basicValidation.isValid) {
setErrorMessage(basicValidation.error || "File validation failed");
return;
}
if (accept) {
// Client-side magic byte validation
const clientValidation = await validatorRef.current.validateUpload(
newFile,
newFile.type,
allowedMimeTypes || []
);
if (!clientValidation.isValid) {
setErrorMessage(clientValidation.error || "File validation failed");
return;
}
}
if (needsCropping(newFile)) {
setCroppingFile(newFile);
return;
}
}
}
// Set the file and immediately call onChange to update parent
const finalFile = multiple
? [
...(Array.isArray(file) ? file : []),
...(Array.isArray(newFile) ? newFile : [newFile]).filter(Boolean),
]
: newFile;
const cleanedFile = Array.isArray(finalFile)
? finalFile.filter((f): f is File => f !== null) // removes nulls
: finalFile;
setFile(cleanedFile);
// Immediately notify parent about the file change
if (cleanedFile) {
onChange(
cleanedFile,
"selected",
fileUploadFields?.requiredDocumentId
);
} else {
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
}
},
[fileUploadFields?.photoConfiguration, accept, multiple, file, onChange]
);
const handleClose = useCallback(() => {
setCroppingFile(null);
setFile(null);
setCroppedFile(null);
setErrorMessage(null);
onClose?.();
}, [onClose]);
const handleCropComplete = useCallback(
(croppedFile: File) => {
setCroppingFile(null);
setCroppedFile(croppedFile);
setFile(croppedFile);
onChange(croppedFile, "selected", fileUploadFields?.requiredDocumentId);
},
[onChange, fileUploadFields?.requiredDocumentId]
);
const handleCancelCrop = useCallback(() => {
setCroppingFile(null);
setFile(null);
setCroppedFile(null);
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
}, [onChange, fileUploadFields?.requiredDocumentId]);
const handleRemoveFile = useCallback(
(index?: number) => {
if (multiple && index !== undefined) {
const updatedFiles = Array.isArray(file)
? file.filter((_, i) => i !== index)
: null;
setFile(updatedFiles);
setFileErrorMessages(
fileErrorMessages.filter((x) => x?.fileIndex !== index)
);
if (updatedFiles && updatedFiles.length > 0) {
onChange(
updatedFiles,
"selected",
fileUploadFields?.requiredDocumentId
);
} else {
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
}
} else {
setFile(null);
setCroppedFile(null);
setErrorMessage(null);
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
}
},
[
file,
multiple,
fileErrorMessages,
onChange,
fileUploadFields?.requiredDocumentId,
]
);
const isMultipleFileAttached = useMemo(
() => file && Array.isArray(file),
[file]
);
const isSomeFileAttached = useMemo(() => {
if (isMultipleFileAttached) {
return Array.isArray(file) ? file.length > 0 : false;
}
return !!file;
}, [file, isMultipleFileAttached]);
return (
<Card className="w-full max-w-md relative">
<CardHeader>
<CardTitle>Upload File{multiple && "s"}</CardTitle>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4 h-6 w-6"
onClick={handleClose}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center gap-4">
{croppingFile ? (
<div className="w-full space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">Crop Image</h3>
<Button
variant="ghost"
size="icon"
onClick={handleCancelCrop}>
<X className="h-5 w-5" />
</Button>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>Note</AlertTitle>
<AlertDescription>
This image will be cropped to a 3:4 aspect ratio. Please
adjust the crop area accordingly.
</AlertDescription>
</Alert>
<div className="p-4 border rounded-lg bg-muted">
<ImageCropper
file={croppingFile}
onCropComplete={handleCropComplete}
aspectRatio={3 / 4}
/>
</div>
</div>
) : (
<>
<div className={isSomeFileAttached ? "hidden" : "block"}>
<div className="flex justify-center">
<CloudUpload className="h-12 w-12" />
</div>
<h2 className="text-xl font-bold text-center mt-4">
Upload File{multiple && "s"}
</h2>
</div>
{isSomeFileAttached && (
<div className="relative flex flex-col items-center gap-4 p-4 border rounded-md">
<div
className={
isMultipleFileAttached
? "max-w-[30rem] max-h-[20rem] overflow-auto"
: "h-40 w-40"
}>
{file instanceof File &&
file?.type === "application/pdf" ? (
<div className="flex flex-col items-center justify-center pt-16">
<FileType size={60} className="h-16 w-16" />
</div>
) : isMultipleFileAttached ? (
<div className="grid grid-cols-2 gap-4">
{Array.isArray(file) &&
Array.from(file)?.map((f, i) => (
<div key={i} className="space-y-2">
{f?.type === "application/pdf" ? (
<div className="flex flex-col items-center justify-center pt-16">
<FileType size={60} className="h-16 w-16" />
</div>
) : (
<div
className={`border rounded-md ${
Array.from(file)?.length == 1
? "h-60"
: "h-48"
} overflow-auto`}>
<FilePreview file={f} type="image" />
</div>
)}
<p className="w-36 truncate">{f?.name}</p>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveFile(i)}>
<Trash className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
{fileErrorMessages?.find(
(item) => item.fileIndex === i
)?.error && (
<Alert variant="destructive">
<X className="h-4 w-4" />
<AlertDescription>
{
fileErrorMessages?.find(
(item) => item.fileIndex === i
)?.error
}
</AlertDescription>
</Alert>
)}
</div>
))}
</div>
) : (
<div className="w-full h-full overflow-auto">
<FilePreview
file={croppedFile || (file as File)}
type="image"
/>
</div>
)}
</div>
{!isMultipleFileAttached &&
(croppedFile || file) instanceof File && (
<p className="w-36 truncate">
{croppedFile
? croppedFile.name
: file instanceof File
? file.name
: ""}
</p>
)}
{!isMultipleFileAttached && (
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8"
onClick={() => handleRemoveFile()}>
<Trash className="h-4 w-4 text-destructive" />
</Button>
)}
{/* File Selected Indicator */}
<div className="flex items-center gap-2 text-primary-600">
<Check className="h-4 w-4" />
<span className="text-sm font-medium">File selected</span>
</div>
</div>
)}
<p className="text-sm text-muted-foreground text-center">
You can attach{" "}
{accept
?.map((item) => `${item} files (max ${MAX_FILE_SIZE_MB}MB)`)
?.join(", ")}
</p>
{multiple && (
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<p className="text-sm text-primary">
You can attach multiple files
</p>
</div>
)}
<div className="flex flex-col gap-4 w-full">
<Input
type="file"
accept={
accept
? getFinalAllowedFileTypes(accept)
: "image/png,image/jpeg,application/pdf"
}
onChange={(e) => {
const files = e.target.files;
if (!files) return;
if (multiple) {
handleChange(Array.from(files));
} else {
handleChange(files[0]);
}
}}
multiple={multiple}
className="hidden"
ref={openRef}
/>
<Button
variant="outline"
onClick={() => openRef.current?.click()}
className="w-full">
{isSomeFileAttached ? (
multiple ? (
<>
<Plus className="mr-2 h-4 w-4" />
Add more files
</>
) : (
<>
<FileUp className="mr-2 h-4 w-4" />
Change file
</>
)
) : (
<>
<FileUp className="mr-2 h-4 w-4" />
Select file{multiple ? "s" : ""}
</>
)}
</Button>
{/* REMOVED: Continue button - file selection is now immediate */}
</div>
{errorMessage && (
<Alert variant="destructive">
<X className="h-4 w-4" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
</>
)}
</div>
</CardContent>
</Card>
);
}
);
UploadFileModal.displayName = "UploadFileModal";

View File

@@ -0,0 +1,450 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import {
Building2,
Loader2,
AlertCircle,
UserRound,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Link } from "react-router-dom";
import VerifyOtp from "../verifyOTP";
import { useTradeLicenseVerification } from "@/external-portal/hooks/useTradeLicenseVerification";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import {
startExternalPortalFaydaAuth,
} from "@/shared/utils/faydaOidc";
import {
EtradeBusinessLicenseOption,
fetchRegistrationByTin,
mapEtradeBusinessLicenses,
resolveEtradeLanguage,
TinRegistrationNotFoundError,
} from "@/complaints/services/etradeTinService";
import {
isValidLicenseNo,
normalizeLicenseNo,
normalizeTin,
} from "@/external-portal/utils/etradeValidation";
import {
clearEtradeLicenseNo,
storeEtradeLicenseNo,
} from "@/external-portal/utils/etradeAuthStorage";
type OrgStep = "trade_license" | null;
type TradeLicenseStep = "tin" | "select_license";
export default function UserTypeSelection() {
const [orgStep, setOrgStep] = useState<OrgStep>(null);
const [tradeLicenseStep, setTradeLicenseStep] =
useState<TradeLicenseStep>("tin");
const [tin, setTin] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [licenseOptions, setLicenseOptions] = useState<
EtradeBusinessLicenseOption[]
>([]);
const [selectedLicenseNumber, setSelectedLicenseNumber] = useState("");
const [tradeLicenseNumber, setTradeLicenseNumber] = useState("");
const [tinError, setTinError] = useState<string | null>(null);
const [isVerifyingTin, setIsVerifyingTin] = useState(false);
const { t, i18n } = useTranslation();
const [showVerifyOtp, setShowVerifyOtp] = useState(false);
const { handleError } = useErrorHandler(t);
const { registerTradeLicense, isRegistering } = useTradeLicenseVerification();
const resetTradeLicenseFlow = () => {
setTradeLicenseStep("tin");
setTin("");
setOrganizationName("");
setLicenseOptions([]);
setSelectedLicenseNumber("");
setTradeLicenseNumber("");
setTinError(null);
setShowVerifyOtp(false);
clearEtradeLicenseNo();
};
useEffect(() => {
setOrgStep(null);
resetTradeLicenseFlow();
}, []);
const onVerifyComplete = () => {
toast.success(t("registration.etrade.registrationSuccess"));
};
const registerWithLicense = async (licenseNumber: string) => {
const normalizedLicense = normalizeLicenseNo(licenseNumber);
if (!isValidLicenseNo(normalizedLicense)) return;
setTradeLicenseNumber(normalizedLicense);
storeEtradeLicenseNo(normalizedLicense);
await registerTradeLicense({
tin: normalizeTin(tin),
licenseNo: normalizedLicense,
});
setShowVerifyOtp(true);
};
const startETradeAuth = () => {
resetTradeLicenseFlow();
setOrgStep("trade_license");
};
const handleTinVerify = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setTinError(null);
const normalizedTin = tin.trim();
if (!normalizedTin) {
setTinError(t("complaint.tin.tinRequired"));
return;
}
if (!/^\d{10}$/.test(normalizedTin)) {
setTinError(t("complaint.tin.tinInvalid"));
return;
}
try {
setIsVerifyingTin(true);
const registration = await fetchRegistrationByTin(
normalizedTin,
resolveEtradeLanguage(i18n.language),
);
const businesses = mapEtradeBusinessLicenses(
registration,
resolveEtradeLanguage(i18n.language),
);
if (businesses.length === 0) {
setTinError(t("registration.etrade.noLicensesFound"));
return;
}
const companyName = String(
registration.BusinessName ??
registration.businessName ??
registration.BusinessNameAmh ??
registration.businessNameAmh ??
"",
).trim();
setOrganizationName(companyName);
setLicenseOptions(businesses);
setSelectedLicenseNumber(businesses[0].licenseNumber);
setTradeLicenseStep("select_license");
} catch (error) {
if (error instanceof TinRegistrationNotFoundError) {
setTinError(t("complaint.tin.notFound"));
return;
}
if (
error instanceof Error &&
error.message === "ETRADE_REFERER_REJECTED"
) {
setTinError(t("complaint.tin.proxyError"));
return;
}
handleError(error);
} finally {
setIsVerifyingTin(false);
}
};
const handleLicenseSelectionSubmit = async () => {
if (!selectedLicenseNumber) return;
try {
await registerWithLicense(selectedLicenseNumber);
} catch (error) {
handleError(error);
}
};
const authOptions = [
{
provider: "fayda" as const,
title: t("registration.auth.continueWithFayda"),
description: t("registration.auth.faydaDescription"),
icon: UserRound,
onClick: () => startExternalPortalFaydaAuth(),
},
{
provider: "etrade" as const,
title: t("registration.auth.continueWithEtrade"),
description: t("registration.auth.etradeDescription"),
icon: Building2,
onClick: () => startETradeAuth(),
},
];
return (
<div className="min-h-screen flex items-center justify-center bg-primary-50 p-4">
<div className="w-full max-w-3xl">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center gap-2 mb-4">
<Building2 className="size-8 text-primary-600" />
<h1 className="text-3xl font-bold text-primary-900">
{t("registration.auth.portalTitle")}
</h1>
</div>
<p className="text-primary-700 text-lg">
{t("registration.auth.selectionDescription")}
</p>
</div>
{!orgStep && (
<>
<div className="grid gap-6 md:grid-cols-2 mb-8">
{authOptions.map((option) => {
const Icon = option.icon;
return (
<Card
key={option.provider}
className="p-6 cursor-pointer border-2 border-primary-200 bg-white transition-all hover:border-primary-400"
onClick={option.onClick}>
<div className="flex flex-col items-center text-center gap-4">
<div className="p-4 rounded-full bg-primary-100 text-primary-600">
<Icon className="size-8" />
</div>
<div>
<h2 className="text-xl font-semibold mb-2 text-primary-900">
{option.title}
</h2>
<p className="text-primary-700 text-sm">
{option.description}
</p>
</div>
</div>
</Card>
);
})}
</div>
<div className="text-center">
<Link
to="/login"
className="text-sm font-medium text-primary-700 hover:text-primary-900 hover:underline">
{t("registration.auth.employeeLogin")}
</Link>
</div>
<div className="mt-4 text-center">
<Link
to="/external-portal/signup/manual"
className="text-sm font-medium text-primary-700 hover:text-primary-900 hover:underline">
{t("registration.auth.manualOrgSignup")}
</Link>
</div>
</>
)}
{orgStep === "trade_license" && (
<div className="mb-6 flex items-center justify-center gap-2 text-sm">
<span
className={`rounded-full px-3 py-1 ${
tradeLicenseStep === "tin" && !showVerifyOtp
? "bg-primary-600 text-white"
: "bg-primary-100 text-primary-700"
}`}>
1. {t("registration.etrade.stepTin")}
</span>
<span className="text-primary-400"></span>
<span
className={`rounded-full px-3 py-1 ${
tradeLicenseStep === "select_license" && !showVerifyOtp
? "bg-primary-600 text-white"
: "bg-primary-100 text-primary-700"
}`}>
2. {t("registration.etrade.stepLicense")}
</span>
<span className="text-primary-400"></span>
<span
className={`rounded-full px-3 py-1 ${
showVerifyOtp
? "bg-primary-600 text-white"
: "bg-primary-100 text-primary-700"
}`}>
3. {t("registration.etrade.stepOtp")}
</span>
</div>
)}
{orgStep === "trade_license" &&
(!showVerifyOtp ? (
tradeLicenseStep === "tin" ? (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-2xl font-semibold text-primary-900">
{t("registration.auth.continueWithEtrade")}
</h2>
<p className="mt-2 text-sm text-primary-700">
{t("registration.etrade.enterTinDescription")}
</p>
</div>
{tinError && (
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
<p className="text-sm text-red-700">{tinError}</p>
</div>
)}
<form onSubmit={handleTinVerify} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="etrade-tin">
{t("complaint.tin.tinLabel")}
</Label>
<Input
id="etrade-tin"
inputMode="numeric"
pattern="\d*"
maxLength={10}
value={tin}
onChange={(event) =>
setTin(
event.target.value.replace(/\D/g, "").slice(0, 10),
)
}
placeholder={t("complaint.tin.tinPlaceholder")}
className="border-primary-300 focus:ring-primary-500 font-mono text-lg tracking-wide"
autoComplete="off"
disabled={isVerifyingTin}
required
/>
<p className="text-xs text-primary-600">
{t("complaint.tin.tinHint")}
</p>
</div>
<div className="flex justify-center gap-4 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setOrgStep(null)}
className="border-primary-400 text-primary-700 hover:bg-primary-100"
disabled={isVerifyingTin}>
{t("registration.etrade.back")}
</Button>
<Button
type="submit"
disabled={isVerifyingTin || tin.trim().length !== 10}
className="bg-primary-600 hover:bg-primary-700 text-white">
{isVerifyingTin ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("complaint.tin.verifying")}
</span>
) : (
t("complaint.tin.verify")
)}
</Button>
</div>
</form>
</div>
) : (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-2xl font-semibold text-primary-900">
{t("registration.etrade.selectLicenseTitle")}
</h2>
<p className="mt-2 text-sm text-primary-700">
{t("registration.etrade.selectLicenseDescription")}
</p>
{organizationName && (
<p className="mt-2 text-sm font-medium text-primary-900">
{organizationName}
</p>
)}
</div>
<div className="max-h-[24rem] space-y-3 overflow-y-auto pr-1">
{licenseOptions.map((option) => {
const isSelected =
selectedLicenseNumber === option.licenseNumber;
return (
<Card
key={option.mainGuid}
className={`cursor-pointer border-2 p-4 transition-all ${
isSelected
? "border-primary-500 bg-primary-50 ring-2 ring-primary-200"
: "border-primary-200 hover:border-primary-300 bg-white"
}`}
onClick={() =>
setSelectedLicenseNumber(option.licenseNumber)
}>
<div className="space-y-2 text-left">
<p className="font-semibold text-primary-900">
{option.tradeName}
</p>
<p className="text-sm text-primary-700">
<span className="font-medium">
{t("registration.etrade.licenseNumber")}:
</span>{" "}
<span className="font-mono">
{option.licenseNumber}
</span>
</p>
{option.activities.length > 0 && (
<p className="text-sm text-primary-600">
<span className="font-medium">
{t("registration.etrade.activity")}:
</span>{" "}
{option.activities.join("; ")}
</p>
)}
</div>
</Card>
);
})}
</div>
<div className="flex justify-center gap-4">
<Button
variant="outline"
onClick={() => {
setTradeLicenseStep("tin");
setSelectedLicenseNumber("");
setLicenseOptions([]);
}}
className="border-primary-400 text-primary-700 hover:bg-primary-100"
disabled={isRegistering}>
{t("registration.etrade.back")}
</Button>
<Button
onClick={handleLicenseSelectionSubmit}
disabled={!selectedLicenseNumber || isRegistering}
className="bg-primary-600 hover:bg-primary-700 text-white">
{isRegistering ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("registration.etrade.loading")}
</span>
) : (
t("registration.etrade.continue")
)}
</Button>
</div>
</div>
)
) : (
<VerifyOtp
licenseNo={tradeLicenseNumber}
onComplete={onVerifyComplete}
isEtradeVerification={true}
isExternalOrg={true}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { PixelCrop } from "react-image-crop";
export async function canvasPreview(
image: HTMLImageElement,
canvas: HTMLCanvasElement,
crop: PixelCrop,
scale = 1,
rotate = 0
) {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("No 2d context");
}
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const pixelRatio = window.devicePixelRatio;
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = "high";
const cropX = crop.x * scaleX;
const cropY = crop.y * scaleY;
const cropWidth = crop.width * scaleX;
const cropHeight = crop.height * scaleY;
const rotateRads = rotate * (Math.PI / 180);
const centerX = image.naturalWidth / 2;
const centerY = image.naturalHeight / 2;
ctx.save();
ctx.translate(-cropX, -cropY);
ctx.translate(centerX, centerY);
ctx.rotate(rotateRads);
ctx.scale(scale, scale);
ctx.translate(-centerX, -centerY);
ctx.drawImage(
image,
0,
0,
image.naturalWidth,
image.naturalHeight,
0,
0,
image.naturalWidth,
image.naturalHeight
);
ctx.restore();
}

View File

@@ -0,0 +1,270 @@
import { useState } from "react";
import Header from "@/layout/components/Header";
import { Button } from "@/shared/common/ui/button";
import { useToast } from "@/shared/common/ui/use-toast";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
import { useQueries } from "@tanstack/react-query";
import {
getMyUploadById,
LatestUploadInfo,
submitApplication,
} from "@/external-portal/services/portalOutgoingService";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import { FilterEnum } from "@/shared/services/organizationsService";
import { UploadFileModal } from "./UploadFileModal";
import { useUser } from "@/shared/context/UserContext";
interface LocalizedText {
am: string;
en: string;
}
type Upload = {
uploadId: string;
// add other fields if needed
};
interface Document {
id: string;
title: LocalizedText;
description: LocalizedText;
key: string;
type: string;
order: number;
isActive: boolean;
isOptional: boolean;
}
const UploadSteps = () => {
const { toast } = useToast();
const localized = useLocalizedName();
const userDetails = useUser();
const filterDataStr = userDetails?.userType;
const mappedType =
filterDataStr === "external_organization" ? "organization" : filterDataStr;
const filterData: FilterEnum | undefined =
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
? (mappedType as FilterEnum)
: undefined;
// Fetch required docs
const { requirementDoc } = useDocumentRequirement(filterData);
const { data: myUploads } = useMyUploads();
// Step 1: Collect latest upload IDs for each document
const latestUploadIds = (requirementDoc.items ?? []).map((doc) => {
const uploadsForDoc = (myUploads ?? []).filter(
(u) => u.documentId === doc.id
);
if (uploadsForDoc.length) {
const latestUpload = uploadsForDoc.reduce((prev, current) =>
new Date(prev.createdAt) > new Date(current.createdAt) ? prev : current
);
return {
docId: doc.id,
uploadId: latestUpload.id,
fileInfo: latestUpload.fileInfo,
};
}
return { docId: doc.id, uploadId: null, fileInfo: null };
});
// Step 2: Query presigned URLs for latest uploads
const queriesToRun = (latestUploadIds as LatestUploadInfo[]).filter(
({ uploadId }) => !!uploadId
);
const uploadQueries = useQueries({
queries: queriesToRun.map(({ uploadId }) => ({
queryKey: ["myUpload", uploadId],
queryFn: () => getMyUploadById(uploadId!),
})),
});
// Step 3: Build files record
const filesRecord: Record<string, File | string | null> =
latestUploadIds.reduce((acc, { docId, fileInfo }, idx) => {
const queryIndex = queriesToRun.findIndex(
(q) => q.uploadId === latestUploadIds[idx].uploadId
);
const uploadData =
queryIndex >= 0 ? uploadQueries[queryIndex]?.data : undefined;
acc[docId] = uploadData?.presigned || fileInfo?.fileName || null;
return acc;
}, {} as Record<string, File | string | null>);
// State for modal and files
const [uploadedFiles, setUploadedFiles] = useState(filesRecord);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const handleOpenModal = (doc: Document) => {
setSelectedDoc(doc);
setModalOpen(true);
};
const handleUploadComplete = (file: File | string | null) => {
if (selectedDoc) {
const newFiles = {
...uploadedFiles,
[selectedDoc.id]: file,
};
setUploadedFiles(newFiles);
setSelectedDoc(null);
setModalOpen(false);
if (file) {
toast({
title: "Success",
description:
"File uploaded successfully. You will be notified of your status via SMS shortly.",
variant: "default",
});
}
}
};
const handleSubmit = async () => {
try {
const response = await submitApplication();
if (!response) {
toast({
title: "Error",
description: "User ID is missing. Cannot submit application.",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description:
"You have submitted your application and will be notified of the status. Thank you!",
variant: "default",
});
} catch (error) {
console.error(error);
toast({
title: "Error",
description: "Failed to submit application.",
variant: "destructive",
});
}
};
return (
<div className="flex flex-col gap-4 w-full">
<Header />
<div className="p-4 bg-white shadow rounded-lg w-full">
<h2 className="text-lg font-semibold text-center">Upload Documents</h2>
<p className="text-sm text-gray-600 text-center">
Please upload the required documents for verification.
</p>
<div className="w-full max-w-5xl mx-auto mt-4 space-y-4">
{requirementDoc?.items.length === 0 && (
<div>No Document Requirements available.</div>
)}
{requirementDoc?.items.map((doc: Document) => (
<div
key={doc.id}
className="flex items-center justify-between p-4 border rounded-md">
<div className="flex flex-col">
<span className="font-medium">{localized(doc.title)}</span>
{doc.description && (
<span className="text-sm text-muted-foreground">
{localized(doc.description)}
</span>
)}
</div>
<div className="flex gap-2">
{uploadedFiles[doc.id] && (
<Button
variant="outline"
size="sm"
onClick={() => {
const file = uploadedFiles[doc.id];
if (!file) return;
if (typeof file === "string") {
window.open(file, "_blank");
} else if (file instanceof File) {
const url = URL.createObjectURL(file);
window.open(url, "_blank");
}
}}>
View
</Button>
)}
<Button
size="sm"
className="bg-primary-600"
onClick={() => handleOpenModal(doc)}>
{uploadedFiles[doc.id] ? "Replace" : "Upload"}
</Button>
</div>
</div>
))}
<div className="pt-4">
<Button
size="sm"
className="bg-primary-600 w-full"
disabled={
Object.values(uploadedFiles).filter((file) => file !== null)
.length === 0
}
onClick={handleSubmit}>
Submit
</Button>
</div>
</div>
</div>
{/* Modal Overlay */}
{modalOpen && selectedDoc && (
<UploadFileModal
value={uploadedFiles[selectedDoc.id] || null}
multiple={false}
accept={[
".png",
".jpg",
".jpeg",
".pdf",
".dwg",
".dxf",
".dwt",
".bak",
".sv$",
".dws",
".mxd",
".aprx",
".rar",
".zip",
]}
onChange={(file) => {
if (file === null) handleUploadComplete(null);
else if (file instanceof File) handleUploadComplete(file);
else if (Array.isArray(file) && file[0])
handleUploadComplete(file[0]);
}}
fileUploadFields={{
requiredDocumentId: selectedDoc.id,
}}
/>
)}
</div>
);
};
export default UploadSteps;

View File

@@ -0,0 +1,26 @@
// components/useDebounceEffect.ts
import { useEffect, useRef } from "react";
export function useDebounceEffect(
fn: () => void,
waitTime: number,
deps?: any[]
) {
const timeoutRef = useRef<number>(0);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
fn();
}, waitTime);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, deps);
}

View File

@@ -0,0 +1,447 @@
"use client";
import React, { useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "react-router-dom";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Mail, User, Phone, Text, Building } from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import { userSchema, UserFormValues } from "./outgoing/formSchema";
import { useRegisterExternalPortalUser } from "../hooks/useRegisterExternalPortalUser";
import { useTenantConfig } from "@/layout/components/TenantConfig";
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
import { useQueries } from "@tanstack/react-query";
import {
getMyUploadById,
submitApplication,
} from "@/external-portal/services/portalOutgoingService";
import { FilterEnum } from "@/shared/services/organizationsService";
import { UploadFileModal } from "./Registration/UploadFileModal";
type LocalizedText = { am: string; en: string };
interface Document {
id: string;
title: LocalizedText;
description: LocalizedText;
key: string;
type: string;
order: number;
isActive: boolean;
isOptional: boolean;
}
export default function SignupWithUploads() {
const { toast } = useToast();
const navigate = useNavigate();
const { config: tenantConfig } = useTenantConfig();
const { registerExternalPortalUser, isRegistering } =
useRegisterExternalPortalUser();
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
username: "",
phoneNumber: "",
userType: "external_organization",
name: { am: "", en: "" },
},
mode: "onChange",
});
// watch userType so we can load document requirements for that type
const watchedUserType = form.watch("userType") || "external_organization";
// Map userType to FilterEnum values (same logic you used elsewhere)
const mappedType =
watchedUserType === "external_organization"
? "organization"
: watchedUserType;
const filterData: FilterEnum | undefined =
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
? (mappedType as FilterEnum)
: undefined;
// fetch requirement doc for the watched user type
const { requirementDoc } = useDocumentRequirement(filterData);
// fetch user's previous uploads (if any) and prefill the upload list
const { data: myUploads } = useMyUploads();
// build latest upload ids per requirement doc (like your UploadSteps logic)
const latestUploadIds = (requirementDoc.items ?? []).map((doc) => {
const uploadsForDoc = (myUploads ?? []).filter(
(u) => u.documentId === doc.id
);
if (uploadsForDoc.length) {
const latestUpload = uploadsForDoc.reduce((prev, current) =>
new Date(prev.createdAt) > new Date(current.createdAt) ? prev : current
);
return {
docId: doc.id,
uploadId: latestUpload.id,
fileInfo: latestUpload.fileInfo,
};
}
return { docId: doc.id, uploadId: null, fileInfo: null };
});
// query presigned urls for each latest upload
const uploadQueries = useQueries({
queries:
latestUploadIds.map(({ uploadId }:any) => ({
queryKey: ["myUpload", uploadId],
queryFn: () => getMyUploadById(uploadId as string),
enabled: !!uploadId,
})) || [],
});
// build filesRecord map: docId -> presignedURL | fileName | null
const filesRecord = latestUploadIds.reduce(
(acc: Record<string, File | string | null>, { docId, fileInfo }: any, idx:any) => {
const uploadData = uploadQueries[idx]?.data as
| { data?: { presigned?: string } }
| undefined;
const presigned = uploadData?.data?.presigned;
acc[docId] = presigned || fileInfo?.fileName || null;
return acc;
},
{} as Record<string, File | string | null>
);
// local state to track uploaded files while user interacts on this page
const [uploadedFiles, setUploadedFiles] =
useState<Record<string, File | string | null>>(filesRecord);
// modal state for UploadFileModal
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openUploadModal = (doc: Document) => {
setSelectedDoc(doc);
setModalOpen(true);
};
const handleUploadComplete = (file: File | null | string) => {
if (!selectedDoc) return;
const newFiles = { ...uploadedFiles, [selectedDoc.id]: file };
setUploadedFiles(newFiles);
setSelectedDoc(null);
setModalOpen(false);
if (file) {
toast({
title: "Success",
description:
"File uploaded successfully. You will be notified of your status via SMS shortly.",
variant: "default",
});
}
};
// Signup submit: registers the user then triggers submitApplication (if desired)
const onSubmit = async (values: UserFormValues) => {
try {
// 1) Register user (existing hook)
await registerExternalPortalUser(values);
// 2) After registration, optionally submit an application.
// Your uploadDocuments hook (used in UploadFileModal) already uploads
// files during the modal upload action. submitApplication() finalizes/sends the application.
try {
await submitApplication();
} catch (err) {
// non-blocking: show toast but continue to navigate to verify OTP
console.error("submitApplication error", err);
toast({
title: "Warning",
description:
"Account created but application submission failed. You can retry later.",
variant: "destructive",
});
}
// 3) Navigate to verify-otp with query params (same behavior you had)
const params = new URLSearchParams();
if (values.email) params.set("email", values.email.trim());
if (values.phoneNumber) {
const normalizedPhone = values.phoneNumber.trim().replace(/^0/, "+251");
params.set("phone", normalizedPhone);
}
navigate(`/external-portal/verify-otp?${params.toString()}`);
} catch (error) {
console.error("Signup error:", error);
toast({
title: "Error",
description: "Failed to create account. Please check your details.",
variant: "destructive",
});
}
};
// helper localizer — re-use your localized name hook if needed (not provided here).
const localized = (t: LocalizedText) => t?.en || t?.am || "";
// count uploaded files
const attachedCount = useMemo(
() => Object.values(uploadedFiles).filter((f) => f !== null).length,
[uploadedFiles]
);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
<div className="w-full max-w-6xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col md:flex-row h-full max-h-[900px]">
{/* Left: Signup form */}
<div className="md:w-1/2 w-full p-8 md:p-12 flex flex-col gap-4">
<div className="flex items-center mb-4">
<img
src={tenantConfig.logo || "/assets/smart-office-logo.svg"}
alt={tenantConfig.appName}
className="h-10 object-contain"
/>
<span className="ml-2 text-xl font-semibold text-gray-800">
{tenantConfig.appName}
</span>
</div>
<div>
<h1 className="text-2xl font-bold mb-1">Create Account</h1>
<p className="text-sm text-gray-500">
Create your account and upload required documents on the right.
</p>
</div>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-4 flex-1 flex flex-col">
<div className="space-y-4">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("email")}
type="email"
placeholder="Email"
className="pl-10 h-12"
/>
</div>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("username")}
type="text"
placeholder="Username"
className="pl-10 h-12"
/>
</div>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("phoneNumber")}
type="tel"
placeholder="Phone Number"
className="pl-10 h-12"
/>
</div>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("name.en")}
type="text"
placeholder="Name (English)"
className="pl-10 h-12"
/>
</div>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
{...form.register("name.am")}
type="text"
placeholder="Name (Amharic)"
className="pl-10 h-12"
/>
</div>
{/* userType control — you may already have this as a select / radio; using plain input for brevity */}
<div>
<label className="text-sm text-gray-600">Account type</label>
<div className="mt-2 flex gap-2">
<label
className={`px-3 py-2 rounded-md border ${
form.getValues("userType") === "external_organization"
? "bg-primary-50 border-primary"
: ""
}`}>
<input
type="radio"
{...form.register("userType")}
value="external_organization"
className="mr-2"
/>
Organization
</label>
<label
className={`px-3 py-2 rounded-md border ${
form.getValues("userType") === "individual"
? "bg-primary-50 border-primary"
: ""
}`}>
<input
type="radio"
{...form.register("userType")}
value="individual"
className="mr-2"
/>
Individual
</label>
</div>
</div>
</div>
<div className="mt-auto pt-4">
<Button
type="submit"
className="w-full h-12 bg-primary hover:bg-primary-700 text-white"
disabled={isRegistering}>
{isRegistering
? "Creating Account..."
: "Create account & submit"}
</Button>
<p className="text-center text-sm text-gray-500 mt-3">
Already have an account?{" "}
<a href="/login" className="text-primary">
Sign in
</a>
</p>
</div>
</form>
</div>
{/* Vertical divider */}
<div className="hidden md:block w-px bg-gray-200" />
{/* Right: Upload document area (changes by userType) */}
<div className="md:w-1/2 w-full p-6 md:p-10">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">
Upload Required Documents
</h3>
<p className="text-sm text-gray-500">
Required documents for:{" "}
<span className="font-medium">{mappedType}</span>
</p>
</div>
<div className="text-sm text-gray-600">
{attachedCount} attached
</div>
</div>
<div className="space-y-3 max-h-[62vh] overflow-auto">
{requirementDoc?.items?.length === 0 && (
<div className="text-sm text-gray-500">
No document requirements for this account type.
</div>
)}
{requirementDoc?.items?.map((doc: Document) => (
<div
key={doc.id}
className="flex items-center justify-between p-3 border rounded-md">
<div className="flex flex-col">
<span className="font-medium">{localized(doc.title)}</span>
{doc.description && (
<span className="text-sm text-muted-foreground">
{localized(doc.description)}
</span>
)}
</div>
<div className="flex gap-2 items-center">
{uploadedFiles[doc.id] && (
<Button
variant="outline"
size="sm"
onClick={() => {
const file = uploadedFiles[doc.id];
if (!file) return;
if (typeof file === "string") {
window.open(file, "_blank");
} else if (file instanceof File) {
const url = URL.createObjectURL(file);
window.open(url, "_blank");
}
}}>
View
</Button>
)}
<Button
size="sm"
className="bg-primary-600"
onClick={() => openUploadModal(doc)}>
{uploadedFiles[doc.id] ? "Replace" : "Upload"}
</Button>
</div>
</div>
))}
</div>
<div className="mt-4 text-sm text-gray-500">
Tip: You can upload files now or complete your account and upload
later.
</div>
</div>
</div>
{/* Upload modal */}
{modalOpen && selectedDoc && (
<UploadFileModal
value={uploadedFiles[selectedDoc.id] || null}
multiple={false}
accept={[
".png",
".jpg",
".jpeg",
".pdf",
".dwg",
".dxf",
".dwt",
".bak",
".sv$",
".dws",
".mxd",
".aprx",
".rar",
".zip",
]}
onChange={(file) => {
if (file === null) handleUploadComplete(null);
else if (file instanceof File) handleUploadComplete(file);
else if (Array.isArray(file) && file[0])
handleUploadComplete(file[0]);
}}
fileUploadFields={{
requiredDocumentId: selectedDoc.id,
// optionally: photoConfiguration etc.
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,339 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchRecordById,
regeneratePdf,
} from "@/record-management/services/api/userRecordService";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import FileList from "@/record-management/common/fileListComponent";
import {
ChevronLeft,
RefreshCw,
Maximize,
Minimize,
Download,
X,
MoveLeft,
} from "lucide-react";
import { attachmentResponse } from "../hooks/useCreateExternalLetterRecord";
import { Button } from "@/shared/common/ui/button";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface ViewExternalRecordProps {
itemId: string;
onBack: () => void;
}
const Box = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 hover:border-primary-200 dark:hover:border-primary-600 transition-colors">
<label className="block text-base font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
{title}
</label>
{children}
</div>
);
const ViewExternalRecord: React.FC<ViewExternalRecordProps> = ({
itemId,
onBack,
}) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [isFullScreen, setIsFullScreen] = useState(false);
const [isLoadingPdf, setIsLoadingPdf] = useState(true);
const { handleError } = useErrorHandler(t);
const { data: attachment, isLoading, error } = attachmentResponse(itemId);
const { data: record, error: recordFetchError } = useQuery({
queryKey: ["record", itemId],
queryFn: () => fetchRecordById(itemId!),
enabled: !!itemId,
});
// Surface main-record fetch failures via the centralized error handler.
// attachment failure stays silent — the existing inline UI already covers it.
const prevRecordErrorRef = useRef<unknown>(null);
useEffect(() => {
if (recordFetchError && recordFetchError !== prevRecordErrorRef.current) {
prevRecordErrorRef.current = recordFetchError;
handleError(recordFetchError);
}
}, [recordFetchError, handleError]);
const attachmentUrl = useMemo(() => {
if (!attachment) return null;
// Handle ArrayBuffer case
if (attachment instanceof ArrayBuffer) {
const blob = new Blob([attachment], { type: "application/pdf" });
return URL.createObjectURL(blob);
}
// Handle presigned URL case
if (typeof attachment === "object" && "presigned" in attachment) {
return attachment.presigned;
}
// Handle direct URL case
if (typeof attachment === "string") {
return attachment.startsWith("http") ? attachment : null;
}
return null;
}, [attachment]);
useEffect(() => {
return () => {
// Clean up blob URLs
if (attachmentUrl && attachmentUrl.startsWith("blob:")) {
URL.revokeObjectURL(attachmentUrl);
}
};
}, [attachmentUrl]);
const { mutate: regeneratePdfMutation, isPending: isRegenerating } =
useMutation({
mutationFn: async () => {
if (!itemId) throw new Error("Record ID is missing");
await regeneratePdf(itemId);
},
onSuccess: () => {
toast.success(t("msg.pdfRegenerated"));
queryClient.invalidateQueries({
queryKey: ["attachmentResponse", itemId],
});
},
onError: (error) => {
handleError(error);
},
});
const handleDownload = () => {
if (!attachmentUrl) return;
const link = document.createElement("a");
link.href = attachmentUrl;
link.download = `document-${itemId}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const openFullScreen = () => {
setIsFullScreen(true);
};
const closeFullScreen = () => {
setIsFullScreen(false);
};
const handleIframeLoad = () => {
setIsLoadingPdf(false);
};
// Handle escape key to exit fullscreen
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && isFullScreen) {
closeFullScreen();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isFullScreen]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">{t("loading")}...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="bg-red-100 rounded-full p-3 w-12 h-12 flex items-center justify-center mx-auto mb-4">
<X className="h-6 w-6 text-red-600" />
</div>
<h3 className="text-lg font-medium text-red-800 mb-2">
{t("error.failedToLoadPdf")}
</h3>
<p className="text-red-600 mb-4">{error.message}</p>
<div className="flex">
<Button
onClick={onBack}
size="sm" // or use className for custom sizing
className="bg-gray-600 text-sm px-1 py-1 w-[100px] hover:bg-gray-700"
>
<MoveLeft className="w-4 h-4" />
{t("common.back")}
</Button>
</div>
</div>
</div>
);
}
return (
<>
{/* Normal View */}
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center mb-6">
<div className="flex">
<Button
onClick={onBack}
size="sm" // or use className for custom sizing
className="bg-gray-600 text-sm px-1 py-1 w-[100px] hover:bg-gray-700"
>
<MoveLeft className="w-4 h-4" />
{t("common.back")}
</Button>
</div>
<div className="flex gap-3">
<button
onClick={() => regeneratePdfMutation()}
disabled={isRegenerating || !attachmentUrl}
className="flex items-center px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<RefreshCw
className={`h-4 w-4 mr-2 ${
isRegenerating ? "animate-spin" : ""
}`}
/>
{t("viewDetail.regeneratePdf")}
</button>
<button
onClick={handleDownload}
disabled={!attachmentUrl}
className="flex items-center px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<Download className="h-4 w-4 mr-2" />
{t("userRecord.Download")}
</button>
<button
onClick={openFullScreen}
disabled={!attachmentUrl}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg shadow-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<Maximize className="h-4 w-4 mr-2" />
{t("viewDetail.fullScreen")}
</button>
</div>
</div>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden">
{attachmentUrl ? (
<div className="relative">
{isLoadingPdf && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-gray-600 dark:text-gray-400 flex items-center gap-2">
<span className="animate-spin rounded-full h-4 w-4 border-t-2 border-primary-600 dark:border-primary-400"></span>
{t("viewDetail.loading")}
</p>
</div>
</div>
)}
<iframe
src={attachmentUrl}
title="PDF Preview"
width="100%"
height="800px"
style={{ border: "none" }}
onLoad={handleIframeLoad}
className={
isLoadingPdf
? "opacity-0"
: "opacity-100 transition-opacity duration-300"
}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-96 bg-gray-50">
<div className="bg-gray-100 rounded-full p-4 w-16 h-16 flex items-center justify-center mb-4">
<X className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{t("noPdfAvailable")}
</h3>
<p className="text-gray-500 text-center max-w-md">
{t("noPdfAvailableDescription") ||
"The PDF document is not available. You can try regenerating it using the button above."}
</p>
</div>
)}
</div>
</div>
</div>
{/* Full Screen Overlay */}
{isFullScreen && attachmentUrl && (
<div className="fixed inset-0 bg-black z-50 flex flex-col">
{/* Header */}
<div className="bg-gray-900 text-white px-6 py-4 flex justify-between items-center">
<div className="flex items-center">
<span className="font-medium">Document Preview</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleDownload}
className="flex items-center px-3 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors duration-200"
title="Download"
>
<Download className="h-4 w-4" />
</button>
<button
onClick={closeFullScreen}
className="flex items-center px-3 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors duration-200"
title="Exit Fullscreen"
>
<Minimize className="h-4 w-4" />
</button>
</div>
</div>
{/* PDF Container */}
<div className="flex-1 bg-gray-800">
<iframe
src={attachmentUrl}
title="PDF Preview - Full Screen"
width="100%"
height="100%"
style={{ border: "none" }}
/>
</div>
{/* Footer */}
<div className="bg-gray-900 text-white px-6 py-3 text-sm text-center">
<p>Press ESC to exit fullscreen mode</p>
</div>
</div>
)}
<div className="mt-3">
<Box title={t("viewDetail.attachments")}>
<FileList
attachments={record?.content[0]?.recordAttachments ?? []}
showDelete={false}
/>
</Box>
</div>
</>
);
};
export default ViewExternalRecord;

View File

@@ -0,0 +1,148 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import { Card, CardHeader, CardContent } from "@/shared/common/ui/card";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import VerifyOtp from "./verifyOTP";
import { Clock } from "lucide-react";
import { useUser } from "@/shared/context/UserContext";
export const LandingPageLayout = () => {
const userDetails = useUser();
const doesUserHaveSetPassword = userDetails?.hasFinishedRegistration;
const shouldBlock = doesUserHaveSetPassword === false;
const shellClasses =
"min-h-screen flex flex-col w-full " +
(shouldBlock ? "blur-sm pointer-events-none select-none" : "");
return (
<>
{/* Main Shell (blurred & non-interactive when blocked) */}
<div className={shellClasses} aria-hidden={shouldBlock}>
{/* Header Section */}
<header className="text-center py-10 border-b border-gray-200 bg-primary-500 ">
<div className="container">
<h1 className="text-4xl font-bold text-primary-foreground mb-4">
Welcome to SmartOffice
</h1>
<p className="text-xl text-primary-foreground/90 max-w-2xl mx-auto mb-8">
Submit, track, and manage letters to organizations seamlessly. Our
system ensures transparency and traceability at every step.
</p>
{!userDetails && (
<div className="flex justify-center gap-4">
<Button asChild variant="secondary">
<Link to="/external-portal/signin">Sign In</Link>
</Button>
<Button asChild>
<Link to="/external-portal/signup">Sign Up</Link>
</Button>
</div>
)}
</div>
</header>
{/* Main Content */}
<main className="flex-grow py-12 px-4">
<section className="container max-w-6xl mx-auto">
<h2 className="text-3xl font-bold text-center text-foreground mb-4">
How It Works
</h2>
<p className="text-lg text-muted-foreground text-center max-w-2xl mx-auto mb-12">
From registration to letter submission and tracking, we've made
the process simple.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="hover:shadow-md transition-shadow">
<CardHeader>
<h3 className="text-xl font-semibold text-primary">
1. Sign Up
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Register your organization or as an individual. Get a
confirmation via text message.
</p>
</CardContent>
</Card>
<Card className="hover:shadow-md transition-shadow">
<CardHeader>
<h3 className="text-xl font-semibold text-primary">
2. Log In
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Access your dashboard and start interacting with the system
securely.
</p>
</CardContent>
</Card>
<Card className="hover:shadow-md transition-shadow hover:cursor-pointer">
<Link to="/external-portal/portal-outgoing/submit-letter">
<CardHeader>
<h3 className="text-xl font-semibold text-primary">
3. Submit Letters
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Select your target organization and submit your letter
directly through the portal.
</p>
</CardContent>
</Link>
</Card>
<Card className="hover:shadow-md transition-shadow hover:cursor-pointer">
<Link to="/external-portal/portal-outgoing">
<CardHeader>
<h3 className="text-xl font-semibold text-primary">
4. Track Activities
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
See your submitted letters and view the activity logs
associated with each.
</p>
</CardContent>
</Link>
</Card>
</div>
</section>
</main>
{/* Footer */}
<footer className="py-6 border-t border-gray-200 text-center text-muted-foreground">
<div className="container">
<p>© 2025 SmartOffice. All rights reserved.</p>
</div>
</footer>
</div>
{shouldBlock && (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center text-center p-6 pointer-events-auto">
<div className="bg-white bg-opacity-90 backdrop-blur-md rounded-xl shadow-xl p-8 max-w-md w-full border border-gray-200">
<Clock className="w-12 h-12 text-yellow-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-red-600 mb-4">
Access Restricted
</h2>
<p className="text-gray-800 text-lg">
Your registration is pending. Please wait for approval from the
SmartOffice administrator.
</p>
</div>
</div>
)}
</>
);
};
export default LandingPageLayout;

View File

@@ -0,0 +1,93 @@
import { z } from "zod";
export const createLetterSchema = z.object({
fileInfo: z.object({
fileName: z.string(),
contentType: z.string(),
size: z.number(),
originalname: z.string(),
}),
subject: z.string().min(1, "Subject is required"),
letterNumber: z.string().min(1, "Letter number is required"),
preferredLanguage: z.enum(["am", "en"], {
required_error: "Preferred language is required",
}),
});
// 👇 If you want TypeScript types derived from the Zod schema:
export type CreateLetterFormValues = z.infer<typeof createLetterSchema>;
export const createComplaintLetterSchema = z.object({
fileInfo: z.object({
fileName: z.string(),
contentType: z.string(),
size: z.number(),
originalname: z.string(),
}),
subject: z.string().min(1, "Subject is required"),
description: z.string().min(1, "Description is required"),
recipient: z.string().optional(),
letterNumber: z.string().optional(),
preferredLanguage: z.enum(["am", "en"], {
required_error: "Preferred language is required",
}),
});
export type CreateComplaintLetterFormValues = z.infer<
typeof createComplaintLetterSchema
>;
export const userSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
username: z
.string()
.min(3, { message: "Username must be at least 3 characters" }),
phoneNumber: z
.string()
.min(10, { message: "Phone number must be at least 10 digits" }),
userType: z.string().min(1, { message: "User type is required" }),
name: z.object({
am: z.string().min(1, { message: "Amharic name is required" }),
en: z.string().min(1, { message: "English name is required" }),
}),
});
export type UserFormValues = z.infer<typeof userSchema>;
export const defaultCreateLetterValues: CreateLetterFormValues = {
fileInfo: {
fileName: "",
contentType: "",
size: 0,
originalname: "",
},
subject: "",
letterNumber: "",
preferredLanguage: "en",
};
export const defaultCreateComplaintLetterValues: CreateComplaintLetterFormValues =
{
fileInfo: {
fileName: "",
contentType: "",
size: 0,
originalname: "",
},
subject: "",
description: "",
recipient: "",
letterNumber: "",
preferredLanguage: "en",
};
export const defaultUserValues: UserFormValues = {
email: "",
username: "",
phoneNumber: "",
userType: "",
name: {
am: "",
en: "",
},
};

View File

@@ -0,0 +1,207 @@
import { useState, useMemo, useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { DataTable } from "@/record-management/common/DataTable";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { FilePlus2, Search } from "lucide-react";
import { t } from "i18next";
import { portalOutgoingColumn } from "./portalOutgoingColumn";
import { useExternalPortal } from "@/external-portal/hooks/useCreateExternalLetterRecord";
import Loader from "@/record-management/common/loader";
import { useUser } from "@/shared/context/UserContext";
import { useLocalizedName } from "@/shared/common/localizedName";
import { hasComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
import { COMPLAINT_SUBMIT_PATH } from "@/complaints/utils/complaintRoutes";
type FilterType = "Reference" | "From" | "Subject" | "letterNumber";
const PortalOutgoing = () => {
const [statusFilter, setStatusFilter] = useState<string>("");
const [searchQuery, setSearchQuery] = useState("");
const [showRegistrationAlert, setShowRegistrationAlert] = useState(false);
const userDetails = useUser();
const localizedName = useLocalizedName();
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
const navigate = useNavigate();
const location = useLocation();
const complaintSessionActive = hasComplaintVerification();
const showComplaintPrompt =
complaintSessionActive ||
Boolean(
(location.state as { fromComplaintVerification?: boolean } | null)
?.fromComplaintVerification,
);
useEffect(() => {
if (!hasCompletedRegistration && !complaintSessionActive) {
setShowRegistrationAlert(true);
} else {
setShowRegistrationAlert(false);
}
}, [complaintSessionActive, hasCompletedRegistration]);
const [filterType, setFilterType] = useState<FilterType>("Reference");
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const params: Record<string, any> = {
skip: pagination.pageIndex * pagination.pageSize,
take: pagination.pageSize,
orderBy: "record.createdAt:DESC",
};
const {
ExternalLetters,
isFetchingLetters,
ExternalCount,
refetchExternalLetters,
} = useExternalPortal(params);
const statusOptions = [{ value: "", label: t("statusBar.All") }];
const filteredRecords = useMemo(() => {
let filtered = ExternalLetters;
if (statusFilter) {
filtered = filtered.filter(
(record: any) => record.status === statusFilter,
);
}
if (!searchQuery) return filtered;
const query = searchQuery.toLowerCase();
return filtered.filter((record: any) => {
switch (filterType) {
case "Reference":
return record.letterNumber?.toLowerCase().includes(query);
case "Subject":
return record.content[0]?.subject.toLowerCase().includes(query);
default:
return true;
}
});
}, [ExternalLetters, statusFilter, searchQuery, filterType]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const handleFilterChange = (value: FilterType) => {
setFilterType(value);
};
return (
<div className="min-h-screen w-full bg-slate-50 py-6 md:py-8">
<div className="mx-auto max-w-6xl px-4 md:px-6">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
{t("nav.myRecord")}
</h1>
<p className="mt-1 text-sm text-slate-600">
{t("complaint.fayda.formSubtitle")}
</p>
</div>
<Link to={COMPLAINT_SUBMIT_PATH}>
<Button className="h-11 rounded-lg px-5 shadow-sm bg-primary text-primary-foreground hover:bg-primary/90">
<FilePlus2 className="mr-2 h-4 w-4" />
{complaintSessionActive
? t("complaint.submit")
: t("userRecord.Add Record")}
</Button>
</Link>
</div>
{showComplaintPrompt ? (
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
{t("complaint.choice.prompt")}{" "}
<Link
to={COMPLAINT_SUBMIT_PATH}
className="font-semibold underline underline-offset-2">
{t("complaint.submit")}
</Link>
</div>
) : null}
{showRegistrationAlert ? (
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{t("registration.registrationRequired")}{" "}
<button
type="button"
className="font-semibold underline underline-offset-2"
onClick={() => navigate("/external-portal/upload-documents")}>
{t("registration.uploadDocuments")}
</button>
</div>
) : null}
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:p-5">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
{statusOptions.map((option) => (
<Button
key={option.value}
variant={statusFilter === option.value ? "linkActive" : "link"}
onClick={() => setStatusFilter(option.value)}
className="rounded-md">
{option.label}
</Button>
))}
</div>
<div className="flex w-full flex-col gap-3 sm:flex-row lg:w-auto">
<Select value={filterType} onValueChange={handleFilterChange}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder={t("userRecord.Filter by")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Subject">
{t("userRecord.Subject")}
</SelectItem>
<SelectItem value="Reference">
{t("userRecord.Letter Number")}
</SelectItem>
</SelectContent>
</Select>
<div className="relative w-full sm:min-w-[240px]">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="pl-9"
type="text"
placeholder={`${t("userRecord.Search")} ${filterType.toLowerCase()}`}
value={searchQuery}
onChange={handleSearchChange}
/>
</div>
</div>
</div>
{isFetchingLetters ? <Loader /> : null}
<DataTable
tableName="External Records"
columns={portalOutgoingColumn(
localizedName as (name?: { am?: string; en?: string }) => string,
)}
data={filteredRecords}
toolBarPosition="right"
totalCount={ExternalCount}
pagination={pagination}
setPagination={setPagination}
refresh={refetchExternalLetters}
/>
</div>
</div>
</div>
);
};
export default PortalOutgoing;

View File

@@ -0,0 +1,111 @@
import { ColumnDef } from "@tanstack/react-table";
import { IncomingRecordDto } from "@/record-management/dto/userRecords/userRecordsDto";
import { Badge } from "@/shared/common/ui/badge";
import { Button } from "@/shared/common/ui/button";
import { Download, Eye, MoreHorizontal, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { LetterRecordDto } from "@/shared/dto/External-Portal/External-PortalDto";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { renderStatus } from "@/record-management/utils/renderDetails";
export const portalOutgoingColumn = (
localizedName: (name?: { am?: string; en?: string }) => string
): ColumnDef<LetterRecordDto>[] => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t, i18n } = useTranslation();
// eslint-disable-next-line react-hooks/rules-of-hooks
const navigate = useNavigate();
const handleViewRecord = (recordId: string) => {
navigate(`/external-portal/view/${recordId}`);
};
return [
{
accessorKey: "referenceNumber",
header: t("userRecord.Letter Number"),
cell: ({ row }) => (
<div className="font-medium">{row?.original?.letterNumber}</div>
),
},
{
accessorKey: "subject",
header: t("userRecord.Subject"),
cell: ({ row }) => {
const subject = row.original.content[0]?.subject || "-";
return <div className="text-sm text-gray-700">{subject}</div>;
},
},
{
accessorKey: "Receiver",
header: t("userRecord.ReceivingUnit"),
cell: ({ row }) => {
const receivingUnit =
localizedName(row.original.receivingUnits?.[0]?.unit?.name) || "-";
return <div className="text-sm text-gray-700">{receivingUnit}</div>;
},
},
{
accessorKey: "status",
header: t("userRecord.Status"),
cell: ({ row }) => {
const status = row?.original?.statusKey;
return renderStatus(status, status);
},
},
{
accessorKey: "Date",
header: t("userRecord.Date Received"),
cell: ({ row }) => {
const lang = i18n.language;
const dispatchedDate = row.original.createdAt;
const amharicDate = row.original.amharicCreatedAt;
if (lang.startsWith("am")) {
return amharicDate ?? "-";
} else {
const date = new Date(dispatchedDate).toLocaleDateString(
i18n.language,
{
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
}
);
return date ?? "-";
}
},
},
{
id: "actions",
header: t("userIncoming.Actions"),
cell: ({ row }) => {
const record = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewRecord(record.id)}>
<Eye className="h-4 w-4 mr-2" />
{t("userRecord.View")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
};

View File

@@ -0,0 +1,601 @@
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useForm, type Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/shared/common/ui/form";
import { ReusableFileUploader } from "@/shared/common/ui/fileUploader/reusableFileUploader";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
createLetterSchema,
createComplaintLetterSchema,
defaultCreateLetterValues,
defaultCreateComplaintLetterValues,
CreateLetterFormValues,
} from "./formSchema";
import { useExternalPortal } from "@/external-portal/hooks/useCreateExternalLetterRecord";
import { useNavigate } from "react-router-dom";
import { recordFileService } from "@/record-management/services/api/recordFileService";
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
import { Label } from "@/shared/common/ui/label";
import { motion } from "framer-motion";
import { useUser } from "@/shared/context/UserContext";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { Textarea } from "@/shared/common/ui/textarea";
import { ComplaintVerifiedInfoPanel } from "@/complaints/components/ComplaintVerifiedInfoPanel";
import {
clearComplaintVerification,
getComplaintVerification,
} from "@/complaints/utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "@/complaints/utils/complaintRoutes";
import {
unitConfigurationService,
type CanReceiveComplaintResponse,
} from "@/shared/services/unitConfigurationService";
import {
getExternalPortalReceivingUnitName,
resolveExternalPortalReceivingUnitIds,
} from "@/external-portal/config/receivingOrganization";
import {
PortalFieldLabel,
PortalFormHeader,
PortalFormSection,
PortalReadOnlyField,
} from "../shared/PortalFormPrimitives";
import { ArrowLeft, Building2, Send } from "lucide-react";
interface AddIncomingRecordPortalFormProps {
onSuccess?: () => void;
onCancel?: () => void;
}
const SubmitExternalRecordForm = ({
onSuccess,
onCancel,
}: AddIncomingRecordPortalFormProps) => {
const { t, i18n } = useTranslation();
const complaintSession = getComplaintVerification();
const isComplaintMode = complaintSession !== null;
const hasAuthToken = Boolean(Cookies.get("auth-token"));
const [canReceiveComplaintConfig, setCanReceiveComplaintConfig] =
useState<CanReceiveComplaintResponse | null>(null);
const [isLoadingCanReceiveComplaintConfig, setIsLoadingCanReceiveComplaintConfig] =
useState(false);
type FormValues = CreateLetterFormValues & {
description?: string;
recipient?: string;
};
const form = useForm<FormValues>({
resolver: zodResolver(
(isComplaintMode
? createComplaintLetterSchema
: createLetterSchema) as typeof createLetterSchema,
) as Resolver<FormValues>,
defaultValues: (isComplaintMode
? defaultCreateComplaintLetterValues
: defaultCreateLetterValues) as FormValues,
mode: "onChange",
});
const [showRegistrationAlert, setShowRegistrationAlert] = useState(false);
const userDetails = useUser();
const { handleError } = useErrorHandler(t);
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
const { createExternalPortalLetter, isSending } = useExternalPortal();
const [files, setFiles] = useState<File[]>([]);
const [mainFileIndex, setMainFileIndex] = useState<number | null>(null);
const navigate = useNavigate();
const handleCancel = onCancel ?? (() => navigate(COMPLAINT_RECORDS_PATH));
const complaintReceivingUnitId = resolveExternalPortalReceivingUnitIds()[0];
const receivingOrganizationName = getExternalPortalReceivingUnitName(
i18n.language,
);
useEffect(() => {
if (!hasCompletedRegistration && !isComplaintMode) {
setShowRegistrationAlert(true);
} else {
setShowRegistrationAlert(false);
}
}, [hasCompletedRegistration, isComplaintMode]);
// Complaint receiving unit is the configured target unit (not the submitter's unit).
useEffect(() => {
if (!isComplaintMode) return;
if (!hasAuthToken) return;
if (!complaintReceivingUnitId) return;
let isCancelled = false;
const load = async () => {
try {
setIsLoadingCanReceiveComplaintConfig(true);
const res = await unitConfigurationService.getCanReceiveComplaint(
complaintReceivingUnitId,
);
if (isCancelled) return;
setCanReceiveComplaintConfig(res.data);
} catch (err) {
if (isCancelled) return;
setCanReceiveComplaintConfig(null);
toast.error(
t(
"complaint.receiveCheckFailed",
"Could not verify complaint receiving for this unit. Please try again.",
),
);
} finally {
if (!isCancelled) {
setIsLoadingCanReceiveComplaintConfig(false);
}
}
};
load();
return () => {
isCancelled = true;
};
}, [complaintReceivingUnitId, hasAuthToken, isComplaintMode, t]);
useEffect(() => {
if (!isComplaintMode) return;
if (!hasAuthToken) return;
if (!canReceiveComplaintConfig) return;
if (canReceiveComplaintConfig.canReceiveComplaint === false) {
toast.error(
t(
"complaint.receiveDisabled",
"Complaint receiving is disabled for this unit.",
),
);
navigate(COMPLAINT_RECORDS_PATH);
}
}, [
canReceiveComplaintConfig,
hasAuthToken,
isComplaintMode,
navigate,
t,
]);
useEffect(() => {
if (files.length === 1) {
setMainFileIndex(0);
}
}, [files]);
const buildComplaintSubject = (
subject: string,
recipient?: string,
description?: string,
) => {
let composed = subject.trim();
if (recipient?.trim()) {
composed = `[To: ${recipient.trim()}] ${composed}`;
}
if (description?.trim()) {
composed = `${composed}\n\n${description.trim()}`;
}
return composed;
};
const isAllowedMainFile = (file: File) => {
if (!isComplaintMode) {
return file.type === "application/pdf";
}
return (
file.type === "application/pdf" ||
file.type === "image/jpeg" ||
file.type === "image/png"
);
};
const onSubmit = async (values: FormValues) => {
if (files.length === 0) {
toast.error(t("userRecord.Please select file"));
return;
}
if (mainFileIndex === null) {
toast.error(t("Please select the main file for the record"));
return;
}
const mainFile = files[mainFileIndex];
const fileInfo = {
fileName: mainFile.name,
contentType: mainFile.type,
size: mainFile.size,
originalname: mainFile.name,
};
const unitIds = isComplaintMode
? [complaintReceivingUnitId]
: resolveExternalPortalReceivingUnitIds();
if (isComplaintMode) {
if (isLoadingCanReceiveComplaintConfig) {
toast.error(
t(
"complaint.receiveCheckInProgress",
"Still verifying complaint receiving for this unit. Please wait.",
),
);
return;
}
if (!canReceiveComplaintConfig) {
toast.error(
t(
"complaint.receiveCheckFailed",
"Could not verify complaint receiving for this unit. Please try again.",
),
);
return;
}
if (canReceiveComplaintConfig.canReceiveComplaint === false) {
toast.error(
t(
"complaint.receiveDisabled",
"Complaint receiving is disabled for this unit.",
),
);
return;
}
}
if (!complaintReceivingUnitId && isComplaintMode) {
toast.error(
t(
"complaint.receiveCheckFailed",
"Could not verify complaint receiving for this unit. Please try again.",
),
);
return;
}
if (!isAllowedMainFile(mainFile)) {
toast.error(
isComplaintMode
? t("complaint.fayda.uploadHint")
: t("userRecord.The main file must be a PDF document"),
);
return;
}
const letterNumber =
values.letterNumber?.trim() ||
(isComplaintMode ? `CMP-${Date.now()}` : "");
if (!letterNumber) {
toast.error(t("userRecord.Letter Number"));
return;
}
const subject = isComplaintMode
? buildComplaintSubject(
values.subject,
values.recipient,
values.description,
)
: values.subject;
try {
const recordResult = await createExternalPortalLetter(
{
...values,
subject,
fileInfo,
unitIds,
letterNumber,
},
mainFile,
);
const contentId = recordResult.contentId;
if (!contentId) {
throw new Error("Missing contentId from created record");
}
form.reset();
setFiles([]);
setMainFileIndex(null);
const attachments = files.filter((_, idx) => idx !== mainFileIndex);
if (attachments.length > 0) {
try {
await Promise.all(
attachments.map(async (attachment) => {
const uploadMetaResult = await recordFileService.uploadMeta(
attachment,
attachment.name,
contentId,
);
await recordFileService.uploadFile(
attachment,
uploadMetaResult.presigned,
);
await recordFileService.updateStatus(
uploadMetaResult.id,
contentId,
true,
);
}),
);
} catch (error: unknown) {
handleError(error);
}
}
toast.success(
isComplaintMode
? t("complaint.success")
: t("externalPortal.letterSentSuccess"),
);
if (isComplaintMode) {
clearComplaintVerification();
}
onSuccess?.();
navigate(COMPLAINT_RECORDS_PATH);
} catch (error) {
await handleError(error);
}
};
const handleGoToDocuments = () => {
navigate("/external-portal/upload-documents");
setShowRegistrationAlert(false);
};
return (
<>
{showRegistrationAlert && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-yellow-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
You haven't completed the verification process. Please upload
required documents to continue.
</p>
</div>
</div>
<div className="ml-4 flex-shrink-0">
<button
onClick={handleGoToDocuments}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500">
Upload Documents
</button>
</div>
</div>
</motion.div>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
aria-labelledby="form-title">
{isComplaintMode ? (
<button
type="button"
onClick={() => navigate(COMPLAINT_RECORDS_PATH)}
className="inline-flex items-center gap-2 text-sm font-medium text-slate-600 transition-colors hover:text-primary"
>
<ArrowLeft className="h-4 w-4" />
{t("complaint.fayda.backToMyRecords")}
</button>
) : null}
<PortalFormHeader
title={
isComplaintMode
? t("complaint.formTitle")
: t("userIncoming.uploadIncomingRecord")
}
subtitle={
isComplaintMode ? t("complaint.fayda.formSubtitle") : undefined
}
/>
{complaintSession ? (
<ComplaintVerifiedInfoPanel session={complaintSession} />
) : null}
<PortalFormSection className="w-full min-w-0 max-w-full space-y-5 overflow-visible">
{!isComplaintMode ? (
<PortalReadOnlyField
label={t("userIncoming.Receiving Organization")}
value={receivingOrganizationName}
icon={Building2}
/>
) : null}
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel asChild>
<PortalFieldLabel required>
{t("userIncoming.Subject")}
</PortalFieldLabel>
</FormLabel>
<FormControl>
<Input
placeholder={t("userIncoming.Subject")}
className="h-11 rounded-lg border-slate-200 focus-visible:ring-primary/20"
{...field}
disabled={isSending}
aria-required="true"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isComplaintMode ? (
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel asChild>
<PortalFieldLabel required>
{t("complaint.fayda.description")}
</PortalFieldLabel>
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"complaint.fayda.descriptionPlaceholder",
)}
className="min-h-[160px] resize-none rounded-lg border-slate-200 focus-visible:ring-primary/20"
{...field}
disabled={isSending}
aria-required="true"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : null}
{!isComplaintMode ? (
<FormField
control={form.control}
name="letterNumber"
render={({ field }) => (
<FormItem>
<FormLabel asChild>
<PortalFieldLabel required>
{t("userRecord.Letter Number")}
</PortalFieldLabel>
</FormLabel>
<FormControl>
<Input
placeholder={t("userRecord.Letter Number")}
className="h-11 rounded-lg border-slate-200 focus-visible:ring-primary/20"
{...field}
disabled={isSending}
aria-required="true"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : null}
<div>
<PortalFieldLabel required>
{isComplaintMode
? t("complaint.fayda.attachment")
: t("userIncoming.Attachments")}
</PortalFieldLabel>
<div className="mt-2 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-2 transition-colors hover:border-primary/40">
<ReusableFileUploader
files={files}
setFiles={setFiles}
isLoading={isSending}
/>
</div>
</div>
{files.length > 0 && (
<div>
<PortalFieldLabel required>
{t("Select the main file for the record")}
</PortalFieldLabel>
<RadioGroup
className="mt-2"
value={mainFileIndex?.toString() || ""}
onValueChange={(value) => setMainFileIndex(Number(value))}
aria-required="true">
{files.map((file, index) => (
<div key={file.name} className="flex items-center space-x-2">
<RadioGroupItem
value={index.toString()}
id={`main-file-${index}`}
/>
<Label htmlFor={`main-file-${index}`}>{file.name}</Label>
</div>
))}
</RadioGroup>
</div>
)}
</PortalFormSection>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-500">{t("common.requiredFields")}</p>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSending}
className="h-11 min-w-[8rem] rounded-lg">
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={
!form.formState.isValid ||
isSending ||
files.length === 0 ||
(isComplaintMode &&
(isLoadingCanReceiveComplaintConfig ||
canReceiveComplaintConfig?.canReceiveComplaint !== true))
}
className="h-11 min-w-[10rem] rounded-lg bg-primary hover:bg-primary/90">
{isSending ? (
t("userIncoming.Processing...")
) : (
<span className="inline-flex items-center gap-2">
{isComplaintMode ? t("complaint.submit") : t("common.Submit")}
{isComplaintMode ? <Send className="h-4 w-4" /> : null}
</span>
)}
</Button>
</div>
</div>
</form>
</Form>
</>
);
};
export default SubmitExternalRecordForm;

View File

@@ -0,0 +1,104 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/common/ui/fileUploader/utils";
export function PortalFormPage({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("min-h-screen bg-slate-50 py-8 md:py-10", className)}>
<div className="mx-auto w-full max-w-3xl px-4 md:px-6">{children}</div>
</div>
);
}
export function PortalFormHeader({
title,
subtitle,
}: {
title: string;
subtitle?: string;
}) {
return (
<div className="mb-8 text-center">
<h1
id="form-title"
className="text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
{title}
</h1>
{subtitle ? (
<p className="mt-2 text-sm text-slate-600 md:text-base">{subtitle}</p>
) : null}
</div>
);
}
export function PortalFormSection({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section
className={cn(
"rounded-xl border border-slate-200 bg-white p-5 shadow-sm md:p-6",
className,
)}>
{children}
</section>
);
}
export function PortalFieldLabel({
children,
required,
}: {
children: ReactNode;
required?: boolean;
}) {
return (
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{children}
{required ? <span className="ml-0.5 text-red-600">*</span> : null}
</span>
);
}
export function PortalReadOnlyField({
label,
value,
icon: Icon,
mono = false,
}: {
label: string;
value?: string;
icon?: LucideIcon;
mono?: boolean;
}) {
if (!value) return null;
return (
<div className="space-y-1.5">
<PortalFieldLabel>{label}</PortalFieldLabel>
<div className="relative">
{Icon ? (
<Icon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
) : null}
<div
className={cn(
"rounded-lg border border-slate-200 bg-white py-2.5 text-sm text-slate-800",
Icon ? "pl-10 pr-3" : "px-3",
mono && "font-mono",
)}>
{value}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Lock, User, Mail, Phone, Globe, Building, Text } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { userSchema, UserFormValues } from "./outgoing/formSchema";
import { useRegisterExternalPortalUser } from "../hooks/useRegisterExternalPortalUser";
import { useTenantConfig } from "@/layout/components/TenantConfig";
const Signup = () => {
const navigate = useNavigate();
const { config: tenantConfig } = useTenantConfig();
const { registerExternalPortalUser, isRegistering } =
useRegisterExternalPortalUser();
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
username: "",
phoneNumber: "",
userType: "external_organization",
name: {
am: "",
en: "",
},
},
mode: "onChange",
});
const onSubmit = async (values: UserFormValues) => {
try {
await registerExternalPortalUser(values);
const params = new URLSearchParams();
if (values.email) params.set("email", values.email.trim());
if (values.phoneNumber) {
const normalizedPhone = values.phoneNumber.trim().replace(/^0/, "+251");
params.set("phone", normalizedPhone);
}
navigate(`/external-portal/verify-otp?${params.toString()}`);
} catch (error) {
console.error("Signup error:", error);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
<div className="w-full max-w-6xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col md:flex-row h-full max-h-[800px]">
{/* Left Panel */}
<div className="md:w-1/2 w-full p-8 md:p-12 flex flex-col">
<div className="flex items-center mb-8">
<img
src={tenantConfig.logo || "/assets/smart-office-logo.svg"}
alt={tenantConfig.appName}
className="h-10 object-contain"
/>
<span className="ml-2 text-xl font-semibold text-gray-800">
{tenantConfig.appName}
</span>
</div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Create Organization Account
</h1>
<p className="text-gray-500">
Streamline your workflow with our paperless solution
</p>
</div>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 flex-1 flex flex-col"
>
<div className="space-y-4">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="email"
placeholder="Organization Email"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("email")}
/>
</div>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="text"
placeholder="Organization Username"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("username")}
/>
</div>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="tel"
placeholder="Organization Phone Number"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("phoneNumber")}
/>
</div>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="text"
placeholder="Organization Name (English)"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("name.en")}
/>
</div>
<div className="relative">
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
type="text"
placeholder="Organization Name (Amharic)"
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
{...form.register("name.am")}
/>
</div>
</div>
<div className="mt-auto pt-4">
<Button
type="submit"
className="w-full h-12 bg-primary hover:bg-primary-700 text-white text-base font-medium rounded-lg transition-all duration-300 shadow-md hover:shadow-lg"
disabled={isRegistering}
>
{isRegistering ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Creating Account...
</span>
) : (
"Sign Up"
)}
</Button>
<p className="text-center text-sm text-gray-500 mt-4">
Already have an account?{" "}
<a
href="/login"
className="text-primary hover:underline font-medium"
>
Sign in
</a>
</p>
</div>
</form>
</div>
{/* Right Panel */}
<div className="md:w-1/2 bg-gradient-to-br from-primary to-primary-300 text-white p-10 hidden md:flex flex-col">
<div className="mb-8">
<h2 className="text-3xl font-bold mb-4">
Transform Your Office Experience
</h2>
<p className="text-lg opacity-90">
Join thousands of organizations that have gone paperless with our
smart solutions.
</p>
</div>
<div className="relative flex-1 flex items-center justify-center">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-64 h-64 rounded-full bg-white/10 blur-xl"></div>
</div>
<div className="relative z-10 w-full max-w-md">
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20 shadow-xl">
<div className="flex items-center mb-4">
<Building className="w-8 h-8 mr-3" />
<h3 className="text-xl font-semibold">
Organization Dashboard
</h3>
</div>
<div className="space-y-3">
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-primary-400 mr-2"></div>
<span>Real-time document management</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-blue-400 mr-2"></div>
<span>Secure cloud storage</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-purple-400 mr-2"></div>
<span>Automated workflow</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 rounded-full bg-yellow-400 mr-2"></div>
<span>Multi-language support</span>
</div>
</div>
</div>
<div className="mt-8 relative">
<img
src="/assets/MainDashboard.png"
alt="Dashboard Preview"
className="w-full rounded-xl shadow-2xl border-4 border-white/20"
/>
<div className="absolute -bottom-4 -right-4 bg-white p-2 rounded-lg shadow-lg">
<img
src="/assets/StatsCard.png"
alt="Stats Preview"
className="w-24 h-24 rounded-md"
/>
</div>
</div>
</div>
</div>
<div className="mt-auto pt-6 text-center text-sm opacity-80">
<p>Trusted by 500+ organizations worldwide</p>
</div>
</div>
</div>
</div>
);
};
export default Signup;

View File

@@ -0,0 +1,468 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams, useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import {
ShieldCheck,
ArrowLeft,
RefreshCcw,
EyeOff,
Eye,
Lock,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { cn } from "@/shared/lib/utils";
import { GenerateVerifcationCodePayload } from "@/shared/services/authService";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useTradeLicenseVerification } from "../hooks/useTradeLicenseVerification";
import { getEtradeLicenseNo } from "../utils/etradeAuthStorage";
interface VerifyOtpProps {
email?: string;
phone?: string;
onComplete?: () => void;
isExternalOrg?: boolean;
userId?: string;
isEtradeVerification?: boolean;
licenseNo?: string;
}
const OTP_LENGTH = 6;
const RESEND_COOLDOWN_SECONDS = 30;
const VerifyOtp: React.FC<VerifyOtpProps> = ({
email,
phone,
onComplete,
isExternalOrg,
userId: propUserId,
isEtradeVerification,
licenseNo,
}: VerifyOtpProps) => {
const [searchParams] = useSearchParams();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [otpValues, setOtpValues] = useState<string[]>(
Array(OTP_LENGTH).fill("")
);
const userID = propUserId || searchParams.get("userId") || "";
const resolvedLicenseNo =
licenseNo?.trim() ||
searchParams.get("licenseNo")?.trim() ||
getEtradeLicenseNo() ||
"";
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [error, setError] = useState("");
const [resendLoading, setResendLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [cooldown, setCooldown] = useState(0);
const navigate = useNavigate();
const { t } = useTranslation();
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const [hasNavigated, setHasNavigated] = useState(false);
const requersOtp =
isExternalOrg || searchParams.get("isExternalOrg") === "true";
const {
setPassword,
setFayidaPassword,
resendOtpCode,
setPasswordSuccess,
setPasswordError,
} = useAuthUser();
const { verifyEtradeOtp, isVerifyingOtp } = useTradeLicenseVerification();
useEffect(() => {
inputsRef.current[0]?.focus();
}, []);
useEffect(() => {
if (cooldown <= 0) return;
const t = setInterval(() => setCooldown((s) => (s > 0 ? s - 1 : 0)), 1000);
return () => clearInterval(t);
}, [cooldown]);
useEffect(() => {
if (setPasswordSuccess && !setPasswordError?.message && !hasNavigated) {
setMessage({
type: "success",
text: t("registration.verifyOtp.messages.success"),
});
setHasNavigated(true);
onComplete?.();
navigate("/verification_page");
}
}, [setPasswordSuccess, setPasswordError]);
useEffect(() => {
if (setPasswordError?.message) {
setMessage({
type: "error",
text: setPasswordError.message.includes("OTP")
? setPasswordError.message
: t("registration.verifyOtp.messages.passwordUpdateFailed"),
});
}
}, [setPasswordError]);
const focusInput = (idx: number) => {
inputsRef.current[idx]?.focus();
inputsRef.current[idx]?.select?.();
};
const handleOtpChange = (idx: number, value: string) => {
const char = value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 1);
if (!char) return;
const updated = [...otpValues];
updated[idx] = char;
setOtpValues(updated);
if (idx < OTP_LENGTH - 1) {
setTimeout(() => focusInput(idx + 1), 10); // give DOM time to settle
}
};
const handleOtpKeyDown = (
idx: number,
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "Backspace") {
if (otpValues[idx]) {
setOtpValues((prev) => {
const next = [...prev];
next[idx] = "";
return next;
});
} else if (idx > 0) {
focusInput(idx - 1);
}
} else if (e.key === "ArrowLeft" && idx > 0) {
focusInput(idx - 1);
} else if (e.key === "ArrowRight" && idx < OTP_LENGTH - 1) {
focusInput(idx + 1);
}
};
const handleOtpPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pasted = e.clipboardData.getData("text").replace(/\D/g, "");
if (!pasted) return;
e.preventDefault();
setOtpValues(
Array(OTP_LENGTH)
.fill("")
.map((_, i) => pasted[i] ?? "")
);
const lastIndex = Math.min(pasted.length, OTP_LENGTH) - 1;
focusInput(lastIndex >= 0 ? lastIndex : 0);
};
const handleResend = async () => {
if (!email && !phone) {
setMessage({
type: "error",
text: t("registration.verifyOtp.messages.missingEmailPhone"),
});
toast.error(t("registration.verifyOtp.errors.missingUserId"));
return;
}
setResendLoading(true);
try {
const payload = {
email: email || "",
phoneNumber: phone || "",
} satisfies GenerateVerifcationCodePayload;
await resendOtpCode(payload);
setMessage({
type: "success",
text: t("registration.verifyOtp.messages.verificationCodeResent"),
});
setCooldown(RESEND_COOLDOWN_SECONDS);
} catch (err) {
setMessage({
type: "error",
text: t("registration.verifyOtp.messages.resendFailed"),
});
toast.error(t("registration.verifyOtp.errors.resendFailed"));
} finally {
setResendLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError("");
setMessage(null);
if (!isEtradeVerification) {
if (!newPassword || !confirmPassword) {
setError(t("registration.verifyOtp.messages.fillPasswords"));
setIsSubmitting(false);
return;
}
if (newPassword.length < 8) {
setError(t("registration.verifyOtp.messages.passwordLength"));
setIsSubmitting(false);
return;
}
if (newPassword !== confirmPassword) {
setError(t("registration.verifyOtp.messages.passwordMismatch"));
setIsSubmitting(false);
return;
}
}
if (requersOtp) {
const code = otpValues.join("");
if (isEtradeVerification) {
if (!resolvedLicenseNo) {
setError(t("registration.etrade.licenseNoRequired"));
setIsSubmitting(false);
return;
}
if (code.length !== OTP_LENGTH || !/^[a-zA-Z0-9]{6}$/.test(code)) {
setError(t("registration.verifyOtp.messages.invalidOtp"));
setIsSubmitting(false);
return;
}
try {
await verifyEtradeOtp({
licenseNo: resolvedLicenseNo,
otp: code,
});
onComplete?.();
navigate("/login");
} catch (err) {
setError(t("registration.verifyOtp.messages.invalidOtp"));
}
setIsSubmitting(false);
return;
}
if (code.length !== OTP_LENGTH || !/^[a-zA-Z0-9]{6}$/.test(code)) {
setError(t("registration.verifyOtp.messages.invalidOtp"));
setIsSubmitting(false);
return;
}
if (!email && !phone) {
setError(t("registration.verifyOtp.messages.missingEmailPhone"));
setIsSubmitting(false);
return;
}
const passwordPayload: any = {
email: email || "",
verificationCode: code,
newPassword,
confirmPassword,
};
if (userID) {
passwordPayload.userId = userID;
}
await setPassword(passwordPayload);
} else {
await setFayidaPassword({
userId: userID,
newPassword,
confirmPassword,
});
navigate("/login");
}
setIsSubmitting(false);
};
const pageFade = { initial: { opacity: 0 }, animate: { opacity: 1 } };
return (
<motion.div
{...pageFade}
className="min-h-screen w-full bg-gradient-to-b from-background to-muted/30 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-xl border-0">
<CardHeader className="text-center">
<img
src="/assets/smart-office-logo.svg"
alt="Smart Office Logo"
className="h-10 mx-auto mb-2"
/>
<h1 className="text-2xl font-bold">
{t("registration.verifyOtp.title")}
</h1>
<p className="text-sm text-muted-foreground">
{isEtradeVerification
? t("registration.etrade.otpVerificationDescription")
: t("registration.verifyOtp.description", {
channel: email
? t("registration.verifyOtp.channel.email")
: t("registration.verifyOtp.channel.phone"),
})}
</p>
</CardHeader>
<CardContent className="space-y-6">
{requersOtp && (
<div className="flex justify-center gap-2">
{Array.from({ length: OTP_LENGTH }).map((_, idx) => (
<Input
key={idx}
ref={(el) => {
inputsRef.current[idx] = el;
}}
inputMode="text"
pattern="[a-zA-Z0-9]{1}"
maxLength={1}
value={otpValues[idx]}
onChange={(e) => handleOtpChange(idx, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(idx, e)}
onPaste={handleOtpPaste}
className="w-10 h-12 text-center text-xl tracking-widest"
aria-label={t("registration.verifyOtp.otpInput.ariaLabel", {
index: idx + 1,
})}
/>
))}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{!isEtradeVerification && (
<>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<Input
type={showPassword ? "text" : "password"}
placeholder={t("registration.verifyOtp.password.newPassword")}
className="h-10 rounded-md border px-4 text-sm ps-10"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
<div
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
onClick={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<Input
type={showConfirmPassword ? "text" : "password"}
placeholder={t(
"registration.verifyOtp.password.confirmPassword"
)}
className="h-10 rounded-md border px-4 text-sm ps-10"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
<div
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
</>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex flex-col gap-2">
<Button
type="submit"
className="w-full"
disabled={isSubmitting || isVerifyingOtp}>
{isSubmitting || isVerifyingOtp
? t("registration.verifyOtp.buttons.verifying")
: isEtradeVerification
? t("registration.verifyOtp.buttons.verifyOtp")
: t("registration.verifyOtp.buttons.completeRegistration")}
</Button>
</div>
</form>
{!isEtradeVerification && (
<Button
variant="outline"
className="w-full"
onClick={() => void handleResend()}
disabled={resendLoading || cooldown > 0}
aria-label={
resendLoading
? t("registration.verifyOtp.buttons.resendLoading")
: cooldown > 0
? t("registration.verifyOtp.buttons.resendCooldown", {
seconds: cooldown,
})
: t("registration.verifyOtp.buttons.resendCode")
}>
{resendLoading ? (
<span className="inline-flex items-center gap-2">
<RefreshCcw className="h-4 w-4 animate-spin" />
{t("registration.verifyOtp.buttons.resending")}
</span>
) : cooldown > 0 ? (
t("registration.verifyOtp.buttons.resendIn", {
seconds: cooldown,
})
) : (
t("registration.verifyOtp.buttons.resendCode")
)}
</Button>
)}
<div className="min-h-12" aria-live="polite" aria-atomic="true">
{message && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"p-3 rounded-lg flex items-start gap-3",
message.type === "success"
? "bg-primary-50 text-primary-700"
: "bg-red-50 text-red-700"
)}>
<ShieldCheck
className={cn(
"h-5 w-5 flex-shrink-0 mt-0.5",
message.type === "success"
? "text-primary-500"
: "text-red-500"
)}
/>
<p className="text-sm font-medium">{message.text}</p>
</motion.div>
)}
</div>
<div className="text-center text-sm">
<Link
to="/support/resend-sms"
className="text-muted-foreground hover:text-foreground">
{t("registration.verifyOtp.support.needHelp")}
</Link>
</div>
</CardContent>
</Card>
</motion.div>
);
};
export default VerifyOtp;

View File

@@ -0,0 +1,25 @@
/**
* Default receiving organization for external-portal / complaint submissions.
*
* Change `unitKey` and `unitId` here when routing complaints to a different unit.
* `unitKey` should match the unit key from your organization API.
*/
export const EXTERNAL_PORTAL_RECEIVING_UNIT = {
unitKey: "adwa_victory_vemorial_museum",
unitId: "8524ba74-6df6-4632-9515-7903cc93c229",
name: {
am: "የዓድዋ ድል መታሰቢያ ሙዚየም",
en: "Adwa Victory Memorial Museum",
},
} as const;
export function resolveExternalPortalReceivingUnitIds(): string[] {
return [EXTERNAL_PORTAL_RECEIVING_UNIT.unitId];
}
export function getExternalPortalReceivingUnitName(language = "en"): string {
if (language === "am") {
return EXTERNAL_PORTAL_RECEIVING_UNIT.name.am;
}
return EXTERNAL_PORTAL_RECEIVING_UNIT.name.en;
}

View File

@@ -0,0 +1,107 @@
import { CreateLetterDto } from "@/shared/dto/External-Portal/External-PortalDto";
import { useState } from "react";
import {
createExternalLetterRecord,
externalParam,
getExternalLetterRecord,
} from "../services/portalOutgoingService";
import { nonSmartOfficeFileService } from "@/record-management/services/api/nonSmartOfficeFileService";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { fetchLetterById } from "@/record-management/services/api/userRecordService";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export interface CreateLetterResult {
recordId: string;
contentId: string;
fileInfo: {
bucket: string;
fileName: string;
contentType: string;
originalname: string;
size: number;
};
presigned: string;
}
export const useExternalPortal = (params?: externalParam) => {
const [isSending, setIsSending] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const createExternalPortalLetter = async (
values: CreateLetterDto,
file: File,
): Promise<CreateLetterResult> => {
let recordId: string | null = null;
try {
setIsSending(true);
const createRes = await createExternalLetterRecord(values);
recordId = createRes.data.recordId;
const { presigned: uploadUrl } = createRes.data;
if (!recordId || !uploadUrl) {
return Promise.reject(new Error("Failed to create record"));
}
await nonSmartOfficeFileService.uploadFile(file, uploadUrl);
await nonSmartOfficeFileService.updateStatus(recordId, true);
setIsUploading(true);
// Return the record information here
return createRes.data;
} catch (error) {
// If upload failed, mark status as false
if (recordId) {
try {
await nonSmartOfficeFileService.updateStatus(recordId, false);
} catch (statusError) {
await handleError(statusError);
}
}
return Promise.reject(error);
} finally {
setIsSending(false);
setIsUploading(false);
}
};
const {
data: externalLettersData = [],
isLoading: isFetchingLetters,
isError,
refetch,
} = useQuery({
queryKey: ["external-letters", params],
queryFn: async () => {
const res = await getExternalLetterRecord(params);
return res.data; // adjust if you need metadata too
},
});
const ExternalLetters = externalLettersData.items || [];
const ExternalCount = externalLettersData.count || 0;
return {
createExternalPortalLetter,
ExternalLetters,
ExternalCount,
isFetchingLetters,
isError,
refetchExternalLetters: refetch,
isSending,
isUploading,
};
};
export const attachmentResponse = (id: string) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useQuery({
queryKey: ["record-pdf", id],
queryFn: () => fetchLetterById(id),
enabled: !!id,
});
};

View File

@@ -0,0 +1,95 @@
import {
CreateUserDto,
DocumentPayloadDto,
DocumentResponseDto,
} from "@/shared/dto/External-Portal/External-PortalDto";
import { docuemntUpload, getMyUploads, registerExternalUser, UpdateMyUpload, UpdateMyUploadStatus } from "../services/portalOutgoingService";
import { useState } from "react";
import { nonSmartOfficeFileService } from "@/record-management/services/api/nonSmartOfficeFileService";
import { useQuery } from "@tanstack/react-query";
type documentProps = {
userId?:string ;
}
export const useDocumentUploads = () => {
const [isRegistering, setIsRegistering] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const uploadDocuments = async (payload: DocumentPayloadDto, file: File) => {
let uploadId: string | null = null;
try {
// Step 1: Get presigned URL
const uploadRes = await docuemntUpload(payload);
const { presigned, id } = uploadRes.data;
if (!id || !presigned) {
throw new Error("Failed to get upload URL");
}
uploadId = id;
// Step 2: Upload file to S3 using presigned URL
await nonSmartOfficeFileService.uploadFile(file, presigned);
// Step 3: Update upload status to success
await UpdateMyUploadStatus(id, true);
return uploadRes.data;
} catch (error) {
// If upload failed, mark status as false
if (uploadId) {
try {
await UpdateMyUploadStatus(uploadId, false);
} catch (statusError) {
console.error("Failed to update upload status:", statusError);
}
}
throw error;
}
};
const updateDocuemnts = async (Id:string, values:DocumentPayloadDto,file:File) => {
let uploadId: string | null = null;
try {
const uploadRes = await UpdateMyUpload(Id, values);
const { presigned, id } = uploadRes.data;
if (!id || !presigned) {
return Promise.reject(new Error("Failed to Upload Document"));
}
uploadId = id;
await nonSmartOfficeFileService.uploadFile(file, presigned);
// Update upload status to success
await UpdateMyUploadStatus(id, true);
setIsUpdating(true);
return uploadRes.data;
} catch (error) {
// If upload failed, mark status as false
if (uploadId) {
try {
await UpdateMyUploadStatus(uploadId, false);
} catch (statusError) {
console.error("Failed to update upload status:", statusError);
}
}
setIsUpdating(false);
throw error;
}
};
return {
uploadDocuments,
isRegistering,
isUpdating,
updateDocuemnts,
};
};

View File

@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { getMyUploadById, getMyUploads } from "../services/portalOutgoingService";
import { FileInfo } from "@/shared/services/complaintService";
export interface Upload {
id: string;
documentId: string;
createdAt: string; // ISO date string
fileInfo: FileInfo; // refine if you know the shape
}
// Fetch list of uploads by userId
export const useMyUploads = () => {
return useQuery<Upload[]>({
queryKey: ["myUploads"],
queryFn: getMyUploads,
});
};
// Fetch single upload by documentId
export const useMyUploadById = (docId: string) => {
return useQuery({
queryKey: ["myUpload", docId],
queryFn: () => getMyUploadById(docId).then((res) => res),
enabled: !!docId,
});
};

View File

@@ -0,0 +1,47 @@
import { CreateUserDto, FayidaUserDto } from "@/shared/dto/External-Portal/External-PortalDto";
import {
registerExternalUser,
registerFayidaUser,
} from "../services/portalOutgoingService";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export const useRegisterExternalPortalUser = () => {
const [isRegistering, setIsRegistering] = useState(false);
const [isFayidaRegistering, setIsFayidaRegistering] = useState(false);
const { t } = useTranslation();
const { handleError } = useErrorHandler(t)
const registerExternalPortalUser = async (values: CreateUserDto) => {
try {
setIsRegistering(true);
const response = await registerExternalUser(values);
return { success: true, response };
} catch (error: any) {
handleError(error)
setIsRegistering(false);
} finally {
setIsRegistering(false);
}
};
const registerExternalFayidaUser = async (values: FayidaUserDto) => {
try {
setIsFayidaRegistering(true);
const response = await registerFayidaUser(values);
return { success: true, response };
} catch (error: any) {
handleError(error);
} finally {
setIsFayidaRegistering(false);
}
};
return {
registerExternalPortalUser,
registerExternalFayidaUser,
isRegistering,
isFayidaRegistering,
};
};

View File

@@ -0,0 +1,77 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { toast } from "sonner";
import {
registerTradeLicense,
setETradePassword,
SetETradePasswordPayload,
TradeLicensePayload,
verifyEtradeOtpCode,
} from "../services/portalOutgoingService";
import type { VerifyEtradeOtpPayload } from "@/shared/services/authService";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
export const useTradeLicenseVerification = () => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const registerMutation = useMutation<AxiosResponse, unknown, TradeLicensePayload>(
{
mutationFn: (body) => registerTradeLicense(body),
onSuccess: () => {
toast.success(t("registration.etrade.otpSent"));
},
onError: (error) => {
if (
error instanceof Error &&
error.message === "ETRADE_PHONE_LOOKUP_FAILED"
) {
toast.error(t("registration.etrade.phoneLookupFailed"));
return;
}
handleError(error);
},
},
);
const verifyOtpMutation = useMutation<
AxiosResponse,
unknown,
VerifyEtradeOtpPayload
>({
mutationFn: (body) => verifyEtradeOtpCode(body),
onSuccess: () => {
toast.success(t("registration.etrade.registrationSuccess"));
},
onError: (error) => {
handleError(error);
},
});
const passwordMutation = useMutation<
AxiosResponse,
unknown,
SetETradePasswordPayload
>({
mutationFn: (body) => setETradePassword(body),
onSuccess: () => {
toast.success("Password set successfully");
},
onError: (error) => {
handleError(error);
},
});
return {
registerTradeLicense: registerMutation.mutateAsync,
verifyEtradeOtp: verifyOtpMutation.mutateAsync,
setETradePassword: passwordMutation.mutateAsync,
isRegistering: registerMutation.isPending,
isVerifyingOtp: verifyOtpMutation.isPending,
isSettingPassword: passwordMutation.isPending,
registerResponse: registerMutation.data,
verifyOtpResponse: verifyOtpMutation.data,
passwordResponse: passwordMutation.data,
};
};

View File

@@ -0,0 +1,5 @@
// import VerifyOtp from "../components/verifyOTP";
// export const OtpVerificationPage = () => {
// return <VerifyOtp />;
// // }

View File

@@ -0,0 +1,5 @@
import PortalOutgoing from "../components/outgoing/portalOutgoing"
export const PortalOutgoingPage =()=>{
return <PortalOutgoing/>
}

View File

@@ -0,0 +1,5 @@
import Signup from "../components/signUp"
export const SignUpPage = ()=>{
return <Signup/>
}

View File

@@ -0,0 +1,16 @@
import { useNavigate } from "react-router-dom";
import SubmitExternalRecordForm from "../components/outgoing/submitExternalRecords";
import { PortalFormPage } from "../components/shared/PortalFormPrimitives";
import { COMPLAINT_RECORDS_PATH } from "@/complaints/utils/complaintRoutes";
export const SubmitExternalRecordFormPage = () => {
const navigate = useNavigate();
return (
<PortalFormPage>
<SubmitExternalRecordForm
onCancel={() => navigate(COMPLAINT_RECORDS_PATH)}
/>
</PortalFormPage>
);
};

View File

@@ -0,0 +1,6 @@
const UploadStepsPage = () => {
return (<div></div>);
};
export default UploadStepsPage;

View File

@@ -0,0 +1,32 @@
import React, { useEffect } from "react";
import VerificationPending from "../components/External-Portal-Navigation/VerificationPending";
import LandingPage from "../../layout/landingpage";
import Cookies from "js-cookie";
import { useUser } from "@/shared/context/UserContext";
export default function VerificationPendingPage() {
const userDetails = useUser();
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
// Remove token cookie after 15 minutes
useEffect(() => {
if (!hasCompletedRegistration) {
const timer = setTimeout(() => {
Cookies.remove("auth-token");
}, 15 * 60 * 1000); // 15 minutes in milliseconds
return () => clearTimeout(timer); // cleanup if the component unmounts
}
}, [hasCompletedRegistration]);
// If registration is finished → show landing page
if (hasCompletedRegistration) {
return <LandingPage />;
}
// Otherwise → show pending page
return (
<VerificationPending
hasCompletedRegistration={!!hasCompletedRegistration}
contactNumber="+251 955232323"
email="info@triaplc.com"
/>
);
}

View File

@@ -0,0 +1,41 @@
import { useNavigate, useParams } from "react-router-dom";
import ViewExternalRecord from "../components/ViewExternalRecord";
import { attachmentResponse } from "../hooks/useCreateExternalLetterRecord";
import { useEffect } from "react";
const ViewExternalRecordPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// Verify we have a valid ID
if (!id) {
return <div className="p-4 text-red-500">Error: Invalid record ID</div>;
}
const { data: attachment, isLoading, error } = attachmentResponse(id); // Fixed: using id instead of undefined itemId
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
}, [attachment, isLoading, error]);
if (isLoading) {
return <div className="p-4 text-center">Loading record...</div>;
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
Error loading record: {error.message}
</div>
);
}
return (
<ViewExternalRecord
itemId={id}
onBack={() => navigate("/external-portal/portal-outgoing")}
/>
);
};
export default ViewExternalRecordPage;

View File

@@ -0,0 +1,5 @@
import LandingPageLayout from "../components/landingOutlet";
export const LandingPage =()=>{
return <LandingPageLayout />;
}

View File

@@ -0,0 +1,249 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import {
CreateLetterDto,
CreateUserDto,
DocumentPayloadDto,
FayidaUserDto,
} from "@/shared/dto/External-Portal/External-PortalDto";
import axiosInstance from "@/shared/services/axiosInstance";
import recordAxiosInstance from "@/shared/services/recordAxiosInstance";
import { AxiosResponse } from "axios";
import { id } from "date-fns/locale";
import Cookies from "js-cookie";
import { Upload } from "../hooks/useMyUpload";
import { FileInfo } from "@/shared/services/complaintService";
import {
registerWithFayda,
registerWithEtrade,
SetFayidaPasswordPayload,
verifyEtradeOtp,
type VerifyEtradeOtpPayload,
} from "@/shared/services/authService";
import { resolveEtradePhoneForSignup } from "@/complaints/services/etradeTinService";
export interface LatestUploadInfo {
docId: string;
uploadId: string | null;
fileInfo: FileInfo;
presigned: string;
}
export interface TradeLicensePayload {
tin: string;
licenseNo: string;
phoneNumber?: string;
}
/** @deprecated Use TradeLicensePayload */
export interface TradeLicenseSigninPayload {
mode: "signin";
licenseNo: string;
}
/** @deprecated Use TradeLicensePayload */
export interface TradeLicenseSignupPayload {
mode: "signup";
tin: string;
licenseNo: string;
}
export interface SetETradePasswordPayload {
licenseNo: string;
verificationCode: string;
newPassword: string;
confirmPassword: string;
}
export interface UploadDetails {
presigned: string;
// add other fields if needed
}
export interface externalParam {
skip?: number;
take?: number;
order?: string;
orderBy?: string;
}
export const createExternalLetterRecord = async (
body: CreateLetterDto,
): Promise<any> => {
return recordAxiosInstance.post("/records/self-non-smart-record-pdf", body, {
headers: withHeaders(),
});
};
export const registerExternalUser = async (
body: CreateUserDto,
): Promise<AxiosResponse> => {
return axiosInstance.post("/auth/signup", body, {
headers: withHeaders(),
});
};
export const registerFayidaUser = async (
body: FayidaUserDto,
): Promise<AxiosResponse> => {
return registerWithFayda(body);
};
export const signinFayidaUser = async (
body: FayidaUserDto,
): Promise<AxiosResponse> => {
return registerWithFayda(body);
};
export const signupFayidaUser = async (
body: FayidaUserDto,
): Promise<AxiosResponse> => {
return registerWithFayda(body);
};
export const getExternalLetterRecord = async (
params?: externalParam,
): Promise<AxiosResponse> => {
return recordAxiosInstance.get("/records/self-non-smart-records", {
params,
headers: withHeaders(),
});
};
export const docuemntUpload = async (
body: DocumentPayloadDto,
): Promise<AxiosResponse> => {
return axiosInstance.post("/user-documents", body, {
headers: withHeaders(),
});
};
export const submitApplication = async (): Promise<any[]> => {
const token = Cookies.get("auth-token");
// get token from wherever you store it
if (!token) return [];
try {
const response = await axiosInstance.patch("/users/submit-application", {
headers: withHeaders(),
});
return response.data?.items ?? []; // assuming API returns { items: [...] }
} catch (err) {
console.error("Failed to fetch user uploads", err);
return []; // fallback to empty array on error
}
};
export const getMyUploads = async (): Promise<Upload[]> => {
const token = Cookies.get("auth-token");
// get token from wherever you store it
if (!token) return []; // return empty if no token
try {
const response = await axiosInstance.get("/user-documents/mine/", {
headers: withHeaders(),
});
return response.data?.items ?? []; // assuming API returns { items: [...] }
} catch (err) {
console.error("Failed to fetch user uploads", err);
return []; // fallback to empty array on error
}
};
export const getMyUploadedFiles = async (
id: string,
): Promise<AxiosResponse> => {
return axiosInstance.get(`/user-documents/list-with-presigned/${id}`, {
headers: withHeaders(),
});
};
export const getMineUploadedFiles = async (): Promise<AxiosResponse> => {
return axiosInstance.get(`/user-documents/mine-with-presigned`, {
headers: withHeaders(),
});
};
export const getMyUploadById = async (id: string): Promise<UploadDetails> => {
return axiosInstance.get(`/user-documents/${id}`, {
headers: withHeaders(),
});
};
export const UpdateMyUpload = async (
id: string,
body: DocumentPayloadDto,
): Promise<AxiosResponse> => {
return axiosInstance.put(
`/user-documents/${id}`,
{ body },
{
headers: withHeaders(),
},
);
};
export const UpdateMyUploadStatus = async (
id: string,
uploadedSuccessfully: boolean,
): Promise<AxiosResponse> => {
return axiosInstance.patch(
`/user-documents/${id}/upload-status`,
{ uploadedSuccessfully },
{
headers: withHeaders(),
},
);
};
export const registerTradeLicense = async (
body: TradeLicensePayload,
): Promise<AxiosResponse> => {
let phoneNumber = body.phoneNumber?.trim() || undefined;
if (!phoneNumber && body.tin?.trim() && body.licenseNo?.trim()) {
try {
const resolved = await resolveEtradePhoneForSignup(
body.licenseNo,
body.tin,
"en",
);
phoneNumber = resolved ?? undefined;
} catch (error) {
console.warn(
"[eTrade] Failed to resolve phone from GetBusinessByLicenseNo",
error,
);
}
}
return registerWithEtrade({
tin: body.tin,
licenseNumber: body.licenseNo,
...(phoneNumber ? { phoneNumber } : {}),
});
};
export const signinTradeLicense = async (body: {
licenseNo: string;
tin?: string;
}): Promise<AxiosResponse> =>
registerTradeLicense({
tin: body.tin ?? "",
licenseNo: body.licenseNo,
});
export const signupTradeLicense = async (body: {
tin: string;
licenseNo: string;
}): Promise<AxiosResponse> =>
registerTradeLicense({
tin: body.tin,
licenseNo: body.licenseNo,
});
export const verifyEtradeOtpCode = async (
body: VerifyEtradeOtpPayload,
): Promise<AxiosResponse> => verifyEtradeOtp(body);
export const setETradePassword = async (
body: SetETradePasswordPayload,
): Promise<AxiosResponse> => {
return axiosInstance.patch("/auth/set-etrade-password", body, {
headers: withHeaders(),
});
};

View File

@@ -0,0 +1,17 @@
const ETRADE_LICENSE_NO_KEY = "etrade-auth-license-no";
/** Persist license number for the eTrade OTP verification step. */
export function storeEtradeLicenseNo(licenseNo: string): void {
if (typeof sessionStorage === "undefined") return;
sessionStorage.setItem(ETRADE_LICENSE_NO_KEY, licenseNo.trim());
}
export function getEtradeLicenseNo(): string | null {
if (typeof sessionStorage === "undefined") return null;
return sessionStorage.getItem(ETRADE_LICENSE_NO_KEY);
}
export function clearEtradeLicenseNo(): void {
if (typeof sessionStorage === "undefined") return;
sessionStorage.removeItem(ETRADE_LICENSE_NO_KEY);
}

View File

@@ -0,0 +1,15 @@
export function normalizeTin(value: string): string {
return value.replace(/\D/g, "").slice(0, 10);
}
export function isValidTin(tin: string): boolean {
return /^\d{10}$/.test(tin.trim());
}
export function normalizeLicenseNo(value: string): string {
return value.trim();
}
export function isValidLicenseNo(licenseNo: string): boolean {
return normalizeLicenseNo(licenseNo).length > 0;
}

View File

@@ -0,0 +1,22 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
/**
* i18next singleton for the vendored IAM UI (shared/super-admin/
* user-management), which do `import i18n from "@/i18n"` and call
* `useTranslation()` / `t()`. Minimal init so those resolve and render keys;
* load real translation resources here as they are migrated.
*/
if (!i18n.isInitialized) {
void i18n.use(initReactI18next).init({
resources: { en: { translation: {} } },
lng: "en",
fallbackLng: "en",
interpolation: { escapeValue: false },
returnNull: false,
// Missing keys render as the key text itself (acceptable until resources land).
parseMissingKeyHandler: (key) => key,
});
}
export default i18n;

View File

@@ -1,36 +1,36 @@
import { useLocation } from "react-router-dom";
import { Outlet } from "react-router-dom";
import { AppMenuTabs } from "./AppMenuTabs";
import { useSidebar } from "@/shared/common/ui/sidebar";
import Top from "@/record-management/components/common/Top";
import { useAuth } from "@/shared/context/AuthContext";
export const AppLayout = () => {
const { pathname } = useLocation();
const isAuthPage = pathname === "/";
const { toggleSidebar } = useSidebar();
const { user } = useAuth();
const userRoles = user?.roles?.map((role) => role.key) || [];
const isSuperAdmin = userRoles.includes("super_admin");
if (isAuthPage) {
return (
<div className="min-h-screen w-full bg-gray-50">
<Outlet />
</div>
);
}
return (
<div className="w-full flex flex-col min-h-screen bg-background text-foreground">
<Top onToggleSidebar={toggleSidebar} showRecordManagementShortcut={!isSuperAdmin} />
<AppMenuTabs />
<div className="px-2 pb-2 pt-8 sm:px-4 sm:pb-4 sm:pt-10 flex-1">
<div className="w-full overflow-x-auto">
<Outlet />
</div>
</div>
</div>
);
};
import { useLocation } from "react-router-dom";
import { Outlet } from "react-router-dom";
import { AppMenuTabs } from "./AppMenuTabs";
import { useSidebar } from "@/shared/common/ui/sidebar";
import Top from "@/record-management/components/common/Top";
import { useAuth } from "@/shared/context/AuthContext";
export const AppLayout = () => {
const { pathname } = useLocation();
const isAuthPage = pathname === "/";
const { toggleSidebar } = useSidebar();
const { user } = useAuth();
const userRoles = user?.roles?.map((role) => role.key) || [];
const isSuperAdmin = userRoles.includes("super_admin");
if (isAuthPage) {
return (
<div className="min-h-screen w-full bg-gray-50">
<Outlet />
</div>
);
}
return (
<div className="w-full flex flex-col min-h-screen bg-background text-foreground">
<Top onToggleSidebar={toggleSidebar} showRecordManagementShortcut={!isSuperAdmin} />
<AppMenuTabs />
<div className="px-2 pb-2 pt-8 sm:px-4 sm:pb-4 sm:pt-10 flex-1">
<div className="w-full overflow-x-auto">
<Outlet />
</div>
</div>
</div>
);
};

View File

@@ -1,312 +1,312 @@
import { NavLink } from "react-router-dom";
import {
Archive,
BarChart,
Building2,
ChartAreaIcon,
ClipboardList,
FileText,
Globe,
Settings,
ShieldAlert,
Users2,
UsersRound,
} from "lucide-react";
import { useAuth } from "@/shared/context/AuthContext";
import { usePermissions } from "@/shared/context/PermissionContext";
import { useTranslation } from "react-i18next";
export interface MenuItem {
label: string;
href: string;
icon: React.ReactNode;
roles?: string[];
permissions?: string[];
isPrimary?: boolean;
displayLabel?: string;
children?: MenuItem[];
}
export const AppMenuTabs = () => {
const { user } = useAuth();
const userRoles = user?.roles.map((role) => role.key) || [];
const menuItems: MenuItem[] = [
{
label: "dashboard",
href: "/user-management/dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "organizations",
href: "/user-management/organizations",
icon: <Building2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "organizationAdmins", // Shortened for mobile
displayLabel: "organizationAdmins", // Full label for desktop
href: "/user-management/organization_admins",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "externalUsers", // Shortened for mobile
displayLabel: "externalUsers", // Full label for desktop
href: "/user-management/external_users",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "dashboard",
href: "/user-management/user_management-dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "userManagement",
href: "/user-management/user_management",
icon: <UsersRound className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
// {
// label: "userPositionApproval",
// href: "/user-management/user-position-approval",
// icon: <UsersRound className="h-4 w-4" />,
// roles: ["admin", "organization_admin", "unit_admin"],
// permissions: ["can:activateEmployee"],
// isPrimary: true,
// },
// {
// label: "All Records",
// displayLabel: "All Records",
// href: "/user-management/all-records",
// icon: <FileText className="h-4 w-4" />,
// roles: ["admin", "organization_admin", "unit_admin"],
// permissions: ["can:canViewAllRecords"],
// isPrimary: true,
// },
{
label: "contentManagement", // Shortened for mobile
displayLabel: "contentManagement", // Full label for desktop
href: "/user-management/content-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "webManagement",
displayLabel: "webManagement",
href: "/user-management/web-management",
icon: <Globe className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Position",
displayLabel: "positionTypes",
href: "/user-management/position-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
isPrimary: true,
},
{
label: "migratedRecords",
displayLabel: "migratedRecords",
href: "/user-management/migrated-records-management",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "settings",
displayLabel: "settings",
href: "/user-management/organization-settings",
icon: <Settings className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Bulk",
displayLabel: "bulkUpload",
href: "/user-management/bulk-upload",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Archive Users",
displayLabel: "Archive Users",
href: "/user-management/archive-users",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "Archived Organizations",
displayLabel: "Archived Organizations",
href: "/user-management/archived-organizations",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "Archive Users",
displayLabel: "Archive Users",
href: "/user-management/archives",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Archived Units & Positions",
displayLabel: "Archived Units & Positions",
href: "/user-management/archived",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Sector Reports",
displayLabel: "Sector Reports",
href: "/user-management/sector-reports",
icon: <ChartAreaIcon className="h-5 w-5" />,
roles: ["unit_admin", "admin", "organization_admin"],
isPrimary: true,
},
{
label: "activityLog",
href: "/user-management/activity_log",
icon: <ClipboardList className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
},
{
label: "setting",
href: "/user-management/settings",
icon: <Settings className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
},
{
label: "Letter Template",
href: "/user-management/templates",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
},
{
label: "Add Site",
href: "/user-management/add-site",
icon: <Globe className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
// {
// label: "branding.title",
// href: "/user-management/web-management/Branding/Branding",
// icon: <ShieldAlert className="h-4 w-4" />,
// roles: ["super_admin"],
// isPrimary: true,
// },
];
const { permissions } = usePermissions();
const { t } = useTranslation();
const filteredMenu = menuItems.filter((item) =>
item?.roles?.some((r) => userRoles.includes(r)),
);
const primaryMenuItems = filteredMenu.filter(
(item) => item.isPrimary !== false,
);
const secondaryMenuItems = filteredMenu.filter(
(item) => item.isPrimary === false,
);
return (
<div className="fixed top-16 left-0 right-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-800 overflow-hidden flex flex-col">
{/* Mobile View - Two separate rows */}
<div className="md:hidden flex flex-col pt-5 pb-3 px-4 space-y-3 mt-2">
{/* Primary items row */}
<div className="flex items-center overflow-x-auto scrollbar-none">
{primaryMenuItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-[80px] truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
{/* Secondary items row (if any) */}
{secondaryMenuItems.length > 0 && (
<div className="flex items-center overflow-x-auto scrollbar-none border-t dark:border-gray-800 pt-3">
{secondaryMenuItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-[80px] truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
)}
</div>
{/* Desktop View - Single row with all items */}
<div className="hidden md:flex pt-5 pb-3 px-8 mt-1">
<div className="flex items-center gap-2 overflow-x-auto scrollbar-none">
{filteredMenu.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-full truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
</div>
</div>
);
};
import { NavLink } from "react-router-dom";
import {
Archive,
BarChart,
Building2,
ChartAreaIcon,
ClipboardList,
FileText,
Globe,
Settings,
ShieldAlert,
Users2,
UsersRound,
} from "lucide-react";
import { useAuth } from "@/shared/context/AuthContext";
import { usePermissions } from "@/shared/context/PermissionContext";
import { useTranslation } from "react-i18next";
export interface MenuItem {
label: string;
href: string;
icon: React.ReactNode;
roles?: string[];
permissions?: string[];
isPrimary?: boolean;
displayLabel?: string;
children?: MenuItem[];
}
export const AppMenuTabs = () => {
const { user } = useAuth();
const userRoles = user?.roles.map((role) => role.key) || [];
const menuItems: MenuItem[] = [
{
label: "dashboard",
href: "/user-management/dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "organizations",
href: "/user-management/organizations",
icon: <Building2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "organizationAdmins", // Shortened for mobile
displayLabel: "organizationAdmins", // Full label for desktop
href: "/user-management/organization_admins",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "externalUsers", // Shortened for mobile
displayLabel: "externalUsers", // Full label for desktop
href: "/user-management/external_users",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "dashboard",
href: "/user-management/user_management-dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "userManagement",
href: "/user-management/user_management",
icon: <UsersRound className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
// {
// label: "userPositionApproval",
// href: "/user-management/user-position-approval",
// icon: <UsersRound className="h-4 w-4" />,
// roles: ["admin", "organization_admin", "unit_admin"],
// permissions: ["can:activateEmployee"],
// isPrimary: true,
// },
// {
// label: "All Records",
// displayLabel: "All Records",
// href: "/user-management/all-records",
// icon: <FileText className="h-4 w-4" />,
// roles: ["admin", "organization_admin", "unit_admin"],
// permissions: ["can:canViewAllRecords"],
// isPrimary: true,
// },
{
label: "contentManagement", // Shortened for mobile
displayLabel: "contentManagement", // Full label for desktop
href: "/user-management/content-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "webManagement",
displayLabel: "webManagement",
href: "/user-management/web-management",
icon: <Globe className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Position",
displayLabel: "positionTypes",
href: "/user-management/position-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
isPrimary: true,
},
{
label: "migratedRecords",
displayLabel: "migratedRecords",
href: "/user-management/migrated-records-management",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "settings",
displayLabel: "settings",
href: "/user-management/organization-settings",
icon: <Settings className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Bulk",
displayLabel: "bulkUpload",
href: "/user-management/bulk-upload",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Archive Users",
displayLabel: "Archive Users",
href: "/user-management/archive-users",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "Archived Organizations",
displayLabel: "Archived Organizations",
href: "/user-management/archived-organizations",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "Archive Users",
displayLabel: "Archive Users",
href: "/user-management/archives",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Archived Units & Positions",
displayLabel: "Archived Units & Positions",
href: "/user-management/archived",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Sector Reports",
displayLabel: "Sector Reports",
href: "/user-management/sector-reports",
icon: <ChartAreaIcon className="h-5 w-5" />,
roles: ["unit_admin", "admin", "organization_admin"],
isPrimary: true,
},
{
label: "activityLog",
href: "/user-management/activity_log",
icon: <ClipboardList className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
},
{
label: "setting",
href: "/user-management/settings",
icon: <Settings className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
},
{
label: "Letter Template",
href: "/user-management/templates",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
},
{
label: "Add Site",
href: "/user-management/add-site",
icon: <Globe className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
// {
// label: "branding.title",
// href: "/user-management/web-management/Branding/Branding",
// icon: <ShieldAlert className="h-4 w-4" />,
// roles: ["super_admin"],
// isPrimary: true,
// },
];
const { permissions } = usePermissions();
const { t } = useTranslation();
const filteredMenu = menuItems.filter((item) =>
item?.roles?.some((r) => userRoles.includes(r)),
);
const primaryMenuItems = filteredMenu.filter(
(item) => item.isPrimary !== false,
);
const secondaryMenuItems = filteredMenu.filter(
(item) => item.isPrimary === false,
);
return (
<div className="fixed top-16 left-0 right-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-800 overflow-hidden flex flex-col">
{/* Mobile View - Two separate rows */}
<div className="md:hidden flex flex-col pt-5 pb-3 px-4 space-y-3 mt-2">
{/* Primary items row */}
<div className="flex items-center overflow-x-auto scrollbar-none">
{primaryMenuItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-[80px] truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
{/* Secondary items row (if any) */}
{secondaryMenuItems.length > 0 && (
<div className="flex items-center overflow-x-auto scrollbar-none border-t dark:border-gray-800 pt-3">
{secondaryMenuItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-[80px] truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
)}
</div>
{/* Desktop View - Single row with all items */}
<div className="hidden md:flex pt-5 pb-3 px-8 mt-1">
<div className="flex items-center gap-2 overflow-x-auto scrollbar-none">
{filteredMenu.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-full truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,156 @@
import { Link, useLocation } from "react-router-dom";
import {
BarChart,
Building2,
ClipboardList,
FileText,
LogOut,
Settings,
Users2,
UsersRound,
ChartAreaIcon,
} from "lucide-react";
import { useAuth } from "@/shared/context/AuthContext";
interface MenuItem {
label: string;
href: string;
icon: React.ReactNode;
roles: string[]; // role keys allowed to see this item
}
export const AppSidebar = () => {
const location = useLocation();
const { user } = useAuth();
const userRoles = user?.roles.map((role) => role.key) || [];
const menuItems: MenuItem[] = [
{
label: "Dashboard",
href: "/dashboard",
icon: <BarChart className="h-5 w-5" />,
roles: ["super_admin"],
},
{
label: "Dashboard",
href: "/user_management-dashboard",
icon: <BarChart className="h-5 w-5" />,
roles: ["admin", "unit_admin"],
},
{
label: "Organizations",
href: "/organizations",
icon: <Building2 className="h-5 w-5" />,
roles: ["super_admin"],
},
{
label: "Organization Admins",
href: "/organization_admins",
icon: <Users2 className="h-5 w-5" />,
roles: ["super_admin"],
},
{
label: "User Management",
href: "/user_management",
icon: <UsersRound className="h-5 w-5" />,
roles: ["admin", "unit_admin"],
},
{
label: "Content Management",
href: "/content-management",
icon: <FileText className="h-5 w-5" />,
roles: ["admin", "unit_admin"],
},
{
label: "Bulk Upload",
href: "/bulk-upload",
icon: <FileText className="h-5 w-5" />,
roles: ["unit_admin"],
},
{
label: "Archives",
href: "/archives",
icon: <FileText className="h-5 w-5" />,
roles: ["unit_admin"],
},
{
label: "Activity Log",
href: "/activity_log",
icon: <ClipboardList className="h-5 w-5" />,
roles: ["super_admin"],
},
{
label: "Settings",
href: "/settings",
icon: <Settings className="h-5 w-5" />,
roles: ["super_admin"],
},
{
label: "Sector Reports",
href: "/sector-reports",
icon: <ChartAreaIcon className="h-5 w-5" />,
roles: ["unit_admin", "admin"],
},
];
const filteredMenu = menuItems.filter((item) =>
item.roles.some((r) => userRoles.includes(r)),
);
return (
<div className="flex flex-col h-full border-r bg-gray-50">
<div className="p-4">
<img
src="/assets/smart-office-logo.svg"
alt="Smart Office"
className="w-40"
/>
</div>
<div className="flex flex-col gap-1 px-2 py-4 flex-1">
{filteredMenu.map((item) => (
<MenuItem
key={item.href}
label={item.label}
href={item.href}
icon={item.icon}
isActive={location.pathname === item.href}
/>
))}
</div>
<div className="p-4 mt-auto border-t">
<MenuItem
label="Logout"
href="/"
icon={<LogOut className="h-5 w-5" />}
isActive={false}
/>
</div>
</div>
);
};
interface MenuItemProps {
label: string;
href: string;
icon: React.ReactNode;
isActive?: boolean;
}
const MenuItem = ({ label, href, icon, isActive = false }: MenuItemProps) => {
return (
<Link
to={href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
isActive
? "bg-primary text-primary-foreground"
: "hover:bg-primary/10 text-slate-800"
}`}
>
{icon}
<span className="text-sm font-medium">{label}</span>
</Link>
);
};

View File

@@ -0,0 +1,12 @@
import React from 'react'
const FayidaSignup = ()=> {
return (
<div>
</div>
)
}
export default FayidaSignup;

View File

@@ -0,0 +1,357 @@
"use client";
import {
ChevronDown,
Globe,
LogOut,
User,
Key,
Moon,
Sun,
Home,
Menu,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Avatar, AvatarFallback } from "@/shared/common/ui/avatar";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { useTranslation } from "react-i18next";
import { NavLink, useNavigate, useLocation } from "react-router-dom";
import { useRef, useEffect, useState } from "react";
import { FiBell } from "react-icons/fi";
import { useUser } from "@/shared/context/UserContext";
import { useDarkMode } from "@/shared/hooks/useDarkMode";
import {
UI_LANGUAGE_OPTIONS,
getUiLanguageLabel,
resolveUiLanguage,
} from "@/shared/i18n/uiLanguages";
import { useNotifications } from "@/shared/hooks/useNotification";
import NotificationList from "@/record-management/components/NotificationList";
import {
resolveModuleConfig,
useTenantConfig,
} from "@/layout/components/TenantConfig";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
export const TopBar = () => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { pathname } = useLocation();
const { logout } = useAuthUser();
const userDetails = useUser();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { config: tenantConfig } = useTenantConfig();
const moduleConfig = resolveModuleConfig(tenantConfig);
const isAdminModuleVisible = userDetails?.roles?.some(
(role: { key: string }) =>
role.key === "unit_admin" ||
role.key === "organization_admin" ||
role.key === "super_admin",
);
const visibleModuleCount = [
moduleConfig.recordManagement,
moduleConfig.performance,
moduleConfig.objective,
moduleConfig.dms,
moduleConfig.siteManagement && isAdminModuleVisible,
].filter(Boolean).length;
const shouldShowHomePageLink = visibleModuleCount > 1;
const isObjectiveManagementRoute = pathname.startsWith("/objective-management");
const fullName = userDetails?.name?.en || t("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 currentLanguage = resolveUiLanguage(i18n.language);
const handleLogout = () => {
logout();
};
const userRoles = userDetails?.roles?.map((role: any) => role.key) || [];
const isSuperAdmin = userRoles.includes("super_admin");
const isOrgAdmin =
userRoles.includes("unit_admin") ||
userRoles.includes("organization_admin");
const modulesList = [
{
id: "homepage",
label: t("nav.homePage", "Home Page"),
path: "/homepage",
},
...(moduleConfig.recordManagement
? [
{
id: "recordManagement",
label: t("nav.Record Management", "Record Management"),
path: "/record-management/dashboard",
},
]
: []),
...(moduleConfig.performance
? [
{
id: "performanceManagement",
label: t("nav.PerformanceManagement", "Performance Management"),
path: "/performance-management/plan-years",
},
]
: []),
...(moduleConfig.objective
? [
{
id: "objectiveManagement",
label: t("nav.objectiveManagement", "Objective Management"),
path: "/objective-management/plan-years",
},
]
: []),
...(moduleConfig.dms
? [
{
id: "documentManagement",
label: t("nav.DocumentManagement", "Document Management"),
path: "/dms/dashboard",
},
]
: []),
...(isOrgAdmin && moduleConfig.siteManagement
? [
{
id: "orgAdmin",
label: t("nav.admin", "Admin"),
path: "/user-management/user_management-dashboard",
},
]
: []),
...(isSuperAdmin
? [
{
id: "superAdmin",
label: t("OrganizationAdmin", "Super Admin"),
path: "/user-management/dashboard",
},
]
: []),
];
const [openNotifications, setOpenNotifications] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { unseenCount, refresh } = useNotifications({
take: 1,
skip: 0,
orderBy: "createdAt:DESC",
});
// Close notification panel on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setOpenNotifications(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<>
<header className="fixed top-0 left-0 right-0 z-50 flex justify-between items-center gap-3 px-6 py-3 border-b dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm">
<div className="flex items-center gap-2">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 flex-shrink-0 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40"
aria-label={t("nav.moduleNavigationHint")}
title={t("nav.moduleNavigationHint")}
>
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("nav.moduleNavigationHint")}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="start"
className="w-56 dark:bg-gray-900 dark:border-gray-800"
>
{modulesList
.filter((item) => {
const currentPath = pathname;
if (item.id === "homepage") {
return !currentPath.startsWith("/homepage");
}
if (item.id === "recordManagement") {
return !currentPath.startsWith("/record-management");
}
if (item.id === "performanceManagement") {
return !currentPath.startsWith("/performance-management");
}
if (item.id === "objectiveManagement") {
return !currentPath.startsWith("/objective-management");
}
if (item.id === "documentManagement") {
return !currentPath.startsWith("/dms");
}
if (item.id === "complaints") {
return !currentPath.startsWith("/complaints");
}
if (item.id === "orgAdmin" || item.id === "superAdmin") {
return !currentPath.startsWith("/user-management");
}
return true;
})
.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => navigate(item.path)}
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5"
>
<span>{item.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-3">
{/* Notification bell */}
<div className="relative" ref={dropdownRef}>
<Button
variant="ghost"
onClick={() => {
setOpenNotifications((prev) => {
const next = !prev;
if (next) refresh();
return next;
});
}}
aria-label="Toggle Notifications"
className="p-2 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded-full transition-colors duration-200 relative">
<FiBell className="w-5 h-5 text-gray-700 dark:text-gray-300" />
{unseenCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-4.5 rounded-full bg-red-500 px-1.5 py-0.5 text-center text-[10px] font-bold text-white shadow">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</Button>
{openNotifications && (
<div className="absolute right-0 z-50 mt-2 w-88 overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:w-96">
<NotificationList />
</div>
)}
</div>
<Button
onClick={toggleDarkMode}
aria-label="Toggle Dark Mode"
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200"
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
style={{ display: isObjectiveManagementRoute ? "none" : "block" }}>
{isDarkMode ? (
<Sun className="h-5 w-5 text-yellow-500" />
) : (
<Moon className="h-5 w-5 text-gray-600" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 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 transition-colors duration-200 font-medium">
<Globe className="w-4 h-4" />
{getUiLanguageLabel(currentLanguage, t)}
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-[140px] dark:bg-gray-900 dark:border-gray-800">
<DropdownMenuItem
onClick={() => changeLanguage("en")}
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400">
English
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => changeLanguage("am")}
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400">
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center gap-3 cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded-lg px-3 py-2 transition-colors duration-200 group">
<Avatar className="w-9 h-9 border-2 border-primary-100 dark:border-primary-900 group-hover:border-primary-200 dark:group-hover:border-primary-800 transition-colors">
<AvatarFallback className="bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-400 font-semibold text-sm">
{initials.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-xs text-right">
<div className="font-semibold text-gray-900 dark:text-gray-100">
{userDetails?.name?.en}
</div>
<div className="text-gray-500 dark:text-gray-400 text-[11px]">
@{userDetails?.username}
</div>
</div>
<ChevronDown className="w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-52 dark:bg-gray-900 dark:border-gray-800">
<DropdownMenuItem
onClick={() => navigate("/profile")}
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
<User className="w-4 h-4 mr-3" />
{t("header.viewProfile")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate("/change-password")}
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
<Key className="w-4 h-4 mr-3" />
{t("header.changePassword")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleLogout}
className="cursor-pointer hover:bg-red-50 dark:hover:bg-red-900/30 hover:text-red-700 dark:hover:text-red-400 py-2.5">
<LogOut className="w-4 h-4 mr-3" />
{t("header.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
</>
);
};

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
fetchRequirementSatisfied,
AdminSetupAlertResponse,
} from "@/record-management/services/api/alertService";
import { X } from "lucide-react";
export default function AdminSetupAlert({
unitId: propUnitId,
}: {
unitId?: string;
}) {
const [unitId, setUnitId] = useState<string>(propUnitId || "");
const [visible, setVisible] = useState(true);
// Listen for unitChanged events from Content Management.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<string>).detail;
queueMicrotask(() => setUnitId(detail));
};
window.addEventListener("unitChanged", handler);
return () => window.removeEventListener("unitChanged", handler);
}, []);
// TanStack Query for fetching requirements
const { data, isLoading, refetch } = useQuery<AdminSetupAlertResponse>({
queryKey: ["admin-setup-requirements", unitId],
queryFn: () => fetchRequirementSatisfied(unitId),
enabled: !!unitId,
});
const requirements = data?.requirements;
// Optional: refetch automatically on unit change (real-time trigger)
useEffect(() => {
if (unitId) {
refetch();
}
}, [unitId, refetch]);
// Hide if no requirements or all are satisfied
if (!requirements || Object.values(requirements).every(Boolean)) return null;
if (!visible) return null; // hide after dismiss
return (
<div className="relative flex items-center gap-6 p-2 border rounded bg-amber-50 shadow-2xl top-10 left-1/2 -translate-x-1/2 z-[1000] w-full">
{/* Title */}
<h3 className="font-semibold text-amber-700 whitespace-nowrap">
Finish site setup
</h3>
{/* Messages inline */}
{isLoading ? (
<p className="text-sm text-slate-600">Loading requirements...</p>
) : (
<div className="flex flex-wrap gap-6 text-sm text-slate-600">
{!requirements.hasFooter && <span> Missing Footer</span>}
{!requirements.hasSeal && <span> Missing Seal</span>}
{!requirements.hasHeader && <span> Missing Header</span>}
{!requirements.internalPrefix && (
<span> Missing Internal Prefix</span>
)}
{!requirements.externalPrefix && (
<span> Missing External Prefix</span>
)}
{!requirements.internalSuffix && (
<span> Missing Internal Suffix</span>
)}
{!requirements.externalSuffix && (
<span> Missing External Suffix</span>
)}
</div>
)}
{/* Close button */}
<button
onClick={() => setVisible(false)}
className="absolute top-2 right-2 text-amber-700 hover:text-red-600">
<X size={16} />
</button>
</div>
);
}

View File

@@ -0,0 +1,351 @@
import React, { useState } from "react";
import {
FaUpload,
FaCheckCircle,
FaCopy,
FaExclamationCircle,
FaArrowLeft,
} from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import Header from "./Header";
import Footer from "./Footer";
import {
submitComplaint,
ComplaintPayload,
FileInfo,
} from "../../shared/services/complaintService";
const ComplaintForm = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
const [submissionSuccess, setSubmissionSuccess] = useState(false);
const [complaintId, setComplaintId] = useState("");
const [error, setError] = useState<string | null>(null);
const [formValues, setFormValues] = useState({
fullName: "",
subCity: "",
woreda: "",
houseNumber: "",
phone: "",
institution: "",
complaintDetails: "",
complaintWant: "",
complaintPlace: "",
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFormValues({ ...formValues, [e.target.name]: e.target.value });
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!disclaimerAccepted) return;
setLoading(true);
setError(null);
try {
// Prepare file info if files are uploaded
let fileInfo: FileInfo | undefined;
if (files.length > 0) {
const file = files[0]; // Take the first file for now
fileInfo = {
fileName: file.name,
contentType: file.type,
size: file.size,
originalname: file.name,
};
}
// Prepare the complaint payload
const payload: ComplaintPayload = {
fullName: formValues.fullName,
phoneNumber: formValues.phone,
subCity: formValues.subCity,
woreda: formValues.woreda,
houseNumber: formValues.houseNumber,
institution: formValues.institution,
complaintPlace: formValues.complaintPlace,
complaintDetail: formValues.complaintDetails,
desiredResolution: formValues.complaintWant,
fileInfo,
};
// Submit the complaint
const response = await submitComplaint(payload);
if (response.data) {
setComplaintId(response.data.complaintNumber || response.data.id);
setSubmissionSuccess(true);
toast.success(t("complaint.success"));
}
} catch (err: any) {
console.error("Error submitting complaint:", err);
const errorMessage =
err.response?.data?.message || err.message || t("complaint.error");
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<div>
<Header />
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex flex-col pt-16 md:pt-20">
{/* Hero Section */}
<div className="bg-primary py-16 text-center text-white relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10"></div>
<h1 className="text-4xl md:text-5xl font-bold mb-4 relative z-10 animate-fade-in">
{t("complaint.title")}
</h1>
<p className="max-w-2xl mx-auto text-lg opacity-90 relative z-10">
{t("complaint.subtitle")}
</p>
</div>
{/* Main Form */}
<div className="flex-1 flex justify-center p-6">
<div className="w-full max-w-3xl bg-white shadow-2xl rounded-2xl p-8 relative border border-gray-100">
{/* Floating circles */}
<div className="absolute -top-6 -left-6 w-16 h-16 bg-blue-100 rounded-full opacity-30 animate-pulse"></div>
<div className="absolute -bottom-6 -right-6 w-24 h-24 bg-blue-100 rounded-full opacity-30 animate-ping"></div>
<h2 className="text-2xl font-semibold text-blue-800 mb-8 text-center">
{t("complaint.formTitle")}
</h2>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2">
<FaExclamationCircle className="text-red-500" />
<span className="text-red-700 font-medium">
{t("complaint.error")}
</span>
</div>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
)}
{submissionSuccess ? (
<div className="text-center animate-fade-in">
<FaCheckCircle className="text-primary-500 text-5xl mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">
{t("complaint.success")}
</h3>
<p className="mb-4 text-gray-600">{t("complaint.keepId")}</p>
<div className="bg-gray-100 border rounded-lg p-4 flex justify-center items-center gap-3">
<span className="font-bold text-blue-700">{complaintId}</span>
<button
className="p-2 rounded hover:bg-gray-200"
onClick={() => navigator.clipboard.writeText(complaintId)}
>
<FaCopy />
</button>
</div>
<div className="flex justify-center gap-4 mt-6">
<button
onClick={() => setSubmissionSuccess(false)}
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg shadow hover:bg-primary/90 transition"
>
{t("complaint.close")}
</button>
<button
onClick={() => navigate("/complaints")}
className="px-6 py-3 bg-gray-300 text-gray-800 rounded-lg shadow hover:bg-gray-400 transition flex items-center gap-2"
>
<FaArrowLeft /> {t("complaint.back")}
</button>
</div>
</div>
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 animate-fade-in"
>
{/* Inputs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<input
type="text"
name="fullName"
value={formValues.fullName}
onChange={handleChange}
placeholder={t("complaint.fullName")}
required
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
/>
<input
type="tel"
name="phone"
value={formValues.phone}
onChange={handleChange}
placeholder={t("complaint.phone")}
required
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
/>
<input
type="text"
name="subCity"
value={formValues.subCity}
onChange={handleChange}
placeholder={t("complaint.subCity")}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
/>
<input
type="text"
name="woreda"
value={formValues.woreda}
onChange={handleChange}
placeholder={t("complaint.woreda")}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
/>
<input
type="text"
name="houseNumber"
value={formValues.houseNumber}
onChange={handleChange}
placeholder={t("complaint.houseNumber")}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
/>
<input
type="text"
name="institution"
value={formValues.institution}
onChange={handleChange}
placeholder={t("complaint.institution")}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
/>
</div>
<input
type="text"
name="complaintPlace"
value={formValues.complaintPlace}
onChange={handleChange}
placeholder={t("complaint.place")}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
/>
<textarea
name="complaintDetails"
value={formValues.complaintDetails}
onChange={handleChange}
placeholder={t("complaint.details")}
rows={4}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
></textarea>
<textarea
name="complaintWant"
value={formValues.complaintWant}
onChange={handleChange}
placeholder={t("complaint.want")}
rows={4}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-400 outline-none"
></textarea>
{/* File Upload */}
{/* <div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("complaint.evidence")}
</label>
<div className="flex items-center gap-3">
<input
type="file"
multiple
onChange={handleFileChange}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4
file:rounded-lg file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
<FaUpload className="text-gray-400" />
</div>
{files.length > 0 && (
<p className="text-sm text-gray-500 mt-2">
{t("complaint.selectedFiles")}:{" "}
{files.map((f) => f.name).join(", ")}
</p>
)}
</div> */}
{/* Disclaimer */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<FaExclamationCircle className="text-red-500" />
<span className="font-semibold text-red-700">
{t("complaint.disclaimerTitle")}
</span>
</div>
<p className="text-sm text-gray-700">
{t("complaint.disclaimerText")}
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={disclaimerAccepted}
onChange={(e) => setDisclaimerAccepted(e.target.checked)}
className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
/>
<span className="text-sm text-gray-700">
{t("complaint.accept")}
</span>
</div>
{/* Submit + Back */}
<div className="flex justify-between items-center gap-4">
<button
type="button"
onClick={() => navigate("/complaints")}
className="flex items-center gap-2 px-5 py-3 bg-gray-300 text-gray-800 rounded-lg shadow hover:bg-gray-400 transition"
>
<FaArrowLeft /> {t("complaint.back")}
</button>
<button
type="submit"
disabled={!disclaimerAccepted || loading}
className={`px-6 py-3 rounded-lg text-white font-semibold transition flex items-center justify-center gap-2
${
loading || !disclaimerAccepted
? "bg-gray-400 cursor-not-allowed"
: "bg-primary hover:bg-primary-700"
}`}
>
{loading && (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
)}
{loading
? t("complaint.submitting")
: t("complaint.submit")}
</button>
</div>
</form>
)}
</div>
</div>
</div>
<Footer />
</div>
);
};
export default ComplaintForm;

View File

@@ -0,0 +1,229 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useState } from "react";
const FeaturesSection = () => {
const { t } = useTranslation();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const features = [
{
name: t("landingPage.outgoingRecords"),
description: t("landingPage.outgoingRecordsDesc"),
icon: (
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
),
color: "bg-primary",
},
{
name: t("landingPage.incomingRecords"),
description: t("landingPage.incomingRecordsDesc"),
icon: (
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
),
color: "bg-primary-500",
},
{
name: t("landingPage.approvalWorkflows"),
description: t("landingPage.approvalWorkflowsDesc"),
icon: (
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
color: "bg-[#3B82F6]",
},
{
name: t("landingPage.interactiveDashboard"),
description: t("landingPage.interactiveDashboardDesc"),
icon: (
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
),
color: "bg-[#8B5CF6]",
},
{
name: t("landingPage.organizedFolders"),
description: t("landingPage.organizedFoldersDesc"),
icon: (
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
),
color: "bg-[#EC4899]",
},
{
name: t("landingPage.securityAccessControl"),
description: t("landingPage.securityAccessControlDesc"),
icon: (
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
),
color: "bg-[#F59E0B]",
},
];
return (
<div className="py-16 bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="lg:text-center mb-16"
>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary/10 text-primary">
{t("landingPage.powerfulFeaturesTitle")}
</span>
<h2 className="mt-4 text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white sm:text-5xl">
<span className="block">
{t("landingPage.powerfulFeaturesSubtitle")}
</span>
<span className="block text-primary">
{t("landingPage.documentWorkflow")}
</span>
</h2>
<p className="mt-6 max-w-3xl text-xl text-gray-600 dark:text-gray-300 lg:mx-auto">
{t("landingPage.powerfulFeaturesDesc")}
</p>
</motion.div>
<div className="mt-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{features.map((feature, index) => (
<motion.div
key={feature.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
onHoverStart={() => setHoveredIndex(index)}
onHoverEnd={() => setHoveredIndex(null)}
className="relative"
>
<div
className={`absolute -inset-0.5 rounded-xl ${
feature.color
} blur opacity-75 transition duration-500 ${
hoveredIndex === index ? "opacity-100" : "opacity-0"
}`}
></div>
<div className="relative bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 h-full transition-all duration-300 hover:border-primary/30">
<div
className={`flex items-center justify-center h-12 w-12 rounded-lg ${feature.color} text-white mb-4`}
>
{feature.icon}
</div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{feature.name}
</h3>
<p className="text-gray-600 dark:text-gray-300">{feature.description}</p>
<div className="mt-4">
</div>
</div>
</motion.div>
))}
</div>
</div>
{/* Stats Section */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
viewport={{ once: true }}
className="mt-20 bg-gradient-to-r from-primary to-primary-500 rounded-2xl shadow-xl overflow-hidden"
>
<div className="max-w-7xl mx-auto py-12 px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
{[
{ number: "95%", label: t("landingPage.fasterApproval") },
{ number: "10x", label: t("landingPage.auditReady") },
{ number: "100%", label: t("landingPage.organizedRecords") },
].map((stat, index) => (
<div key={index} className="px-6 py-8">
<p className="text-4xl font-extrabold text-white">
{stat.number}
</p>
<p className="mt-2 text-lg font-medium text-white/90">
{stat.label}
</p>
</div>
))}
</div>
</div>
</motion.div>
</div>
</div>
);
};
export default FeaturesSection;

View File

@@ -0,0 +1,172 @@
import React, { useState } from "react";
import { FaExclamationCircle, FaArrowLeft } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Header from "./Header";
import Footer from "./Footer";
import { getComplaintById } from "../../shared/services/complaintService";
const FollowCompliant = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [searchButton, setSearchButton] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchId, setSearchId] = useState("");
const [formValues, setFormValues] = useState({
complaintNumber: "",
status: "",
createdAt: "",
message: "",
});
const handleSubmit = async (id: string) => {
try {
setLoading(true); // start spinner
const response = await getComplaintById(id);
if (response) {
setFormValues({
complaintNumber: response.data.complaintNumber || "",
status: response.data.status || "",
createdAt: response.data.createdAt || "",
message: response.data.message || "",
});
}
} catch (error) {
setError(t("complaint.errorMsg"));
} finally {
setLoading(false); // stop spinner
}
};
return (
<div>
<Header />
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex flex-col pt-16 md:pt-20">
{/* Hero Section */}
<div className="bg-primary py-16 text-center text-white relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10"></div>
<h1 className="text-4xl md:text-5xl font-bold mb-4 relative z-10 animate-fade-in">
{t("complaint.followTitle")}
</h1>
<p className="max-w-2xl mx-auto text-lg opacity-90 relative z-10">
{t("complaint.followSubtitle")}
</p>
</div>
{/* Main Form */}
<div className="flex-1 flex justify-center p-6">
<div className="w-full max-w-3xl bg-white shadow-2xl rounded-2xl p-8 relative border border-gray-100">
{/* Floating circles */}
<div className="absolute -top-6 -left-6 w-16 h-16 bg-blue-100 rounded-full opacity-30 animate-pulse"></div>
<h2 className="text-2xl font-semibold text-blue-800 mb-8 text-center">
{t("complaint.followTitle")}
</h2>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2">
<FaExclamationCircle className="text-red-500" />
<span className="text-red-700 font-medium">
{t("complaint.error")}
</span>
</div>
<p className="text-red-600 text-sm mt-1">{error}</p>
</div>
)}
<form className="space-y-6 animate-fade-in">
{/* View Fields */}
<div className="flex gap-2">
<label htmlFor="complaintId" className="self-center">
{t("complaint.compliantId")}
</label>
<input
id="complaintId"
type="text"
value={searchId}
onChange={(e) => {
const value = e.target.value;
setSearchId(value);
setSearchButton(value.trim() !== "");
}}
placeholder={t("complaint.enterComNum")}
className="border rounded px-3 py-2 w-full"
/>
<button
type="button"
disabled={!searchButton || loading}
onClick={() => handleSubmit(searchId)}
className={`flex items-center justify-center gap-2 px-4 py-2 rounded transition ${
searchButton && !loading
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
>
{loading ? (
<svg
className="animate-spin h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
></path>
</svg>
) : (
t("userRecord.Search")
)}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<p className="w-full p-3 border rounded-lg bg-gray-50 text-gray-700">
{formValues.complaintNumber || t("complaint.complaintNo")}
</p>
<p className="w-full p-3 border rounded-lg bg-gray-50 text-gray-700">
{formValues.status || t("userRecord.Status")}
</p>
<p className="w-full p-3 border rounded-lg bg-gray-50 text-gray-700">
{formValues.createdAt || t("userRecord.createdAt")}
</p>
</div>
{/* Message in full width */}
<div className="mt-6">
<p className="w-full min-h-[120px] p-3 border rounded-lg bg-gray-50 text-gray-700 whitespace-pre-line">
{formValues.message || t("complaint.message")}
</p>
</div>
{/* Back */}
<div className="flex justify-end items-center gap-4">
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center cursor-pointer gap-2 px-5 py-3 bg-gray-300 text-gray-800 rounded-lg shadow hover:bg-gray-400 transition"
>
<FaArrowLeft /> {t("complaint.back")}
</button>
</div>
</form>
</div>
</div>
</div>
<Footer />
</div>
);
};
export default FollowCompliant;

View File

@@ -0,0 +1,374 @@
import {
TooltipContent,
TooltipProvider,
TooltipTrigger,
Tooltip,
} from "@/shared/common/ui/tooltip";
import { motion } from "framer-motion";
import { Share2 as Facebook, Globe, Globe as Linkedin, Mail, MapPin, Phone } 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";
import { useNavigate } from "react-router-dom";
import { useTenantConfig } from "@/layout/components/TenantConfig";
const Footer = () => {
const [activeTab, setActiveTab] = useState("");
const navigate = useNavigate();
const { t } = useTranslation();
const { config: tenantConfig } = useTenantConfig();
const defaultFooterData = {
address: "XQXP+J6C Lingo Tower, Namibia St, Addis Ababa",
phone: "+251-955-232323",
email: "sales@triaplc.com",
};
const footerData = tenantConfig?.footerData || defaultFooterData;
const navItems = [
{ id: "overview", label: t("landingPage.overview") },
{ id: "features", label: t("landingPage.features") },
{ id: "workflow", label: t("landingPage.workflow") },
{ id: "howto", label: t("landingPage.manual") },
];
const navigateTo = "/";
const handleAddressClick = () => {
const address =
footerData.address || "XQXP+J6C Lingo Tower, Namibia St, Addis Ababa";
const query = encodeURIComponent(address);
window.open(
`https://www.google.com/maps/search/?api=1&query=${query}`,
"_blank",
"noopener noreferrer",
);
};
const handleEmailClick = () => {
const email = footerData.email || "sales@triaplc.com";
const subject = "";
const body = "";
const gmailParams = new URLSearchParams();
gmailParams.append("to", email);
if (subject) gmailParams.append("su", subject);
if (body) gmailParams.append("body", body);
const gmailUrl = `https://mail.google.com/mail/?view=cm&fs=1&${gmailParams.toString()}`;
const mailtoUrl = `mailto:${email}?subject=${encodeURIComponent(subject || "")}&body=${encodeURIComponent(body || "")}`;
window.open(gmailUrl, "_blank", "noopener noreferrer");
setTimeout(() => {
window.open(mailtoUrl, "_blank", "noopener noreferrer");
}, 500);
};
const socials = tenantConfig?.socials || {};
const socialLinks = {
facebook: socials.facebook || "https://web.facebook.com/Triaplc",
twitter: socials.twitter || "https://x.com/Triaplc",
linkedin: socials.linkedin || "https://www.linkedin.com/company/triaplc",
telegram: socials.telegram || "",
tiktok: socials.tiktok || "",
website: socials.website || "https://triaplc.com/",
};
const optionalSocialLinks = [
{
href: socialLinks.telegram,
label: t("landingPage.telegram"),
icon: FaTelegramPlane,
},
{
href: socialLinks.tiktok,
label: t("landingPage.tiktok"),
icon: FaTiktok,
},
].filter((item) => item.href);
return (
<footer className="bg-gradient-to-b from-gray-900 to-gray-800 dark:bg-gradient-to-b dark:from-gray-900 dark:to-gray-800">
<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">
<div className="space-y-8 xl:col-span-1">
<div className="flex items-center">
{tenantConfig?.logo ? (
<div className="relative group inline-flex">
{/* Glow */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-primary/20 to-secondary/20 blur-xl opacity-0 group-hover:opacity-100 transition-all duration-500 scale-110" />
{/* Footer Logo Wrapper */}
<motion.div
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 220, damping: 16 }}
className="relative inline-flex items-center justify-center cursor-pointer group"
onClick={() => (window.location.href = "/")}
title={t("nav.homePage")}
>
<motion.img
src={tenantConfig.logo}
alt="Footer Logo"
className="
h-35
w-auto
object-contain
group-hover:scale-105
transition-transform
duration-300
"
onError={(e) => {
const fallback =
e.currentTarget.parentElement?.querySelector(
".footer-logo-fallback",
);
if (fallback) {
fallback.classList.remove("hidden");
}
e.currentTarget.remove();
}}
/>
{/* Fallback when logo fails to load */}
<div
className="
footer-logo-fallback
hidden
h-20
w-20
items-center
justify-center
rounded-full
bg-primary
text-primary-foreground
font-bold
text-2xl
pointer-events-none
"
>
{tenantConfig?.organizationName
?.split(/[\s\-–—]+/)
.filter(Boolean)
.map((word) => word.charAt(0))
.join("")
.toUpperCase() || "SO"}
</div>
</motion.div>
</div>
) : (
<div className="h-10 w-10 rounded-full bg-primary flex items-center justify-center text-white font-bold text-xl">
SO
</div>
)}
<span className="ml-3 text-2xl font-bold text-white">
{tenantConfig?.organizationName || t("landingPage.smartOffice")}
</span>
</div>
<p className="text-gray-300 text-lg max-h-20 overflow-y-auto pr-2">
{tenantConfig?.footerText ||
t("landingPage.digitalTransformation")}
</p>
<div className="flex space-x-6">
<a
href={socialLinks.facebook}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors duration-300"
>
<li className="flex items-center space-x-2 text-base text-gray-400 hover:text-primary transition-colors duration-300 cursor-pointer">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Facebook className="h-6 w-6 hover:text-primary" />
</TooltipTrigger>
<TooltipContent>
<span>{t("landingPage.facebook")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="sr-only">{t("landingPage.facebook")}</span>
</li>
</a>
<a
href={socialLinks.twitter}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors duration-300"
>
<li className="flex items-center space-x-2 text-base text-gray-400 hover:text-primary transition-colors duration-300 cursor-pointer">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<FaXTwitter className="h-6 w-6 hover:text-primary" />
</TooltipTrigger>
<TooltipContent>
<span>{t("landingPage.twitter")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="sr-only">{t("landingPage.twitter")}</span>
</li>
</a>
<a
href={socialLinks.linkedin}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors duration-300"
>
<li className="flex items-center space-x-2 text-base text-gray-400 hover:text-primary transition-colors duration-300 cursor-pointer">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Linkedin className="h-6 w-6 hover:text-primary" />
</TooltipTrigger>
<TooltipContent>
<span>{t("landingPage.linkedIn")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="sr-only">{t("landingPage.linkedIn")}</span>
</li>
</a>
{optionalSocialLinks.map(({ href, label, icon: Icon }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors duration-300"
>
<li className="flex items-center space-x-2 text-base text-gray-400 hover:text-primary transition-colors duration-300 cursor-pointer">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Icon className="h-6 w-6 hover:text-primary" />
</TooltipTrigger>
<TooltipContent>
<span>{label}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="sr-only">{label}</span>
</li>
</a>
))}
<a
href={socialLinks.website}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors duration-300"
>
<li className="flex items-center space-x-2 text-base text-gray-400 hover:text-primary transition-colors duration-300 cursor-pointer">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Globe className="h-6 w-6 hover:text-primary" />
</TooltipTrigger>
<TooltipContent>
<span>{t("landingPage.website")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="sr-only">{t("landingPage.website")}</span>
</li>
</a>
</div>
</div>
<div className="mt-12 xl:mt-0 xl:col-span-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-gray-300 tracking-wider uppercase">
{t("landingPage.contactUs")}
</h3>
<ul className="mt-4 space-y-4">
<li
onClick={handleEmailClick}
title={footerData.email || "sales@triaplc.com"}
className="flex items-center space-x-3 text-base text-gray-400 hover:text-primary transition-colors duration-300 cursor-pointer"
>
<Mail className="h-5 w-5 hover:text-primary" />
<span className="font-medium">
{footerData.email || "sales@triaplc.com"}
</span>
</li>
<li className="flex items-center space-x-3 text-base text-gray-400 hover:text-primary transition-colors duration-300 cursor-pointer">
<Phone className="h-5 w-5 hover:text-primary" />
<span className="font-medium">
{footerData.phone || "+251-955-232323"}
</span>
</li>
<li
onClick={handleAddressClick}
title={
footerData.address ||
"XQXP+J6C Lingo Tower, Namibia St, Addis Ababa"
}
className="flex items-center space-x-3 text-base text-gray-400 hover:text-primary transition-colors duration-300 cursor-pointer"
>
<MapPin className="h-5 w-5 hover:text-primary" />
<span className="font-medium">
{footerData.address || t("landingPage.address")}
</span>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-300 tracking-wider uppercase">
{t("landingPage.quickLinks")}
</h3>
<ul className="mt-4 space-y-4">
{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={`cursor-pointer text-base text-gray-400 hover:text-primary transition-colors duration-300 flex items-center ${
activeTab === item.id
? "text-gray-400"
: "text-gray-400"
}`}
>
{item.label}
{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,
}}
/>
)}
</button>
))}
</ul>
</div>
</div>
</div>
</div>
<div className="mt-12 border-t border-gray-700 pt-8 flex flex-col items-center justify-center text-center">
<p className="text-base text-gray-400">
&copy; {t("landingPage.copyright")}
{new Date().getFullYear()} {t("landingPage.rightsReserved")}
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,703 @@
import { useState, useEffect, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useNavigate, useLocation } from "react-router-dom";
import { ExternalPortal } from "@/external-portal/components/External-Portal-Navigation/PortalHeader";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { toast } from "sonner";
import {
ChevronDown,
FileText,
Search,
Info,
Menu,
X,
Moon,
Sun,
} from "lucide-react";
import { useUser } from "@/shared/context/UserContext";
import { useDarkMode } from "@/shared/hooks/useDarkMode";
import { useTenantConfig, resolveModuleConfig } from "@/layout/components/TenantConfig";
import {
UI_LANGUAGE_OPTIONS,
resolveUiLanguage,
} from "@/shared/i18n/uiLanguages";
import {
hasComplaintVerification,
isComplaintAuthContext,
} from "@/complaints/utils/complaintVerificationStorage";
import { COMPLAINT_RECORDS_PATH } from "@/complaints/utils/complaintRoutes";
import { useExternalPortalSession } from "@/shared/hooks/useExternalPortalSession";
interface NavItem {
id: string;
label: string;
path?: string;
requiresCompleteRegistration?: true;
children?: NavItem[];
}
const languageOptions = UI_LANGUAGE_OPTIONS;
const Header = () => {
const [activeTab, setActiveTab] = useState("overview");
const [scrolled, setScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [servicesOpen, setServicesOpen] = useState(false);
const [navigateTo, setNaviageTo] = useState("/");
const navigate = useNavigate();
const location = useLocation();
const isComplaintContext = isComplaintAuthContext(location.pathname);
const { isAuthenticated, showExternalPortalChrome } = useExternalPortalSession();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { config: tenantConfig } = useTenantConfig();
const moduleConfig = resolveModuleConfig(tenantConfig);
const userDetails = useUser();
const { t, i18n } = useTranslation();
const currentLanguage = resolveUiLanguage(i18n.language);
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
const changeLanguage = (lng: string) => i18n.changeLanguage(lng);
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 handleSignIn = () => {
navigate("/login");
};
useEffect(() => {
if (location.pathname.includes("/external-portal/portal-outgoing")) {
setActiveTab("records");
return;
}
if (location.pathname.startsWith("/complaints")) {
setActiveTab("complaint");
}
}, [location.pathname]);
const navItems: NavItem[] = useMemo(() => {
if (showExternalPortalChrome) {
return [];
}
if (isAuthenticated) {
return [
{ id: "overview", label: t("landingPage.overview") },
{ id: "workflow", label: t("landingPage.workflow") },
];
}
const complaintServices: NavItem[] = moduleConfig.complaint
? [
{
id: "complaint",
label: t("landingPage.fileComplaint"),
path: "/complaints",
},
{
id: "follow-complaint",
label: t("landingPage.followComplaint"),
path: "/follow-complaint",
},
]
: [];
const items: NavItem[] = [
{ id: "overview", label: t("landingPage.overview") },
{ id: "features", label: t("landingPage.features") },
];
if (complaintServices.length > 0) {
items.push({
id: "services",
label: t("landingPage.services"),
children: complaintServices,
});
}
items.push(
{ id: "workflow", label: t("landingPage.workflow") },
{ id: "howto", label: t("landingPage.manual") },
);
return items;
}, [isAuthenticated, moduleConfig.complaint, showExternalPortalChrome, t]);
const handleNavClick = (item: NavItem) => {
if (
item.requiresCompleteRegistration &&
!hasCompletedRegistration &&
!hasComplaintVerification()
) {
toast.error(t("registration.registrationRequired"));
return;
}
if (item.path) {
navigate(item.path);
setMobileMenuOpen(false);
}
};
// Animation variants
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.1,
},
},
};
const mobileItemVariants = {
hidden: { x: -20, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: {
x: { stiffness: 1000, velocity: -100 },
},
},
};
const servicesVariants = {
hidden: {
opacity: 0,
y: -10,
scale: 0.95,
transition: {
duration: 0.2,
},
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.2,
staggerChildren: 0.05,
},
},
};
const serviceItemVariants = {
hidden: { opacity: 0, y: -5 },
visible: { opacity: 1, y: 0 },
};
const getServiceIcon = (id: string) => {
switch (id) {
case "complaint":
return <FileText className="w-4 h-4" />;
case "follow-complaint":
return <Search className="w-4 h-4" />;
case "about-us":
return <Info className="w-4 h-4" />;
default:
return <FileText className="w-4 h-4" />;
}
};
// Get current language display name
const getCurrentLanguageDisplay = () => {
const currentLang = languageOptions.find(
(lang) => lang.value === currentLanguage,
);
return currentLang ? currentLang.label : "English";
};
useMemo(() => {
if (showExternalPortalChrome) {
setNaviageTo(COMPLAINT_RECORDS_PATH);
}
}, [showExternalPortalChrome]);
return (
<>
{/* Registration Alert - Fixed for mobile */}
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled
? "bg-white dark:bg-gray-900 shadow-lg"
: "bg-white dark:bg-gray-900 shadow-sm"
} `}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center gap-4 h-16 md:h-20">
{/* Logo Section */}
<div className="flex items-center">
{!showExternalPortalChrome ? (
<>
{tenantConfig?.logo ? (
<div className="relative group inline-flex">
{/* Glow */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-primary/20 to-secondary/20 blur-xl opacity-0 group-hover:opacity-100 transition-all duration-500 scale-110" />
{/* Footer Logo Wrapper */}
<motion.div
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 220, damping: 16 }}
className="relative inline-flex items-center justify-center cursor-pointer group"
onClick={() =>
navigate(
showExternalPortalChrome
? COMPLAINT_RECORDS_PATH
: isComplaintContext
? "/complaints"
: "/",
)
}
title={t("nav.homePage")}
>
<motion.img
src={tenantConfig.logo}
alt="Footer Logo"
className="
h-15 md:h-30
w-auto
object-contain
group-hover:scale-105
transition-transform
duration-300
"
onError={(e) => {
const fallback =
e.currentTarget.parentElement?.querySelector(
".footer-logo-fallback",
);
if (fallback) {
fallback.classList.remove("hidden");
}
e.currentTarget.remove();
}}
/>
{/* Fallback when logo fails to load */}
<div
className="
footer-logo-fallback
hidden
h-20
w-20
items-center
justify-center
rounded-full
bg-primary
text-primary-foreground
font-bold
text-2xl
pointer-events-none
"
>
{tenantConfig?.organizationName
?.split(/[\s\-–—]+/)
.filter(Boolean)
.map((word) => word.charAt(0))
.join("")
.toUpperCase() || "SO"}
</div>
</motion.div>
</div>
) : (
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="h-10 w-10 md:h-12 md:w-12 rounded-full bg-primary flex items-center justify-center text-white font-bold cursor-pointer"
onClick={() => navigate(navigateTo)}
title={t("nav.homePage")}
>
SO
</motion.div>
)}
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="ml-2 md:ml-3 text-lg md:text-xl font-bold text-gray-800 dark:text-white cursor-pointer"
onClick={() => navigate(navigateTo)}
title={t("nav.homePage")}
>
{tenantConfig?.organizationName ||
tenantConfig?.organizationName == ""
? tenantConfig?.organizationName
: t("landingPage.smartOffice")}
</motion.span>
</>
) : null}
{/* Desktop Navigation */}
<div className="hidden md:ml-6 lg:ml-10 md:flex md:space-x-4 lg:space-x-6">
{navItems.map((item) => (
<div key={item.id} className="relative">
{item.children ? (
<div
className="relative"
onMouseEnter={() => setServicesOpen(true)}
onMouseLeave={() => setServicesOpen(false)}
>
<button
onClick={() => setServicesOpen(!servicesOpen)}
className={`relative inline-flex items-center px-1 pt-1 text-sm lg:text-base font-medium transition-colors duration-200 cursor-pointer ${
activeTab === item.id
? "text-gray-900 dark:text-white"
: "text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white hover:underline"
}`}
>
{item.label}
<ChevronDown
className={`ml-1 w-4 h-4 transition-transform ${
servicesOpen ? "rotate-180" : ""
}`}
/>
</button>
<AnimatePresence>
{servicesOpen && (
<motion.div
initial="hidden"
animate="visible"
exit="hidden"
variants={servicesVariants}
className="absolute top-full left-0 mt-2 w-64 bg-white rounded-lg shadow-xl border border-gray-200 z-50 overflow-hidden"
>
<div className="py-2">
{item.children.map((child) => (
<motion.button
key={child.id}
variants={serviceItemVariants}
onClick={() => handleNavClick(child)}
className="w-full flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition-colors duration-200"
>
<span className="mr-3 text-primary">
{getServiceIcon(child.id)}
</span>
{child.label}
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<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-1 pt-1 text-sm lg:text-base font-medium transition-colors duration-200 cursor-pointer ${
(item.path
? location.pathname.startsWith(item.path)
: activeTab === item.id)
? "text-gray-900 dark:text-white"
: "text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white hover:underline"
}`}
>
{item.label}
{(item.path
? location.pathname.startsWith(item.path)
: 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,
}}
/>
)}
</button>
)}
</div>
))}
</div>
</div>
{/* Desktop Sign Up Button */}
{!isAuthenticated && (
<div className="hidden md:flex items-center space-x-2 lg:space-x-4">
<div className="w-28 lg:w-32">
<Select
value={currentLanguage}
onValueChange={(lng) => changeLanguage(lng)}
>
<SelectTrigger className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 px-2 py-1.5 lg:px-3 lg:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<SelectValue>{getCurrentLanguageDisplay()}</SelectValue>
</SelectTrigger>
<SelectContent className="dark:bg-gray-800 dark:border-gray-700">
{languageOptions.map((lang) => (
<SelectItem
key={lang.value}
value={lang.value}
className="dark:text-gray-200 dark:hover:bg-gray-700"
>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Dark Mode Toggle */}
<motion.button
onClick={toggleDarkMode}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200"
title={
isDarkMode ? "Switch to light mode" : "Switch to dark mode"
}
>
{isDarkMode ? (
<Sun className="h-5 w-5 text-yellow-500" />
) : (
<Moon className="h-5 w-5 text-gray-600" />
)}
</motion.button>
<motion.button
onClick={handleSignIn}
whileHover={{
scale: 1.03,
boxShadow: "0 4px 12px rgba(24, 170, 157, 0.2)",
}}
whileTap={{ scale: 0.98 }}
className="inline-flex items-center px-3 py-1.5 lg:px-4 lg:py-2.5 border border-transparent text-sm font-medium rounded-lg shadow-sm text-white bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition-all duration-200 whitespace-nowrap"
>
{t("landingPage.signIn")}
</motion.button>
</div>
)}
{showExternalPortalChrome && (
<div className="hidden md:flex items-center">
<ExternalPortal />
</div>
)}
{/* Mobile Menu Button */}
<div className="flex items-center md:hidden">
<motion.button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="inline-flex items-center justify-center p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary transition-colors duration-200"
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>
{/* Mobile Menu */}
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
initial="hidden"
animate="visible"
exit="hidden"
variants={mobileMenuVariants}
className="md:hidden overflow-hidden bg-white dark:bg-gray-800 shadow-xl border-t border-gray-200 dark:border-gray-700"
>
<motion.div className="pt-2 pb-4 space-y-1 px-4">
{navItems.map((item) => (
<div key={item.id}>
{item.children ? (
<div>
<button
onClick={() => setServicesOpen(!servicesOpen)}
className={`block w-full text-left pl-3 pr-4 py-3 border-l-4 text-base font-medium transition-all duration-200 ${
activeTab === item.id
? "bg-primary-500/15 border-primary text-primary"
: "border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800"
}`}
>
<div className="flex items-center justify-between">
{item.label}
<ChevronDown
className={`w-4 h-4 transition-transform ${
servicesOpen ? "rotate-180" : ""
}`}
/>
</div>
</button>
<AnimatePresence>
{servicesOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="pl-6 overflow-hidden"
>
{item.children.map((child) => (
<motion.button
key={child.id}
onClick={() => handleNavClick(child)}
className="block w-full text-left pl-3 pr-4 py-2 text-sm text-gray-600 hover:bg-blue-50 hover:text-blue-700 transition-colors duration-200"
>
<div className="flex items-center">
<span className="mr-2 text-primary">
{getServiceIcon(child.id)}
</span>
{child.label}
</div>
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<motion.button
variants={mobileItemVariants}
onClick={() => {
setActiveTab(item.id);
setMobileMenuOpen(false);
if (item.path) {
handleNavClick(item);
}
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={`block w-full text-left pl-3 pr-4 py-3 border-l-4 text-base font-medium transition-all duration-200 ${
activeTab === item.id
? "bg-primary-500/15 border-primary text-primary"
: "border-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 hover:text-gray-800 dark:hover:text-white"
}`}
>
{item.label}
</motion.button>
)}
</div>
))}
{!isAuthenticated && (
<motion.div
variants={mobileItemVariants}
className="pt-4 pb-2 border-t border-gray-200 dark:border-gray-700 space-y-3"
>
<div className="px-2">
<Select
value={currentLanguage}
onValueChange={(lng) => changeLanguage(lng)}
>
<SelectTrigger className="w-full text-sm bg-gray-100 dark:bg-gray-700 border-none rounded-md px-3 py-2.5 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary dark:text-gray-200">
<SelectValue>
{getCurrentLanguageDisplay()}
</SelectValue>
</SelectTrigger>
<SelectContent className="dark:bg-gray-800 dark:border-gray-700">
{languageOptions.map((lang) => (
<SelectItem
key={lang.value}
value={lang.value}
className="dark:text-gray-200 dark:hover:bg-gray-700"
>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Dark Mode Toggle for Mobile */}
<motion.button
onClick={toggleDarkMode}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="block w-full px-4 py-3 text-base font-medium text-center text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg shadow hover:bg-gray-200 dark:hover:bg-gray-600 transition-all duration-200 flex items-center justify-center gap-2"
>
{isDarkMode ? (
<>
<Sun className="h-5 w-5 text-yellow-500" />
{t("landingPage.lightMode")}
</>
) : (
<>
<Moon className="h-5 w-5 text-gray-600" />
{t("landingPage.darkMode")}
</>
)}
</motion.button>
<motion.button
onClick={() => {
handleSignIn();
setMobileMenuOpen(false);
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="block w-full px-4 py-3 text-base font-medium text-center text-white bg-primary rounded-lg shadow hover:bg-primary-700 transition-all duration-200"
>
{t("landingPage.signIn")}
</motion.button>
</motion.div>
)}
{showExternalPortalChrome && (
<motion.div
variants={mobileItemVariants}
className="pt-3 border-t border-gray-200"
>
<ExternalPortal
mobileView={true}
onItemClick={() => setMobileMenuOpen(false)}
/>
</motion.div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</nav>
</>
);
};
export default Header;

View File

@@ -0,0 +1,560 @@
import { useEffect, useState } from "react";
import { motion, useAnimation, Variants } from "framer-motion";
import { useInView } from "react-intersection-observer";
import { easeInOut } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Cookies from "js-cookie";
import { ArrowRight, BookOpenText, ShieldCheck } from "lucide-react";
import {
useTenantConfig,
resolveModuleConfig,
} from "@/layout/components/TenantConfig";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
const HeroSection = () => {
const controls = useAnimation();
const { t } = useTranslation();
const navigate = useNavigate();
const { config: tenantConfig } = useTenantConfig();
const moduleConfig = resolveModuleConfig(tenantConfig);
const primary = tenantConfig.primaryColor || "#18AA9D";
const secondary = tenantConfig.secondaryColor || primary;
const [ref, inView] = useInView({
threshold: 0.1,
triggerOnce: true,
});
useEffect(() => {
if (inView) {
controls.start("visible");
} else {
controls.start("hidden");
}
}, [controls, inView]);
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.2,
},
},
};
const itemVariants: Variants = {
hidden: { y: 30, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
duration: 0.6,
ease: easeInOut,
},
},
};
const isTokenPresent = Boolean(Cookies.get("auth-token"));
const heroTitle =
tenantConfig?.organizationName || t("landingPage.smartOffice");
const welcomeMessage =
tenantConfig?.welcomeMessage || t("landingPage.initiativeDescription");
const organizationInitials =
heroTitle
?.split(/[\s\-–—]+/)
.filter(Boolean)
.map((word) => word.charAt(0))
.join("")
.toUpperCase() || "SO";
const cardVariants: Variants = {
hidden: { scale: 0.85, opacity: 0, rotateX: 10 },
visible: {
scale: 1,
opacity: 1,
rotateX: 0,
transition: {
delay: 0.4,
duration: 0.8,
type: "spring",
stiffness: 100,
damping: 15,
},
},
};
const floatingElementVariants: Variants = {
float: {
y: [0, -20, 0],
transition: {
duration: 4,
repeat: Infinity,
ease: "easeInOut", // ✅ correct literal type
},
},
};
return (
<section className="relative bg-gradient-to-br from-white via-blue-50/30 to-primary-50/50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 overflow-x-hidden min-h-screen flex items-start pt-8">
{/* Enhanced Background */}
<div className="absolute inset-0 overflow-hidden">
{/* Gradient Orbs */}
<div className="absolute -top-40 -left-40 w-80 h-80 bg-gradient-to-r from-primary to-primary-500 rounded-full mix-blend-multiply filter blur-3xl opacity-15 animate-orb-slow dark:opacity-10"></div>
<div className="absolute -top-20 -right-20 w-96 h-96 bg-gradient-to-r from-primary-500 to-primary rounded-full mix-blend-multiply filter blur-3xl opacity-10 animate-orb-medium dark:opacity-5"></div>
<div className="absolute -bottom-40 left-1/3 w-72 h-72 bg-gradient-to-r from-primary-700 to-primary-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-orb-fast dark:opacity-10"></div>
{/* Grid Pattern */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(24,170,157,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(24,170,157,0.03)_1px,transparent_1px)] bg-[size:60px_60px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,black,transparent)] dark:bg-[linear-gradient(rgba(24,170,157,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(24,170,157,0.05)_1px,transparent_1px)]"></div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10 pt-12 lg:pt-20">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 lg:gap-20 items-start">
{/* Enhanced Content */}
<motion.div
ref={ref}
initial="hidden"
animate={controls}
variants={containerVariants}
className="space-y-8 lg:space-y-10"
>
{!isTokenPresent && (
<motion.div
variants={itemVariants}
className="inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-primary/10 to-primary-500/10 border border-primary/20 text-primary text-sm font-medium mb-4"
>
<span className="relative flex h-2 w-2 mr-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
{"Online"}
</motion.div>
)}
<motion.h1
variants={itemVariants}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight text-gray-900 dark:text-white leading-tight"
>
<span className="block bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent">
{tenantConfig?.organizationName || t("landingPage.smartOffice")}
</span>
<span className="block text-transparent bg-clip-text bg-gradient-to-r from-primary via-primary-500 to-primary-500">
{t("landingPage.platformSystem")}
</span>
</motion.h1>
<motion.p
variants={itemVariants}
className="text-lg md:text-xl text-gray-600 dark:text-gray-300 max-w-lg leading-relaxed"
>
{welcomeMessage}
</motion.p>
<motion.div variants={itemVariants}>
<Dialog>
<DialogTrigger asChild>
<button className="group inline-flex items-center gap-3 rounded-full border border-primary/25 bg-white/75 px-5 py-3 text-sm font-semibold text-primary shadow-sm shadow-primary/10 backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/45 hover:bg-primary/10 hover:shadow-lg hover:shadow-primary/15 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 dark:bg-gray-900/60 dark:hover:bg-primary/15 dark:focus:ring-offset-gray-900">
<span className="flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors duration-300 group-hover:bg-primary group-hover:text-white">
<BookOpenText className="size-4" />
</span>
<span>{t("newssection.readMore")}</span>
<ArrowRight className="size-4 transition-transform duration-300 group-hover:translate-x-1" />
</button>
</DialogTrigger>
<DialogContent className="max-h-[86vh] max-w-3xl overflow-hidden rounded-2xl border border-primary/15 bg-white/95 p-0 shadow-2xl shadow-primary/10 backdrop-blur-xl dark:border-primary/20 dark:bg-gray-900/95">
<div className="h-1.5 bg-gradient-to-r from-primary via-primary-500 to-primary" />
<DialogHeader className="relative gap-0 px-6 pb-5 pt-6 text-left sm:px-8">
<div className="absolute right-8 top-8 hidden h-24 w-24 rounded-full bg-primary/10 blur-2xl sm:block" />
<div className="relative flex items-start gap-4">
{tenantConfig?.logo ? (
<div className="relative group inline-flex shrink-0">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-primary/20 to-secondary/20 blur-xl opacity-0 transition-all duration-500 scale-110 group-hover:opacity-100" />
<motion.div
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
transition={{
type: "spring",
stiffness: 220,
damping: 16,
}}
className="relative flex h-16 w-24 items-center justify-center overflow-hidden rounded-2xl border-none bg-white/5 shadow-md dark:bg-slate-900/40"
title={heroTitle}
>
<motion.img
src={tenantConfig.logo}
alt={`${heroTitle} logo`}
className="h-full w-full object-contain drop-shadow-lg transition-transform duration-300 group-hover:scale-105"
onError={(e) => {
const fallback =
e.currentTarget.parentElement?.querySelector(
".modal-logo-fallback",
);
if (fallback) {
fallback.classList.remove("hidden");
fallback.classList.add("flex");
}
e.currentTarget.remove();
}}
/>
<div className="modal-logo-fallback absolute inset-0 hidden items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary-500 text-xl font-bold text-white">
{organizationInitials || "SO"}
</div>
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/15 to-transparent transition-transform duration-1000 group-hover:translate-x-full" />
</motion.div>
</div>
) : (
<div className="relative shrink-0">
<div className="absolute inset-0 rounded-2xl bg-primary/20 blur-xl scale-110" />
<div className="relative flex h-16 w-24 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-primary to-primary-500 text-xl font-bold text-white shadow-lg shadow-primary/25 ring-1 ring-white/20">
<span className="drop-shadow-sm">
{organizationInitials || "SO"}
</span>
<div className="absolute inset-x-0 top-0 h-1/2 bg-white/15" />
</div>
</div>
)}
<div className="min-w-0 pt-1">
<DialogTitle className="text-2xl font-bold leading-tight text-gray-950 dark:text-white sm:text-3xl">
{heroTitle}
</DialogTitle>
<DialogDescription className="sr-only">
{welcomeMessage}
</DialogDescription>
<div className="mt-3 h-1 w-20 rounded-full bg-gradient-to-r from-primary to-primary-500" />
</div>
</div>
</DialogHeader>
<div className="border-t border-gray-100 bg-gradient-to-b from-gray-50/80 to-white px-6 py-6 dark:border-gray-800 dark:from-gray-950/40 dark:to-gray-900 sm:px-8">
<div className="rounded-xl border border-gray-100 bg-white p-5 text-base leading-8 text-gray-700 shadow-sm dark:border-gray-800 dark:bg-gray-900/80 dark:text-gray-300 sm:p-6 sm:text-lg">
<p className="whitespace-pre-line">{welcomeMessage}</p>
</div>
</div>
</DialogContent>
</Dialog>
</motion.div>
<motion.div
variants={itemVariants}
className="flex flex-col sm:flex-row flex-wrap gap-4 pt-2"
>
{moduleConfig.complaint && (
<motion.button
type="button"
whileHover={{
scale: 1.05,
boxShadow: `0 20px 40px ${primary}4d`,
}}
whileTap={{ scale: 0.95 }}
onClick={() => navigate("/complaints")}
className="group relative px-8 py-4 rounded-xl bg-gradient-to-r from-primary to-primary-500 text-white font-semibold text-lg shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-white/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<span className="relative flex items-center justify-center">
{t("complaint.fayda.submitComplaint")}
<ShieldCheck className="w-5 h-5 ml-2" />
</span>
</motion.button>
)}
{!isTokenPresent && (
<motion.a
whileHover={{
scale: 1.05,
boxShadow: `0 20px 40px ${primary}4d`,
}}
whileTap={{ scale: 0.95 }}
href="/login"
className="group relative px-8 py-4 rounded-xl bg-gradient-to-r from-primary to-primary-500 text-white font-semibold text-lg shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-white/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<span className="relative flex items-center justify-center">
{t("landingPage.signIn")}
<svg
className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</span>
</motion.a>
)}
<motion.a
whileHover={{
scale: 1.05,
backgroundColor: "rgba(24, 170, 157, 0.08)",
}}
whileTap={{ scale: 0.95 }}
href="#features"
className="group px-8 py-4 rounded-xl border-2 border-primary text-primary font-semibold text-lg hover:shadow-lg transition-all duration-300 flex items-center justify-center"
>
{t("landingPage.learnMore")}
<svg
className="ml-2 w-4 h-4 group-hover:translate-y-0.5 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</motion.a>
</motion.div>
{/* Stats */}
<motion.div
variants={itemVariants}
className="flex flex-wrap gap-8 pt-5 pb-10"
>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
95%
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t("landingPage.uptime")}
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
25+
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t("landingPage.bureaus")}
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
24/7
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t("landingPage.support")}
</div>
</div>
</motion.div>
</motion.div>
{/* Enhanced Card */}
<motion.div
initial="hidden"
animate={controls}
variants={cardVariants}
className="relative"
>
<div className="absolute -inset-4 bg-gradient-to-r from-primary to-primary-500 rounded-3xl opacity-20 blur-xl animate-pulse-slow"></div>
<div className="absolute -inset-2 bg-gradient-to-r from-primary to-primary-500 rounded-2xl opacity-10 blur-lg"></div>
<motion.div
whileHover={{ y: -5, rotateX: 5 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="relative h-full bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl shadow-2xl border border-white/20 dark:border-gray-700 overflow-hidden"
>
{/* Card Header */}
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-primary to-primary-500"></div>
<div className="p-8 lg:p-10 h-full flex flex-col items-center justify-center bg-gradient-to-br from-white/90 to-gray-50/80 dark:from-gray-800/90 dark:to-gray-700/80">
<div className="text-center space-y-6">
{/* Logo/Brand */}
<div className="mb-6">
<div
className="w-16 h-16 mx-auto mb-4 rounded-2xl shadow-lg flex items-center justify-center"
style={{
background: `linear-gradient(135deg, ${primary}, ${secondary})`,
}}
>
<span className="text-white font-bold text-xl">SO</span>
</div>
</div>
<div className="text-4xl lg:text-5xl font-bold mb-2 tracking-tight text-slate-900 dark:text-white">
{t("landingPage.sops")}
</div>
<div className="text-lg font-semibold text-gray-800 dark:text-gray-200">
{tenantConfig.appName}
</div>
<div className="text-gray-600 dark:text-gray-400 max-w-md leading-relaxed">
{t("landingPage.revolutionDescription")}
</div>
{/* Features List */}
<div className="grid grid-cols-2 gap-4 pt-4">
{[
"features1",
"features2",
"features3",
"features4",
"features5",
"features6",
].map((feature, index) => (
<motion.div
key={feature}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.8 + index * 0.1 }}
className="flex items-center text-sm text-gray-600 dark:text-gray-400"
>
<div
className="w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: primary }}
></div>
{t(`landingPage.${feature}`)}
</motion.div>
))}
</div>
<div className="pt-6">
<motion.div
whileHover={{ scale: 1.05 }}
className="inline-flex items-center px-6 py-3 rounded-full font-semibold backdrop-blur-sm"
style={{
background: `linear-gradient(to right, ${primary}1a, ${secondary}1a)`,
color: primary,
border: `1px solid ${primary}33`,
}}
>
<span className="relative flex h-3 w-3 mr-3">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ backgroundColor: primary }}
></span>
<span
className="relative inline-flex rounded-full h-3 w-3"
style={{ backgroundColor: primary }}
></span>
</span>
{t("landingPage.liveDemo")}
</motion.div>
</div>
</div>
</div>
</motion.div>
</motion.div>
</div>
</div>
{/* Enhanced Floating Elements */}
<motion.div
variants={floatingElementVariants}
animate="float"
className="hidden lg:block absolute bottom-20 left-20"
>
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary/20 to-primary-500/20 border border-primary/10 backdrop-blur-sm rotate-45"></div>
</motion.div>
<motion.div
variants={floatingElementVariants}
animate="float"
transition={{ delay: 1 }}
className="hidden lg:block absolute top-32 right-32"
>
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-primary-500/15 to-primary/15 border border-primary-500/10 backdrop-blur-sm"></div>
</motion.div>
<motion.div
variants={floatingElementVariants}
animate="float"
transition={{ delay: 2 }}
className="hidden lg:block absolute top-1/2 left-1/4"
>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-700/10 to-primary-500/10 border border-primary-700/10 backdrop-blur-sm rotate-12"></div>
</motion.div>
{/* Scroll Indicator */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2 }}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 hidden lg:block"
>
<motion.div
animate={{ y: [0, 10, 0] }}
transition={{ duration: 2, repeat: Infinity }}
className="w-6 h-10 border-2 border-gray-300 rounded-full flex justify-center"
>
<motion.div
animate={{ y: [0, 12, 0] }}
transition={{ duration: 2, repeat: Infinity }}
className="w-1 h-3 bg-gray-400 rounded-full mt-2"
></motion.div>
</motion.div>
</motion.div>
<style>
{`
@keyframes orb-slow {
0%, 100% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(40px, -60px) scale(1.1);
}
66% {
transform: translate(-30px, 30px) scale(0.9);
}
}
@keyframes orb-medium {
0%, 100% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(-50px, 40px) scale(1.05);
}
66% {
transform: translate(20px, -20px) scale(0.95);
}
}
@keyframes orb-fast {
0%, 100% {
transform: translate(0px, 0px) scale(1);
}
50% {
transform: translate(20px, -40px) scale(1.08);
}
}
.animate-orb-slow {
animation: orb-slow 15s infinite ease-in-out;
}
.animate-orb-medium {
animation: orb-medium 12s infinite ease-in-out;
}
.animate-orb-fast {
animation: orb-fast 10s infinite ease-in-out;
}
@keyframes pulse-slow {
0%, 100% {
opacity: 0.2;
}
50% {
opacity: 0.3;
}
}
.animate-pulse-slow {
animation: pulse-slow 4s infinite ease-in-out;
}
`}
</style>
</section>
);
};
export default HeroSection;

View File

@@ -0,0 +1,609 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
const RecordManagementGuide = () => {
const [activeTab, setActiveTab] = useState("dashboard");
const { t } = useTranslation();
const [expandedStep, setExpandedStep] = useState<string | null>(null);
const navigate = useNavigate();
const handleTabChange = (tab: string) => {
setActiveTab(tab);
setExpandedStep(null);
//window.scrollTo({ top: 0, behavior: "smooth" });
};
const toggleStep = (stepId: string) => {
setExpandedStep(expandedStep === stepId ? null : stepId);
};
const [openStatus, setOpenStatus] = useState<string | null>(null);
const toggle = (status: string) => {
setOpenStatus((prev) => (prev === status ? null : status));
};
const steps = [
{
id: "login",
title: t("landingPage.login"),
content: (
<div className="space-y-4">
<p className="dark:text-gray-300">
{t("landingPage.step1")}{" "}
<span className="font-semibold text-primary">
{t("landingPage.signIn")}
</span>{" "}
{t("landingPage.headerBtn")}
</p>
<p className="dark:text-gray-300">{t("landingPage.step2")}</p>
<p className="dark:text-gray-300">
{t("landingPage.step3")}{" "}
<span className="font-semibold text-primary">
{t("auth.login")}
</span>{" "}
{t("landingPage.accessBtn")}
</p>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 mt-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-semibold">{t("landingPage.note")}</span>{" "}
{t("landingPage.forgotIntro")}
</p>
</div>
</div>
),
image: "/images/login-screen.png",
},
{
id: "dashboard",
title: t("landingPage.dashboardTitle"),
content: (
<div className="space-y-4">
<p className="dark:text-gray-300">{t("landingPage.dashboardDesc")}</p>
<ul className="list-disc pl-5 space-y-2 dark:text-gray-300">
<li>
<span className="font-semibold">
{t("landingPage.recordsCreated")}:
</span>{" "}
{t("landingPage.recordsTotal")}
</li>
<li>
<span className="font-semibold">
{t("landingPage.recordsReceived")}:
</span>{" "}
{t("landingPage.incomingDocs")}
</li>
<li>
<span className="font-semibold">
{t("landingPage.breakdown")}:
</span>{" "}
{t("landingPage.visuals")}
</li>
<li>
<span className="font-semibold">
{t("landingPage.approvalStatus")}:
</span>{" "}
{t("landingPage.approvalTypes")}
</li>
</ul>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
<h4 className="font-medium text-gray-900 dark:text-white">
{t("landingPage.quickActions")}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{t("landingPage.createOrCheck")}
</p>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
<h4 className="font-medium text-gray-900 dark:text-white">
{t("landingPage.recentActivity")}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{t("landingPage.trackUpdates")}
</p>
</div>
</div>
</div>
),
image: "/images/dashboard-screen.png",
},
{
id: "outgoing",
title: t("landingPage.manageOut"),
content: (
<div className="space-y-6">
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">
{t("landingPage.createNew")}
</h4>
<ol className="list-decimal pl-5 space-y-2 mt-2 dark:text-gray-300">
<li>
{t("landingPage.goToTab")}{" "}
<span className="font-semibold text-primary">
{t("statusBar.Outgoing")}
</span>{" "}
{t("landingPage.tab")}
</li>
<li>
{t("landingPage.click")}{" "}
<span className="font-semibold text-primary">
{t("userRecord.Add Record")}
</span>{" "}
{t("landingPage.button")}
</li>
<li>{t("landingPage.fillFields")}</li>
<li>{t("landingPage.attach")}</li>
<li>{t("landingPage.saveOrSubmit")}</li>
</ol>
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">
{t("landingPage.trackStatus")}
</h4>
<div className="mt-2 grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{
status: "Draft",
color: "bg-gray-200 dark:bg-gray-700",
detail: t("landingPage.notForwarded"),
},
{
status: "Submitted",
color: "bg-purple-100 dark:bg-purple-900/50",
detail: t("landingPage.awaiting"),
},
{
status: "Accepted",
color: "bg-primary-100 dark:bg-primary-900/50",
detail: t("landingPage.accepted"),
},
{
status: "Approved",
color: "bg-primary-200 dark:bg-primary-800/50",
detail: t("landingPage.approvedSent"),
},
{
status: "Adjustment",
color: "bg-yellow-100 dark:bg-yellow-900/50",
detail: t("landingPage.returned"),
},
{
status: "Rejected",
color: "bg-red-100 dark:bg-red-900/50",
detail: t("landingPage.rejected"),
},
{
status: "Sent",
color: "bg-primary-300 dark:bg-primary-700",
detail: t("landingPage.sent"),
},
{
status: "Returned",
color: "bg-gray-400 dark:bg-gray-600",
detail: t("landingPage.returnedByOfficer"),
},
].map((item) => (
<div
key={item.status}
className={`${item.color} p-2 rounded text-center text-sm font-medium relative`}
>
<div className="flex items-center justify-between">
<span className="mx-auto dark:text-gray-900">
{t(`statusBar.${item.status}`)}
</span>
<button
onClick={() => toggle(item.status)}
className="ml-2 text-xs text-primary hover:underline"
>
<span
className={`inline-block transition-transform duration-200 ${
openStatus === item.status ? "rotate-180" : ""
}`}
>
</span>
</button>
</div>
{openStatus === item.status && (
<div className="mt-2 p-2 text-xs bg-white dark:bg-gray-700 border dark:border-gray-600 rounded shadow absolute top-full left-0 w-full z-10 dark:text-gray-200">
{item.detail}
</div>
)}
</div>
))}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3">
{t("landingPage.monitor")}
</p>
</div>
</div>
),
image: "/images/outgoing-screen.png",
},
{
id: "incoming",
title: t("landingPage.manageIncoming"),
content: (
<div className="space-y-6">
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">
{t("landingPage.incomingTypes")}
</h4>
<div className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
{[
{ type: "external", desc: t("landingPage.fromOthers") },
{ type: "internal", desc: t("landingPage.withinOrg") },
{ type: "cc", desc: t("landingPage.copied") },
].map((item) => (
<div
key={item.type}
className="bg-white dark:bg-gray-800 p-3 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
>
<h5 className="font-medium text-primary">
{t(`nav.${item.type}`)}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{item.desc}</p>
</div>
))}
</div>
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">
{t("landingPage.processIncoming")}
</h4>
<ul className="list-disc pl-5 space-y-2 mt-2">
<li className="dark:text-gray-300">
<span className="font-semibold">{t("userRecord.View")}:</span>{" "}
{t("landingPage.readDoc")}
</li>
<li className="dark:text-gray-300">
<span className="font-semibold">{t("statusBar.Accept")}:</span>{" "}
{t("landingPage.acknowledge")}
</li>
<li className="dark:text-gray-300">
<span className="font-semibold">{t("statusBar.Assign")}:</span>{" "}
{t("landingPage.forward")}
</li>
<li className="dark:text-gray-300">
<span className="font-semibold">
{t("landingPage.archive")}:
</span>{" "}
{t("landingPage.fileRef")}
</li>
</ul>
</div>
</div>
),
image: "/images/incoming-screen.png",
},
{
id: "approval",
title: t("landingPage.approvalworkflow"),
content: (
<div className="space-y-6">
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">
{t("landingPage.process")}
</h4>
<div className="mt-4">
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t("landingPage.internalFlow")}
</h5>
<div className="flex items-center justify-between mt-2">
{[
t("landingPage.creator"),
t("landingPage.leader"),
t("landingPage.director"),
t("landingPage.officer"),
].map((role, i) => (
<div key={i} className="flex flex-col items-center">
<div className="h-10 w-10 rounded-full bg-primary flex items-center justify-center text-white font-medium text-sm">
{i + 1}
</div>
<span className="text-xs mt-1 text-center dark:text-gray-300">{role}</span>
</div>
))}
</div>
</div>
<div className="mt-6">
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t("landingPage.externalFlow")}
</h5>
<div className="flex items-center justify-between mt-2">
{[
t("landingPage.creator"),
t("landingPage.leader"),
t("landingPage.director"),
t("landingPage.officer"),
].map((role, i) => (
<div key={i} className="flex flex-col items-center">
<div className="h-10 w-10 rounded-full bg-primary-500 flex items-center justify-center text-white font-medium text-sm">
{i + 1}
</div>
<span className="text-xs mt-1 text-center dark:text-gray-300">{role}</span>
</div>
))}
</div>
</div>
</div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">
{t("landingPage.actions")}
</h4>
<div className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
{[
{
action: "Approve",
desc:t("landingPage.addTeeterAndSignature"),
color: "bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800",
},
{
action: "Reject",
desc: t("landingPage.returnWithComments"),
color: "bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800",
},
{
action: "Adjust",
desc: t("landingPage.requestModifications"),
color: "bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800",
},
].map((item) => (
<div
key={item.action}
className={`${item.color} p-3 rounded-lg border`}
>
<h5 className="font-medium dark:text-gray-200">
{t(`statusBar.${item.action}`)}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{item.desc}</p>
</div>
))}
</div>
</div>
</div>
),
image: "/images/approval-screen.png",
},
{
id: "delegation",
title: t("landingPage.delegation"),
content: (
<div className="space-y-4">
<p className="dark:text-gray-300">{t("landingPage.delegateInfo")}</p>
<ol className="list-decimal pl-5 space-y-2">
<li className="dark:text-gray-300">
{t("landingPage.delegateNav")}{" "}
<span className="font-semibold text-primary">
{t("delegation.title")}
</span>{" "}
{t("landingPage.tab")}
</li>
<li className="dark:text-gray-300">{t("landingPage.selectColleague")}</li>
<li className="dark:text-gray-300">{t("landingPage.setDates")}</li>
<li className="dark:text-gray-300">{t("landingPage.setPerms")}</li>
<li className="dark:text-gray-300">{t("landingPage.saveDelegate")}</li>
</ol>
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 mt-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-semibold">{t("landingPage.note")}</span>{" "}
{t("landingPage.autoExpire")}
</p>
</div>
</div>
),
image: "/images/delegation-screen.png",
},
{
id: "collaboration",
title: t("landingPage.collab"),
content: (
<div className="space-y-4">
<p className="dark:text-gray-300">{t("landingPage.collabInfo")}</p>
<ul className="list-disc pl-5 space-y-2">
<li className="dark:text-gray-300">{t("landingPage.viewDocs")}</li>
<li className="dark:text-gray-300">{t("landingPage.addComments")}</li>
<li className="dark:text-gray-300">{t("landingPage.approve")}</li>
<li className="dark:text-gray-300">{t("landingPage.trackChanges")}</li>
</ul>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
<h4 className="font-medium text-gray-900 dark:text-white">
{t("landingPage.realTime")}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{t("landingPage.liveChanges")}
</p>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
<h4 className="font-medium text-gray-900 dark:text-white">
{t("landingPage.notifications")}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
{t("landingPage.alerts")}
</p>
</div>
</div>
</div>
),
image: "/images/collaboration-screen.png",
},
{
id: "teeter",
title: t("landingPage.signatureMgmt"),
content: (
<div className="space-y-4">
<p className="dark:text-gray-300">{t("landingPage.leadersOnly")}</p>
<ol className="list-decimal pl-5 space-y-2">
<li className="dark:text-gray-300">
{t("landingPage.goToTab")}{" "}
<span className="font-semibold text-primary">
{t("landingPage.signatureTitle")}
</span>{" "}
{t("landingPage.tab")}
</li>
<li className="dark:text-gray-300">{t("landingPage.uploadTeeter")}</li>
<li className="dark:text-gray-300">{t("landingPage.uploadSignature")}</li>
<li className="dark:text-gray-300">{t("landingPage.defaultSignature")}</li>
<li className="dark:text-gray-300">{t("landingPage.updateDesignation")}</li>
</ol>
<div className="bg-red-50 dark:bg-red-900/30 p-4 rounded-lg border border-red-200 dark:border-red-800 mt-4">
<p className="text-sm text-red-600 dark:text-red-400">
<span className="font-semibold">{t("landingPage.security")}</span>{" "}
{t("landingPage.encrypted")}.
</p>
</div>
</div>
),
image: "/images/teeter-screen.png",
},
];
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{t("landingPage.recordModule")}
</h1>
<p className="mt-2 text-lg text-gray-600 dark:text-gray-300">
{t("landingPage.guide")}
</p>
</div>
<button
onClick={() => navigate("/")}
className="mt-4 md:mt-0 px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
{t("landingPage.back")}
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Navigation Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8 overflow-x-auto scrollbar-hidden">
{steps.map((step) => (
<button
key={step.id}
onClick={() => handleTabChange(step.id)}
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-pointer ${
activeTab === step.id
? "border-primary text-primary"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
{step.title}
</button>
))}
</nav>
</div>
{/* Content Area */}
<div className="mt-8 grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Steps List */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<div className="p-4 bg-primary">
<h2 className="text-lg font-medium text-white">
{t("landingPage.moduleGuide")}
</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{steps.map((step) => (
<div
key={step.id}
onClick={() => handleTabChange(step.id)}
className={`p-4 cursor-pointer transition-colors ${
activeTab === step.id
? "bg-primary-500/5"
: "hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<div className="flex items-center">
<div
className={`h-8 w-8 rounded-full flex items-center justify-center mr-3 ${
activeTab === step.id
? "bg-primary text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
}`}
>
{steps.findIndex((s) => s.id === step.id) + 1}
</div>
<h3 className="text-sm font-medium dark:text-gray-200">{step.title}</h3>
</div>
</div>
))}
</div>
</div>
</div>
{/* Active Tab Content */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<div className="p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{steps.find((step) => step.id === activeTab)?.title}
</h2>
<div className="mt-6 prose prose-sm max-w-none">
{steps.find((step) => step.id === activeTab)?.content}
</div>
{/* Image placeholder - replace with actual image */}
<div className="mt-8 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 p-4 flex items-center justify-center">
<p className="text-gray-500 dark:text-gray-400">
{t("landingPage.screenshotOf")}{" "}
{steps.find((step) => step.id === activeTab)?.title}
</p>
</div>
</div>
{/* Navigation Buttons */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-700 flex justify-between">
{steps.findIndex((step) => step.id === activeTab) > 0 && (
<button
onClick={() =>
handleTabChange(
steps[
steps.findIndex((step) => step.id === activeTab) - 1
].id
)
}
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
{t("landingPage.previous")}
</button>
)}
{steps.findIndex((step) => step.id === activeTab) <
steps.length - 1 && (
<button
onClick={() =>
handleTabChange(
steps[
steps.findIndex((step) => step.id === activeTab) + 1
].id
)
}
className="ml-auto inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
{t("landingPage.next")}
</button>
)}
</div>
</div>
</div>
</div>
</main>
</div>
);
};
export default RecordManagementGuide;

View File

@@ -0,0 +1,17 @@
import React, { useState } from "react";
import { FaExclamationCircle, FaArrowLeft } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Header from "./Header";
import Footer from "./Footer";
import { getComplaintById } from "../../shared/services/complaintService";
const KnowAs = () => {
return (
<div>
<div>comming soon</div>
</div>
);
};
export default KnowAs;

View File

@@ -0,0 +1,591 @@
import { useMemo, useEffect, type ReactNode } from "react";
export interface FooterData {
address: string;
phone: string;
email: string;
}
export interface Socials {
facebook?: string;
twitter?: string;
linkedin?: string;
instagram?: string;
youtube?: string;
telegram?: string;
tiktok?: string;
website?: string;
}
export type fileInfo = {
size?: number;
bucket?: string;
fileName?: string;
contentType?: string;
originalname?: string;
};
export interface ModuleConfig {
recordManagement: boolean;
siteManagement: boolean;
dms: boolean;
performance: boolean;
objective: boolean;
complaint: boolean;
}
export interface TenantConfig {
appName: string;
organizationName: string;
canUseAttachmentFromDMS?: boolean;
logo: string;
primaryColor: string;
secondaryColor?: string;
footerText: string;
footerData: FooterData;
socials: Socials;
welcomeMessage: string;
moduleConfig?: Partial<ModuleConfig>;
dashboardPreviewImage?: string;
}
export interface brandingDTO {
organizationName: {
am: string;
en: string;
};
logo: {
presigned: string;
fileInfo: fileInfo;
};
primaryColor: string;
secondaryColor?: string;
footerText: string;
footerData: FooterData;
socials: Socials;
welcomeMessage: string;
loginImage?: {
presigned: string;
fileInfo: fileInfo;
};
favicon?: {
presigned: string;
fileInfo: fileInfo;
};
moduleConfig?: Partial<ModuleConfig>;
}
/**
* Resolve effective module visibility for a tenant.
* Record Management and Site Management default to enabled.
* Complaint controls public landing-page complaint entry points only (not module navigation).
* Other optional modules default to disabled unless enabled by the tenant.
*/
export const resolveModuleConfig = (config: TenantConfig): ModuleConfig => ({
recordManagement: config.moduleConfig?.recordManagement ?? true,
siteManagement: true,
dms: config.moduleConfig?.dms ?? false,
performance: config.moduleConfig?.performance ?? false,
objective: config.moduleConfig?.objective ?? false,
complaint: config.moduleConfig?.complaint ?? false,
});
const defaultConfig: TenantConfig = {
appName: "Smart Office",
organizationName: "Smart Office",
canUseAttachmentFromDMS: false,
logo: "/assets/TriaTradinglogo.png",
primaryColor: "#1b354d",
secondaryColor: "#0f5a3a",
footerText: "COMMITED TO EXCELLENCE",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251955232323",
email: "info@triaplc.com",
},
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://triaplc.com/",
},
welcomeMessage:
"At Tria Trading PLC, we empower organizations with innovative software solutions and expert consulting services. Our commitment to excellence, innovation, and customer success enables us to deliver technology that drives growth, efficiency, and lasting impact.",
};
const tenantConfigs: Record<string, TenantConfig> = {
localhost: {
appName: "Smart Office",
organizationName: "Addis Ababa City Administration",
logo: "",
primaryColor: "#115005",
moduleConfig: {
dms: true,
performance: true,
objective: true,
complaint: true,
},
canUseAttachmentFromDMS: true,
secondaryColor: "#dad9db",
footerText: "COMMITED TO EXCELLENCE",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251955232323",
email: "info@triaplc.com",
},
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://triaplc.com/",
},
welcomeMessage:
"Welcome to the Smart Office of the Addis Ababa City Administration. Addis Ababa is Ethiopias capital and a center of public service, innovation, culture, and opportunity. Through this platform, we aim to strengthen efficient service delivery, modernize administrative workflows, improve transparency, and support responsive governance for residents, institutions, and stakeholders across the city.",
},
"127.0.0.1": {
appName: "Smart Office EDR",
organizationName: "Ethio-Djibouti Railways",
canUseAttachmentFromDMS: true,
logo: "/assets/edrlogo.png",
primaryColor: "#DC143C",
moduleConfig: {
dms: true,
performance: true,
objective: true,
complaint: true,
},
secondaryColor: "#0f5a3a",
dashboardPreviewImage: "/edrheadoffice.jpg",
footerText:
"The Ethio-Djibouti Railway was established in April 2017 following a bilateral agreement signed on December 16, 2016, between Ethiopia and Djibouti.",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "9546",
email: "edr_@edrsc.com",
},
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://edrsc.com/",
},
welcomeMessage:
"The Ethio-Djibouti Railway was established in April 2017 following a bilateral agreement signed on December 16, 2016, between Ethiopia and Djibouti. A Shareholders Agreement was signed on January 11, 2017, among public bodies and state enterprises from both nations, governed by Ethiopian commercial law.\n\nThe shareholders formed a Share Company with an initial capital of USD 500 million, dedicated to operating and maintaining the Addis AbabaDjibouti Railway and providing freight and passenger transport services.",
},
"smartoffice.edrsc.com": {
appName: "Smart Office EDR",
organizationName: "Ethio-Djibouti Railways",
canUseAttachmentFromDMS: true,
logo: "/assets/edrlogo.png",
primaryColor: "#13724D",
moduleConfig: {
dms: true,
performance: false,
objective: false,
complaint: true,
},
secondaryColor: "#0f5a3a",
dashboardPreviewImage: "/edrheadoffice.jpg",
footerText:
"The Ethio-Djibouti Railway was established in April 2017 following a bilateral agreement signed on December 16, 2016, between Ethiopia and Djibouti.",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "9546",
email: "edr_@edrsc.com",
},
socials: {
facebook: "https://web.facebook.com/ethiodjiboutirailwaysc",
twitter: "https://edrsc.com/",
linkedin: "https://edrsc.com/",
instagram: "https://edrsc.com/",
youtube: "https://edrsc.com/",
telegram: "",
tiktok: "",
website: "https://edrsc.com/",
},
welcomeMessage:
"The Ethio-Djibouti Railway was established in April 2017 following a bilateral agreement signed on December 16, 2016, between Ethiopia and Djibouti. A Shareholders Agreement was signed on January 11, 2017, among public bodies and state enterprises from both nations, governed by Ethiopian commercial law.\n\nThe shareholders formed a Share Company with an initial capital of USD 500 million, dedicated to operating and maintaining the Addis AbabaDjibouti Railway and providing freight and passenger transport services.",
},
"smartoffice.eiar.gov.et": {
appName: "Smart Office EIAR",
organizationName: "Ethiopian Institute of Agricultural Research",
logo: "/assets/eiarLogo.jpg",
primaryColor: "#388e4b",
moduleConfig: { dms: false, performance: false, objective: false },
secondaryColor: "#fff",
footerText:
"The Ethiopian Institute of Agricultural Research (EIAR) is one of the oldest and largest agricultural research institutes in Africa. EIAR has evolved through several stages since its first initiation during the late 1940s, following the establishment of agricultural and technical schools at Ambo and Jimma. In 1955, a full-fledged agricultural experiment station was established at Debre Zeit (now named Debre Zeit Agricultural Research Center) under the then Imperial College of Agricultural and mechanical Arts (now called Haramaya University) and had been continued as the major research entity until the mid-1960s. In 1966, Institute of Agricultural Research (IAR) was established as the first nationally coordinated agricultural research institute in Ethiopia. IAR was established with a mission to formulate national agricultural research guidelines, coordinate national agricultural research system, and undertake research in its centers and sub-centers located in various agro-ecological zones of Ethiopia.",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251-116-454441",
email: "eiar.gov.et",
},
canUseAttachmentFromDMS: false,
socials: {
facebook: "https://web.facebook.com/EIARPR",
website: "http://www.eiar.gov.et",
twitter: "http://www.eiar.gov.et",
linkedin: "http://www.eiar.gov.et",
instagram: "http://www.eiar.gov.et",
youtube: "http://www.eiar.gov.et",
},
welcomeMessage:
"welcome you to the SmartOffice of the Ethiopian Institute of Agricultural Research. The Institute, since its establishment in 1966, has released over more 3000 agricultural technologies and improved farming practices by undertaking scientific research activities on various areas in crop, livestock, land and water, biotechnology, climate, farm machinery and agricultural economics. Of these technologies, 1190 are crop varieties and the rest include livestock breeds, pest and disease control methods, crop production and livestock husbandry methods, pre- and post-harvest technologies and recommendations. EIAR, in the five decades of its existence, has reached a large number of beneficiaries with its technologies and information found in different agro-ecologies throughout the country. The Institute has engaged itself in multiplication of initial technologies based on the demand created by the beneficiaries and supply to public and private technology multiplication actors for their wider reproduction and distribution.",
},
"smartoffice.ebi.gov.et": {
appName: "EBI Smart Office",
organizationName: "Ethiopian Biodiversity Institute - EBI",
logo: "/assets/ebiLogoWhite.png",
primaryColor: "#2a741d",
moduleConfig: { dms: false, performance: false, objective: false },
secondaryColor: "#2c246d",
footerText: "Welcome to the Center of Origin & Diversity, Ethiopia",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251-116-615607",
email: " info@ebi.gov.et",
},
canUseAttachmentFromDMS: false,
socials: {
facebook: "https://www.facebook.com/EthiopiaEBI",
twitter: "https://x.com/EthiopiaEBI",
linkedin: "https://www.linkedin.com/company/EthiopiaEBI",
youtube: "https://www.youtube.com/@EthiopiaEBI",
instagram: "https://www.youtube.com/@EthiopiaEBI",
website: "https://ebi.gov.et",
telegram: "https://t.me/EthiopiaEBI",
tiktok: "https://www.tiktok.com/@ethiopiaebi",
},
welcomeMessage:
"Ethiopia is recognized as one of the worlds most biodiverse nations. Our rugged highlands, vast lowlands, and the rift valley lakes hold the genetic codes of the wild relatives of our staple food, and the endemic wildlife that defines our national identity. The Ethiopian Biodiversity Institute - EBI bears the profound responsibility of being the steward of these natural assets since its establishment in the 1970s.",
},
"smartoffice.aaca.gov.et": {
appName: "Smart Office",
organizationName: "Addis Ababa City Administration",
logo: "",
primaryColor: "#115005",
moduleConfig: { dms: false, performance: false, objective: false },
secondaryColor: "#dad9db",
footerText: "COMMITED TO EXCELLENCE",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251955232323",
email: "info@triaplc.com",
},
canUseAttachmentFromDMS: false,
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://triaplc.com/",
},
welcomeMessage:
"Welcome to the Smart Office of the Addis Ababa City Administration. Addis Ababa is Ethiopias capital and a center of public service, innovation, culture, and opportunity. Through this platform, we aim to strengthen efficient service delivery, modernize administrative workflows, improve transparency, and support responsive governance for residents, institutions, and stakeholders across the city.",
},
"smartoffice.efd.moa.gov.et": {
appName: "Smart Office EFD",
organizationName: "Ethiopian Forest Development",
logo: "/assets/efdlogo.jpg",
primaryColor: "#006400",
moduleConfig: { dms: false, performance: false, objective: false },
secondaryColor: "#a52a2a",
footerText:
"Ethiopian Forestry Development (EFD) is an autonomous federal institution, established by Proclamation No. 1263/2021 on 25th January, as referred on article 81-No. 8 and by the federal government of Ethiopia council of ministers regulation No. 505/2022. EFD was resulted by merging the former research institute (The Ethiopian Environment and Forest Research Institute (EEFRI) together with the forestry sector from the then (Environment, Forest and climate change commission) having the following powers and duties.",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "9546",
email: "dg-office@efd.gov.et",
},
canUseAttachmentFromDMS: false,
socials: {
facebook:
"https://web.facebook.com/Ethiopian-Forestry-Development-EFD-100064767336706/?_rdc=1&_rdr#",
twitter: "Ethiopian Forestry Development@EthiopianFores1",
youtube: "https://www.youtube.com/@infoefd",
website: "https://www.efd.gov.et",
},
welcomeMessage:
"Organized forestry research in Ethiopia was started by the establishment of Forestry Research Centre (FRC) and the then Wood Utilization Research Centre (WUARC) in 1975 and 1979, respectively under the Forestry and Wildlife Conservation Development Authority (FaWCDA). The centres were incorporated to the then Ministry of Natural Resources Development and Environmental Protection in 1992 and again re-transferred to the Ministry of Agriculture in 1995. The Federal Government of Ethiopia reorganized the National Agricultural Research System and established the then Ethiopian Agricultural Research Organization (EARO) in 1997 (Negarit Gazeta, 1997). As a result, FRC and WUARC were transferred to EARO as one research centre (FRC) and one of the research sectors of EARO (now Ethiopian Institute of Agricultural Research (EIAR)). Then, the Government of Ethiopia found it necessary to give due attention to activities of environmental protection and forest development, protection and utilization by linking forestry research with environmental protection research at an institutional level for the attainment of the objectives of the Government that resulted in the establishment of the Ministry of Environment, Forest and Climate Change.",
},
"smartoffice.moa.gov.et": {
appName: "Smart Office MOA",
organizationName: "Ministry of Agriculture",
logo: "/assets/ministryOfAgriculture.jpg",
primaryColor: "#006400",
moduleConfig: {
dms: false,
performance: false,
objective: true,
recordManagement: false,
},
secondaryColor: "#a52a2a",
footerText:
"The Ministry of Agriculture (MoA) is a federal government institution of Ethiopia responsible for leading and coordinating the country's agricultural development. The Ministry is mandated to formulate policies, strategies, and programs that enhance agricultural productivity, ensure food security, promote sustainable natural resource management, and support rural transformation. Through its various sectors and agencies, the Ministry oversees crop and livestock development, agricultural extension services, research coordination, and the implementation of national agricultural initiatives aimed at improving the livelihoods of farmers and contributing to the country's economic growth.",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "9546",
email: "dg-office@efd.gov.et",
},
canUseAttachmentFromDMS: false,
socials: {
facebook: "https://web.facebook.com/MoAEthiopia/?_rdc=1&_rdr#",
twitter: "https://x.com/MoA_Ethiopia",
youtube: "https://www.youtube.com/@publicrelationmoa",
website: "https://www.moa.gov.et",
},
welcomeMessage:
"The Ministry of Agriculture (MoA) has played a central role in Ethiopias agricultural development and transformation efforts. Over the years, the Ministry has undergone various reforms and restructuring initiatives to strengthen the country's agricultural sector and improve food security. The Ministry is responsible for guiding agricultural research, extension services, livestock and crop development, natural resource management, and rural development programs. It works closely with research institutions, regional bureaus, development partners, and stakeholders to promote sustainable agricultural practices, increase productivity, and support the livelihoods of farmers and pastoral communities. Through its ongoing efforts, the Ministry continues to contribute significantly to Ethiopias economic growth, environmental sustainability, and national development objectives.",
},
"smartofficedev.moa.gov.et": {
appName: "Smart Office MOA",
organizationName: "Ministry of Agriculture",
logo: "/assets/ministryOfAgriculture.jpg",
primaryColor: "#006400",
moduleConfig: {
dms: false,
performance: false,
objective: true,
recordManagement: true,
},
secondaryColor: "#a52a2a",
footerText:
"The Ministry of Agriculture (MoA) is a federal government institution of Ethiopia responsible for leading and coordinating the country's agricultural development. The Ministry is mandated to formulate policies, strategies, and programs that enhance agricultural productivity, ensure food security, promote sustainable natural resource management, and support rural transformation. Through its various sectors and agencies, the Ministry oversees crop and livestock development, agricultural extension services, research coordination, and the implementation of national agricultural initiatives aimed at improving the livelihoods of farmers and contributing to the country's economic growth.",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "9546",
email: "dg-office@efd.gov.et",
},
canUseAttachmentFromDMS: false,
socials: {
facebook: "https://web.facebook.com/MoAEthiopia/?_rdc=1&_rdr#",
twitter: "https://x.com/MoA_Ethiopia",
youtube: "https://www.youtube.com/@publicrelationmoa",
website: "https://www.moa.gov.et",
},
welcomeMessage:
"The Ministry of Agriculture (MoA) has played a central role in Ethiopias agricultural development and transformation efforts. Over the years, the Ministry has undergone various reforms and restructuring initiatives to strengthen the country's agricultural sector and improve food security. The Ministry is responsible for guiding agricultural research, extension services, livestock and crop development, natural resource management, and rural development programs. It works closely with research institutions, regional bureaus, development partners, and stakeholders to promote sustainable agricultural practices, increase productivity, and support the livelihoods of farmers and pastoral communities. Through its ongoing efforts, the Ministry continues to contribute significantly to Ethiopias economic growth, environmental sustainability, and national development objectives.",
},
"triadms-dev.smartoffice.aaca.gov.et": {
appName: "Smart Office Tria",
organizationName: "Tria Trading PLC",
logo: "/assets/TriaTradinglogo.png",
primaryColor: "#1b354d",
moduleConfig: { dms: true, performance: true, objective: true },
secondaryColor: "#0f5a3a",
footerText: "COMMITED TO EXCELLENCE",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251955232323",
email: "info@triaplc.com",
},
canUseAttachmentFromDMS: true,
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://triaplc.com/",
},
welcomeMessage:
"At Tria Trading PLC, we empower organizations with innovative software solutions and expert consulting services. Our commitment to excellence, innovation, and customer success enables us to deliver technology that drives growth, efficiency, and lasting impact.",
},
"triadms.smartoffice.aaca.gov.et": {
appName: "Smart Office Tria",
organizationName: "Tria Trading PLC",
logo: "/assets/TriaTradinglogo.png",
primaryColor: "#1b354d",
moduleConfig: { dms: true, performance: true, objective: true },
secondaryColor: "#0f5a3a",
footerText: "COMMITED TO EXCELLENCE",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251955232323",
email: "info@triaplc.com",
},
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://triaplc.com/",
},
welcomeMessage:
"At Tria Trading PLC, we empower organizations with innovative software solutions and expert consulting services. Our commitment to excellence, innovation, and customer success enables us to deliver technology that drives growth, efficiency, and lasting impact.",
},
"smart-dev.smart.aaca.gov.et": {
appName: "Smart Office Tria",
organizationName: "Tria Trading PLC",
logo: "/assets/TriaTradinglogo.png",
primaryColor: "#1b354d",
moduleConfig: { dms: true, performance: true, objective: true },
secondaryColor: "#0f5a3a",
footerText: "COMMITED TO EXCELLENCE",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251955232323",
email: "info@triaplc.com",
},
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://triaplc.com/",
},
welcomeMessage:
"At Tria Trading PLC, we empower organizations with innovative software solutions and expert consulting services. Our commitment to excellence, innovation, and customer success enables us to deliver technology that drives growth, efficiency, and lasting impact.",
},
"performance-dev.smart.aaca.gov.et": {
appName: "Smart Office Tria",
organizationName: "Tria Trading PLC",
logo: "/assets/TriaTradinglogo.png",
primaryColor: "#1b354d",
moduleConfig: { dms: true, performance: true, objective: true },
secondaryColor: "#0f5a3a",
footerText: "COMMITED TO EXCELLENCE",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251955232323",
email: "info@triaplc.com",
},
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://triaplc.com/",
},
welcomeMessage:
"At Tria Trading PLC, we empower organizations with innovative software solutions and expert consulting services. Our commitment to excellence, innovation, and customer success enables us to deliver technology that drives growth, efficiency, and lasting impact.",
},
"performance.smart.aaca.gov.et": {
appName: "Smart Office Tria",
organizationName: "Tria Trading PLC",
logo: "/assets/TriaTradinglogo.png",
primaryColor: "#1b354d",
moduleConfig: { dms: true, performance: true, objective: true },
secondaryColor: "#0f5a3a",
footerText: "COMMITED TO EXCELLENCE",
footerData: {
address: "Addis Ababa, Ethiopia",
phone: "+251955232323",
email: "info@triaplc.com",
},
socials: {
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
youtube: "",
telegram: "",
tiktok: "",
website: "https://triaplc.com/",
},
welcomeMessage:
"At Tria Trading PLC, we empower organizations with innovative software solutions and expert consulting services. Our commitment to excellence, innovation, and customer success enables us to deliver technology that drives growth, efficiency, and lasting impact.",
},
};
/** Resolve tenant config from hostname (defaults to localhost config). */
export const resolveTenantConfig = (
hostname: string = typeof window !== "undefined"
? window.location.hostname
: "localhost",
): TenantConfig => {
// Normalize host input so callers can pass full URLs or mixed-case values safely.
const normalizedHost = hostname
?.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/\/.*$/, "")
.replace(/:\d+$/, "");
return tenantConfigs[normalizedHost] || defaultConfig;
};
/** Apply tenant theme tokens to the document root (CSS variables, title, favicon). */
export const applyTenantTheme = (config: TenantConfig) => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
const primary =
config.primaryColor?.trim() || "var(--primary-default, #18aa9d)";
root.style.setProperty("--primary", primary);
root.style.setProperty("--ring", primary);
root.style.setProperty("--sidebar-primary", primary);
root.style.setProperty("--accent", primary);
root.style.setProperty("--chart-1", primary);
if (config.organizationName) {
document.title = config.organizationName;
}
if (config.logo) {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = config.logo;
}
};
// Apply tenant theme before React mounts so deep-linked routes (e.g. record-management)
// keep branding on hard refresh without relying on login/landing page components.
if (typeof window !== "undefined") {
applyTenantTheme(resolveTenantConfig());
}
export const useTenantConfig = () => {
const hostname = useMemo(() => window.location.hostname, []);
const config = useMemo(() => resolveTenantConfig(hostname), [hostname]);
useEffect(() => {
applyTenantTheme(config);
}, [config]);
return {
config,
hostname,
};
};
/** Ensures tenant theme is applied on every route, including lazy-loaded modules. */
export const TenantConfigProvider = ({ children }: { children: ReactNode }) => {
useTenantConfig();
return children;
};

View File

@@ -0,0 +1,396 @@
import { useTranslation } from "react-i18next";
const WorkflowSection = () => {
const { t } = useTranslation();
const steps = [
{
id: "01",
name: t("landingPage.recordCreation"),
description: t("landingPage.recordCreationDesc"),
status: "complete",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: "02",
name: t("landingPage.teamLeaderReview"),
description: t("landingPage.teamLeaderReviewDesc"),
status: "complete",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
},
{
id: "03",
name: t("landingPage.directorApproval"),
description: t("landingPage.directorApprovalDesc"),
status: "current",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: "04",
name: t("landingPage.deputyHeadReview"),
description: t("landingPage.deputyHeadReviewDesc"),
status: "upcoming",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: "05",
name: t("landingPage.recordOfficerProcessing"),
description: t("landingPage.recordOfficerProcessingDesc"),
status: "upcoming",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fillRule="evenodd"
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clipRule="evenodd"
/>
</svg>
),
},
];
return (
<div className="py-16 bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="lg:text-center mb-16">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary/10 text-primary animate-pulse">
{t("landingPage.streamlinedProcess")}
</span>
<h2 className="mt-4 text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white sm:text-5xl">
<span className="block">
{t("landingPage.recordManagementApprovalWorkflows")}
</span>
<span className="block text-primary">
{t("landingPage.workflow")}
</span>
</h2>
<p className="mt-6 max-w-3xl text-xl text-gray-600 dark:text-gray-300 lg:mx-auto">
{t("landingPage.smartOfficeDescription")}
</p>
</div>
<div className="mt-12">
<div className="relative">
{/* Progress bar */}
<div className="hidden md:block absolute top-0 left-16 h-full w-0.5 bg-gray-200 dark:bg-gray-700">
<div
className="absolute top-0 left-0 h-full bg-primary transition-all duration-1000 ease-in-out"
style={{ height: "60%" }} // Adjust based on current step
/>
</div>
<ul className="space-y-10 md:space-y-12">
{steps.map((step, stepIdx) => (
<li
key={step.name}
className="relative group transition-all duration-300 hover:scale-[1.02]"
data-aos="fade-up"
data-aos-delay={stepIdx * 100}
>
<div className="relative flex items-start md:items-center">
{/* Step indicator */}
<div className="flex-shrink-0 relative z-10">
<div
className={`flex items-center justify-center w-12 h-12 rounded-full transition-all duration-300 shadow-lg ${
step.status === "complete"
? "bg-primary text-white transform group-hover:scale-110"
: step.status === "current"
? "bg-white dark:bg-gray-800 border-4 border-primary shadow-[var(--primary)]/30"
: "bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 group-hover:border-gray-400"
}`}
>
{step.status === "complete" ? (
<svg
className="w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : step.status === "current" ? (
<div className="animate-ping absolute inline-flex h-3 w-3 rounded-full bg-primary opacity-75"></div>
) : step.status === "upcoming" ? (
<span className="text-gray-400 dark:text-gray-500 font-medium">{step.id}</span>
) : (
<span className="text-gray-400 dark:text-gray-500">{step.icon}</span>
)}
</div>
</div>
{/* Step content */}
<div
className={`ml-6 p-6 rounded-xl flex-1 transition-all duration-300 ${
step.status === "current"
? "bg-white dark:bg-gray-800 border-l-4 border-primary shadow-lg"
: "bg-white dark:bg-gray-800 shadow-md group-hover:shadow-lg"
}`}
>
<div className="flex items-center justify-between">
<div>
<span
className={`text-xs font-semibold tracking-wider ${
step.status === "complete"
? "text-primary"
: "text-gray-500 dark:text-gray-400"
}`}
>
{t("landingPage.step")} {step.id}
</span>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mt-1">
{step.name}
</h3>
</div>
{step.status === "current" && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary text-white animate-pulse">
{t("statusBar.In Progress")}
</span>
)}
</div>
<p className="mt-2 text-gray-600 dark:text-gray-300">{step.description}</p>
{step.status === "current" && (
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex space-x-4">
<button className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-700 transition-colors">
{t("statusBar.Approve")}
</button>
<button className="px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
{t("landingPage.requestChanges")}
</button>
</div>
</div>
)}
</div>
</div>
</li>
))}
</ul>
</div>
</div>
{/* Features section */}
<div className="mt-20 bg-white dark:bg-gray-800 rounded-2xl shadow-xl overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="p-10 bg-gradient-to-br from-primary to-primary-800 text-white">
<h3 className="text-2xl font-bold mb-6">
{t("landingPage.workflowAutomationBenefits")}
</h3>
<p className="mb-8 opacity-90">
{t("landingPage.workflowAutomationDesc")}
</p>
<div className="space-y-6">
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-white/20">
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
></path>
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-sm font-semibold">
{t("landingPage.fasterApprovals")}
</h4>
<p className="mt-1 text-sm opacity-80">
{t("landingPage.reduceDelays")}
</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-white/20">
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
></path>
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-sm font-semibold">
{t("landingPage.auditTrail")}
</h4>
<p className="mt-1 text-sm opacity-80">
{t("landingPage.completeRecord")}
</p>
</div>
</div>
</div>
</div>
<div className="p-10">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
{t("landingPage.advancedFeatures")}
</h3>
<div className="grid grid-cols-1 gap-8">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="flex items-center justify-center h-12 w-12 rounded-xl bg-primary-500/5 text-primary">
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{t("landingPage.slaMonitoring")}
</h4>
<p className="mt-2 text-gray-600 dark:text-gray-300">
{t("landingPage.slaMonitoringDesc")}
</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="flex items-center justify-center h-12 w-12 rounded-xl bg-primary-500/5 text-primary">
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{t("landingPage.conditionalRouting")}
</h4>
<p className="mt-2 text-gray-600 dark:text-gray-300">
{t("landingPage.conditionalRoutingDesc")}
</p>
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="flex items-center justify-center h-12 w-12 rounded-xl bg-primary-500/5 text-primary">
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{t("delegation.title")}
</h4>
<p className="mt-2 text-gray-600 dark:text-gray-300">
{t("landingPage.temporaryApprovalDelegation")}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default WorkflowSection;

View File

@@ -0,0 +1,39 @@
import Header from "./components/Header";
import Herosection from "./components/HeroSection";
import Footer from "./components/Footer";
import Features from "./components/Features";
import WorkflowSection from "./components/WorkflowSection";
import Howto from "./components/Howto";
function App() {
return (
<div>
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-grow space-y-12 pt-16 md:pt-20">
{/* Add id attributes here for scrolling */}
<section id="overview">
<Herosection />
</section>
<section id="features">
<Features />
</section>
<section id="howto">
<Howto />
</section>
<section id="workflow">
<WorkflowSection />
</section>
</main>
<Footer />
</div>
</div>
);
}
export default App;

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
// 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() {
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>
);
}

View File

@@ -0,0 +1,552 @@
import { useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ArchiveRestore, Building2, UserSquare2, Trash2, Search, Filter } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useUnit } from "@/user-management/hooks/useUnit";
import {
useArchivedPositions,
useArchivedUnits,
useArchiveActions,
} from "@/user-management/hooks/useArchived";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { toast } from "sonner";
import { deleteUnit } from "@/user-management/services/api/unitService";
import { useQueryClient } from "@tanstack/react-query";
import { useMyAdminOrganizations } from "@/user-management/hooks/useOrgAdminOrganizations";
import { Badge } from "@/shared/common/ui/badge";
import { Skeleton } from "@/shared/common/ui/skeleton";
type Tab = "units" | "positions";
const ArchivedUnitsPositionsPage = () => {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const queryClient = useQueryClient();
const { getAccessibleList } = useUnit();
const { organizations: myAdminOrganizations } = useMyAdminOrganizations();
const [activeTab, setActiveTab] = useState<Tab>("units");
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
const [selectedUnitId, setSelectedUnitId] = useState<string>("");
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [unitToDelete, setUnitToDelete] = useState<{ id: string; name: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const { data: unitsResponse, isLoading: isLoadingUnits } = getAccessibleList(
selectedOrgId || "",
{ take: 300, skip: 0 },
!!selectedOrgId
);
const unitList: UnitDto[] = selectedOrgId ? (unitsResponse?.data?.items ?? []) : [];
const { data: archivedUnitsData, refetch: refetchArchivedUnits, isLoading: isLoadingArchivedUnits } =
useArchivedUnits(selectedOrgId || undefined);
const { data: archivedPositionsData, refetch: refetchArchivedPositions, isLoading: isLoadingArchivedPositions } =
useArchivedPositions(selectedUnitId || undefined);
const { restoreUnit, isRestoringUnit, restorePosition, isRestoringPosition } =
useArchiveActions();
useEffect(() => {
if (activeTab === "units" && !selectedOrgId && myAdminOrganizations.length > 0) {
setSelectedOrgId(myAdminOrganizations[0].id);
}
}, [activeTab, myAdminOrganizations.length]);
useEffect(() => {
if (activeTab === "positions" && !selectedUnitId && unitList.length > 0) {
setSelectedUnitId(unitList[0].id);
}
}, [activeTab, unitList.length]);
const handlePermanentDelete = async () => {
if (!unitToDelete) return;
setIsDeleting(true);
try {
await deleteUnit(unitToDelete.id);
toast.success(t("archive.unitDeletedPermanently", "Unit permanently deleted"));
setDeleteConfirmOpen(false);
setUnitToDelete(null);
queryClient.invalidateQueries({ queryKey: ["archived-units"] });
refetchArchivedUnits();
} catch (error: any) {
if (error?.response?.status === 400 && error?.response?.data?.message) {
const errorMessage = error?.response?.data?.message;
if (errorMessage.includes("Referenced Entity") || errorMessage.includes("referenced")) {
toast.error(
t("archive.cannotDeleteUnitWithPositions", "Cannot delete unit with related positions"),
{
description: t("archive.deletePositionsFirst", "Please delete or reassign all positions first."),
duration: 5000
}
);
} else {
toast.error(t("archive.deleteUnitFailed", "Failed to delete unit"));
}
} else {
toast.error(t("archive.deleteUnitFailed", "Failed to delete unit"));
}
} finally {
setIsDeleting(false);
}
};
const archivedUnits: any[] = useMemo(
() => archivedUnitsData?.items ?? archivedUnitsData ?? [],
[archivedUnitsData],
);
const archivedPositions: any[] = useMemo(
() => archivedPositionsData?.items ?? archivedPositionsData ?? [],
[archivedPositionsData],
);
const unitColumns = [
{
id: "name",
header: t("organization.name", "Name"),
cell: ({ row }: any) => (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Building2 className="h-5 w-5 text-primary" />
</div>
<div>
<div className="font-medium text-gray-900">
{localizedName(row.original?.name) || "—"}
</div>
<div className="text-xs text-gray-500">
{t("archive.archivedAt", "Archived")}: {new Date(row.original?.archivedAt).toLocaleDateString()}
</div>
</div>
</div>
),
},
{
id: "key",
header: t("organization.key", "Key"),
cell: ({ row }: any) => (
<Badge variant="outline" className="font-mono bg-gray-50">
{row.original?.key || "—"}
</Badge>
),
},
{
id: "status",
header: t("common.status", "Status"),
cell: ({ row }: any) => (
<Badge variant="destructive" className="bg-amber-100 text-amber-800 hover:bg-amber-100">
{t("archive.archived", "Archived")}
</Badge>
),
},
{
id: "actions",
header: t("userIncomingretun.Actions", "Actions"),
cell: ({ row }: any) => (
<div className="flex gap-2 justify-end">
<Button
size="sm"
variant="ghost"
disabled={isRestoringUnit}
onClick={() => restoreUnit(row.original.id)}>
<ArchiveRestore className="h-4 w-4 text-emerald-600" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setUnitToDelete({
id: row.original.id,
name: localizedName(row.original?.name),
});
setDeleteConfirmOpen(true);
}}>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
),
},
];
const positionColumns = [
{
id: "name",
header: t("organization.name", "Name"),
cell: ({ row }: any) => (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<UserSquare2 className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="font-medium text-gray-900">
{localizedName(row.original?.name) || "—"}
</div>
<div className="text-xs text-gray-500">
{t("archive.archivedAt", "Archived")}: {new Date(row.original?.archivedAt).toLocaleDateString()}
</div>
</div>
</div>
),
},
{
id: "key",
header: t("organization.key", "Key"),
cell: ({ row }: any) => (
<Badge variant="outline" className="font-mono bg-gray-50">
{row.original?.key || "—"}
</Badge>
),
},
{
id: "status",
header: t("common.status", "Status"),
cell: ({ row }: any) => (
<Badge variant="destructive" className="bg-amber-100 text-amber-800 hover:bg-amber-100">
{t("archive.archived", "Archived")}
</Badge>
),
},
{
id: "actions",
header: t("userIncomingretun.Actions", "Actions"),
cell: ({ row }: any) => (
<div className="flex gap-2 justify-end">
<Button
size="sm"
variant="outline"
disabled={isRestoringPosition}
onClick={() => restorePosition(row.original.id)}
className="gap-1.5 border-emerald-200 text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800">
<ArchiveRestore className="h-4 w-4" />
<span>{t("archive.restore", "Restore")}</span>
</Button>
</div>
),
},
];
const isLoading = activeTab === "units" ? isLoadingArchivedUnits : isLoadingArchivedPositions;
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent">
{t("archive.archivedItems", "Archived Items")}
</h1>
<p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
{t("archive.manageArchivedContent", "Restore or permanently delete archived content")}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-sm">
{activeTab === "units" ? archivedUnits.length : archivedPositions.length} {t("common.items", "items")}
</Badge>
</div>
</div>
{/* Tab Buttons */}
<div className="flex gap-2 border-b border-gray-200 bg-white rounded-t-xl p-1 shadow-sm">
<Button
variant={activeTab === "units" ? "default" : "ghost"}
onClick={() => setActiveTab("units")}
className={`flex-1 md:flex-none gap-2 transition-all ${
activeTab === "units"
? "shadow-md"
: "hover:bg-gray-50"
}`}>
<Building2 className="h-4 w-4" />
<span>{t("archive.archivedUnits", "Archived Units")}</span>
{activeTab === "units" && (
<span className="ml-1 text-xs bg-white/20 px-2 py-0.5 rounded-full">
{archivedUnits.length}
</span>
)}
</Button>
<Button
variant={activeTab === "positions" ? "default" : "ghost"}
onClick={() => setActiveTab("positions")}
className={`flex-1 md:flex-none gap-2 transition-all ${
activeTab === "positions"
? "shadow-md"
: "hover:bg-gray-50"
}`}>
<UserSquare2 className="h-4 w-4" />
<span>{t("archive.archivedPositions", "Archived Positions")}</span>
{activeTab === "positions" && (
<span className="ml-1 text-xs bg-white/20 px-2 py-0.5 rounded-full">
{archivedPositions.length}
</span>
)}
</Button>
</div>
{/* Filter Section */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="flex flex-col md:flex-row md:items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1.5">
{activeTab === "units"
? t("organization.selectOrganization", "Select Organization")
: t("organization.selectUnit", "Select Unit")}
</label>
{activeTab === "units" ? (
myAdminOrganizations.length === 0 ? (
<Skeleton className="h-10 w-full" />
) : (
<Select
value={selectedOrgId}
onValueChange={(value) => setSelectedOrgId(value)}>
<SelectTrigger className="w-full border-gray-300 focus:ring-2 focus:ring-primary/20">
<SelectValue
placeholder={t("organization.selectOrganization")}
/>
</SelectTrigger>
<SelectContent>
{myAdminOrganizations.map((org: any) => (
<SelectItem key={org.id} value={org.id}>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
{localizedName(org.name)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
) : (
unitList.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-2">
{isLoadingUnits ? (
<Skeleton className="h-10 w-full" />
) : (
t("common.noData", "No units available")
)}
</div>
) : (
<Select
value={selectedUnitId}
onValueChange={(value) => setSelectedUnitId(value)}>
<SelectTrigger className="w-full border-gray-300 focus:ring-2 focus:ring-primary/20">
<SelectValue placeholder={t("organization.selectUnit")} />
</SelectTrigger>
<SelectContent>
{unitList.map((unit) => (
<SelectItem key={unit.id} value={unit.id}>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
{localizedName(unit.name)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
)}
</div>
{activeTab === "units" && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Filter className="h-4 w-4" />
<span>{t("common.filter", "Filter")}</span>
</div>
)}
</div>
</div>
{/* Table */}
<Card className="shadow-sm border border-gray-200 overflow-hidden">
<CardHeader className="bg-gray-50/50 border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
{activeTab === "units" ? (
<>
<Building2 className="h-5 w-5 text-primary" />
{t("archive.archivedUnits", "Archived Units")}
</>
) : (
<>
<UserSquare2 className="h-5 w-5 text-blue-600" />
{t("archive.archivedPositions", "Archived Positions")}
</>
)}
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (activeTab === "units") {
refetchArchivedUnits();
} else {
refetchArchivedPositions();
}
}}
className="text-gray-500 hover:text-gray-700">
<span className="sr-only">{t("common.refresh", "Refresh")}</span>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-8 space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
<div className="flex gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
))}
</div>
) : (
<>
{activeTab === "units" ? (
archivedUnits.length === 0 ? (
<div className="p-8 text-center">
<div className="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<Building2 className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-1">
{t("common.noData", "No archived units")}
</h3>
<p className="text-sm text-gray-500">
{t("archive.noArchivedUnits", "No archived units found for this organization")}
</p>
</div>
) : (
<AdvancedTable
columns={unitColumns as any}
data={archivedUnits}
tableName="ArchivedUnits"
toolBarPosition="right"
itemCount={archivedUnits.length}
pageIndex={0}
onPageChange={() => {}}
nextFunction={() => {}}
prevFunction={() => {}}
refresh={refetchArchivedUnits}
/>
)
) : (
archivedPositions.length === 0 ? (
<div className="p-8 text-center">
<div className="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<UserSquare2 className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-1">
{t("common.noData", "No archived positions")}
</h3>
<p className="text-sm text-gray-500">
{t("archive.noArchivedPositions", "No archived positions found for this unit")}
</p>
</div>
) : (
<AdvancedTable
columns={positionColumns as any}
data={archivedPositions}
tableName="ArchivedPositions"
toolBarPosition="right"
itemCount={archivedPositions.length}
pageIndex={0}
onPageChange={() => {}}
nextFunction={() => {}}
prevFunction={() => {}}
refresh={refetchArchivedPositions}
/>
)
)}
</>
)}
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent className="sm:max-w-md">
<AlertDialogHeader>
<div className="mx-auto w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mb-4">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<AlertDialogTitle className="text-center">
{t("archive.deletePermanentlyConfirm", "Permanently delete unit?")}
</AlertDialogTitle>
<AlertDialogDescription className="text-center">
<span className="block mb-2">
{t("archive.deletePermanentlyDescription", "This will permanently delete")}
</span>
<span className="font-semibold text-gray-900 block text-base">
"{unitToDelete?.name}"
</span>
<span className="block mt-2 text-red-600 text-sm font-medium">
{t("archive.cannotBeUndone", "This action cannot be undone.")}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex gap-2">
<AlertDialogCancel disabled={isDeleting} className="flex-1">
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handlePermanentDelete}
disabled={isDeleting}
className="flex-1 bg-red-600 hover:bg-red-700 focus:ring-red-500">
{isDeleting ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t("common.deleting", "Deleting...")}
</span>
) : (
t("archive.deletePermanently", "Delete Permanently")
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
};
export default ArchivedUnitsPositionsPage;

View File

@@ -0,0 +1,198 @@
sentationsand premos r de
- Better foolisheds more p- Looknal**
fessio✅ **Proages
ror messr er Bettegs
-in losources ntify re to ide*
- Easierbugging***De
✅ tive URLsscripre dengines
- Mo eearch s- Better forable)
plic** (if apEO **S
✅ bookmark
hare and Easier to se
-re they ar whe understands canul
- Useraningfdable and me are rea
- URLsrience**r Expe
✅ **Use## Benefitstime
oute at a e one rgratmiy Graduallnd slugs
3.th UUIDs as bohich handleram()` wSlugPaarse2. Use `phanges
ute crt rove but reitiesp slug util:
1. Keearise issues k Plan
IfRollbac)
## on collisi names (slugte planuplica Dmes
-g nalon very thns wi- Pla in names
charactersial ecns with sp- Pla*
ge Cases* **Edns
4.peratioest CRUD oUIDs
- T still use Ull API callsify a**
- Ver*API Testes
3. * valuorrect show ceadcrumbsy br
- VerifLsnew UR with ation flowsavigest all n - Tst**
ion Te. **Navigat
2URLsookmarked t with b - Tesn
migratiouring ll work d stid UUID URLs- Ensure ol*
t*lity Tesmpatibiard Cokw
1. **BacyategStr## Testing ;
```
{planSlug}`)r}/plan/$gement/${yeaormance-manaate(`/perfavigd);
n), plan.ie(plan.nameam(localizedNlugteUniqueSea crlug =
const planSteration - Af
// Navigan.id}`);pl/plan/${Id}nt/${yearanagemeformance-m(`/pervigate
naion - Beforeigat;
// Nav|| '')(planSlug lugParamparseSd } = lanI pd:t { i;
consd)unitI, lanYear(yearResolvedPuse } = arIda, yeearDatata: planY
const { dams();eParusSlug } = lantionKey, pyear, posi
const {
// AfterarId);
ById(ye usePlanYeara } =YearDatlana: pconst { dat();
= useParams }, planIdtionIdrId, posi{ yea
const Before
// ``tsxonent
`ble Compte PlanTapdae: Uxampl## EnSlug}`
lan/\${pyear}/plaent/\${nce-managemrmaerfoigate(\`/p
- `nav\${year}`ent/emance-managormate(\`/perf
- `navigith:
Replace w\${planId}`r}/plan/\${yeant/managemee-/performancigate(\`
- `navarId}`\${yeement/mance-manag/perfore(\`- `navigatf:
s oncer all instaearch fo Calls
Savigation
### 3. NuLayout.tsx`formanceMenpert/layout/nagemene-mac/performanc`sr/*`
- rarchyents/PlanHieement/compon-managrformance `src/pesx`
-rm.tn/PlanFogurationfits/Plan-Componenement/coance-managformerrc/p `sable.tsx`
-n/planTratiolan-Configuponents/P/comgement-manaanceerform
- `src/psing Paramsmponents U
### 2. Coute>
```>} />
</RoPlanForm /ement={< ellug/edit"lan/:planSth="pRoute pa />} />
<ails={<PlanDetug" elementan/:planSl"plth=ute pa>
<Rorchy />} /eranHila={<Pment" elelansth="p <Route pat/:year">
managemenrformance-ath="peRoute par
<o ye tarIdall yege
// Chan`tsxtsx)
``s (App. 1. Route
###eUpdato Files t Key ons
##operatiCRUD t all
- [ ] Tesededre neolution wheo-ID resg-t ] Add slurnally
- [nte use UUIDs itill salls API callnsure [ ] Ealls
- I CUpdate APse 4:
### Phation flows
l naviga] Test alams()`
- [ useParng `mponents usie all codat ] Up- [Slug`
an `:pllanId` torom `:p params fange route
- [ ] Ch` `:yearId` to `:year params fromoute r [ ] Changege)
-hanking Coutes (Breaate R 3: Upd## Phase
#dable valuesuse rearumbs to pdate breadcrs
- [ ] Uear numbe to use yu linkse menat [ ] Upd
-lugso use plan ss tate()` callnavigate all ` [ ] Updmbers
- nuars to use yell` ca `navigate()date all [ ] Upation
-e Navig: Updatase 2 Phrk)
###ll would sti URLs (shoID UUith existing[ ] Test wlugs
- ss andUUIDth boto parse mponents Update coook
- [ ] hanYear`eResolvedPlus
- [x] Add `hookyNumber` nYearB`usePla [x] Add ons
-y functi utilitte slug
- [x] Creaatible)rd Comp(Backwaupport d Slug S Ad1: Phase st
###lickration Che`
## Migment
``nce-departnatable/filans-/ Use: /puuid
/c-123-table/abof: /plans-nstead
// ID:
```tsx of Iat insteadld, use th`key` fiea e ns havIf positioKeys
tion e Posions: Us 3. Positi
###
nId);
```n(pla } = usePlatalanDa data: p
const {lug);ram(planSugPaseSl } = parplanId id: nst {();
corams } = usePaanSlugst { plcons';
slugUtilt/utils/-managemenormancerom '@/perfram } fSlugParse part {
impoxs:**
```tsent compone slugs inrs**Pa
```
g}`);${planSlulan/t/2024/pemenmance-manag(`/perfor
navigate plan.id);ame),(plan.nzedNameocaliqueSlug(leUni = creatplanSlug
const g to a plann navigatin';
// Whetilstils/slugUmanagement/umance-for '@/pereSlug } fromiquUnatet { cresx
imporng:**
```ttivigahen naslugs we Generat
**Suffix
s with ID uglans: Use Sl. P
### 2
y
```r displa" fo24e "20ear will b/ ys
/for API callactual UUID be the arId will
// yed);r, unitIear(yeavedPlanYol useRes, yearId } =ta{ danst
coid;0]?.s?.[ata?.itemata?.d)?.dionId!zatorganitList(text?.getConunitId = uninst co();
itContextext = useUntContunid;
const tionI.organizaployee?.[0]?user?.emzationId = ni
const orga);Params(ar } = use{ yest w way
con
// Ne(yearId);
earByIdePlanY= usdata } st { ams();
conPar} = uset { yearId y
cons Old wasx
//*
```tve year:*olrests to ponen com
**Update
```nt/:year">eme-managerformance"pe path=tsx
<Rout:**
```
**To">
```arIdment/:yege-manaperformancee path="x
<Rout```ts:**
routes from
**Change NumbersYear s: Use lan Year# 1. Ps
##n Steptio Implementat
```
##5/edin-q2-abc1234lay-prterl/plan/quament/2024ance-managerforment
/pedepartmnce-s-table/finat/2024/plance-managemenmanerfor4/plans
/p/202nagementormance-maerf)
```
/pdable (Human-rea After
###`
``/edit
uuidhi-789-d/plan/guic-123-uent/abanagemnce-m/performa456-uuid
s-table/def-3-uuid/planment/abc-12ance-managerm/perfolans
-123-uuid/pt/abcanagemenrmance-m```
/perfoUID-based)
e (U# Beforhanges
##re CRL Structu.
## Uduleanagement moce mrmanin the perfole URLs readaban-to humased URLs D-bm UUIte froto migraains how xpl eideis gu
ThOverviews
## dable URLHuman-Rean Guide: gratio# Mi

View File

@@ -0,0 +1,969 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/shared/common/ui/use-toast";
import { useTranslation } from "react-i18next";
// Import your existing components
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Progress } from "@/shared/common/ui/progress";
import { cn } from "@/shared/lib/utils";
import {
AlertCircleIcon,
ArrowLeftIcon,
ChartBarIcon,
ClockIcon,
HourglassIcon,
Loader2,
PlayCircleIcon,
Settings2Icon,
TicketIcon,
UserIcon,
AlertTriangleIcon,
PhoneIcon,
CalendarIcon,
BuildingIcon,
Activity,
Zap,
Users,
CheckCircle,
RefreshCw,
TrendingUp,
BarChart3,
Phone,
Clock,
} from "lucide-react";
import { getApiErrorMessage } from "@/record-management/services/api/approvalService";
import {
useCallExecution,
useMyActiveExecution,
} from "@/queue-management/hooks/useExecution";
// Types
enum TransferTypes {
REASSIGN = "reassign",
TRANSFER = "transfer",
}
enum TicketStatus {
WAITING = "waiting",
CALLED = "called",
SERVING = "serving",
COMPLETED = "completed",
TRANSFERRED = "transferred",
SKIPPED = "skipped",
}
interface Ticket {
id: string;
ticketNumber: string;
serviceId: string;
service: {
name: string;
category: {
name: string;
};
};
counterId: string;
counter: {
windowNumber: string;
};
status: TicketStatus;
priority: "low" | "normal" | "high" | "urgent";
calledAt?: string;
servedAt?: string;
completedAt?: string;
transferredFrom?: any;
transferredFromId?: string | null;
transferType?: TransferTypes | null;
handlerId?: string;
handler?: {
firstName: string;
lastName: string;
windowNumber: string;
};
estimatedWaitTime?: number;
customerNotes?: string;
customerName?: string;
customerPhone?: string;
queueGroup?: {
customerName?: {
en: string;
am: string;
};
customerPhone?: string;
};
}
const ActiveCallManagement = () => {
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useTranslation();
// State
const [activeTicket, setActiveTicket] = useState<Ticket | null>(null);
const [serviceTime, setServiceTime] = useState(0);
const [isTimeExceeded, setIsTimeExceeded] = useState(false);
const [waitTime, setWaitTime] = useState(0);
const { mutate: callExecution, isPending: isCalling } = useCallExecution();
// Fetch active execution to show notifications
const { data: myActiveExecutionData, refetch: refetchActiveExecution } =
useMyActiveExecution();
// Auto-refresh active execution data every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
refetchActiveExecution();
}, 30000); // 30 seconds
return () => clearInterval(interval);
}, [refetchActiveExecution]);
// Update activeTicket when myActiveExecutionData changes
useEffect(() => {
if (myActiveExecutionData) {
// Extract customer information
const customerName =
myActiveExecutionData.queue?.queueGroup?.customerName?.en ||
myActiveExecutionData.queue?.queueGroup?.customerName?.am ||
t("activeCallManagement.customer");
const customerPhone =
myActiveExecutionData.queue?.queueGroup?.customerPhone ||
t("common.na", "N/A");
// Map execution data to Ticket format
const mappedTicket: Ticket = {
id: myActiveExecutionData.id,
ticketNumber: myActiveExecutionData.queue?.positionInQueue
? `${myActiveExecutionData.service?.prefix || "T"}-${
myActiveExecutionData.queue.positionInQueue
}`
: myActiveExecutionData.queueNumber || t("common.na", "N/A"),
serviceId: myActiveExecutionData.serviceId || "",
service: {
name:
myActiveExecutionData.service?.name?.en ||
t("activeCallManagement.service"),
category: {
name:
myActiveExecutionData.service?.name?.en ||
t("activeCallManagement.category"),
},
},
counterId: myActiveExecutionData.employeePositionId || "",
counter: {
windowNumber: myActiveExecutionData.caseWorkerName?.en || "1",
},
status:
myActiveExecutionData.status === "STARTED"
? TicketStatus.SERVING
: myActiveExecutionData.status === "STOPPED"
? TicketStatus.CALLED
: TicketStatus.COMPLETED,
priority: "normal" as const,
calledAt: myActiveExecutionData.calledAt,
servedAt: myActiveExecutionData.startedAt,
completedAt: myActiveExecutionData.endedAt,
transferredFrom: undefined,
transferredFromId: myActiveExecutionData.parentExecutionId,
transferType: null,
handlerId: myActiveExecutionData.caseWorkerId,
handler: myActiveExecutionData.caseWorkerName
? {
firstName:
myActiveExecutionData.caseWorkerName.en.split(" ")[0] || "",
lastName:
myActiveExecutionData.caseWorkerName.en.split(" ")[1] || "",
windowNumber: "1",
}
: undefined,
estimatedWaitTime: myActiveExecutionData.service?.estimatedTime,
customerNotes: myActiveExecutionData.queue?.queueGroup?.customerPhone
? `Customer: ${myActiveExecutionData.queue.queueGroup.customerName.en} (${myActiveExecutionData.queue.queueGroup.customerPhone})`
: undefined,
customerName: customerName,
customerPhone: customerPhone,
queueGroup: myActiveExecutionData.queue?.queueGroup,
};
setActiveTicket(mappedTicket);
} else {
// Clear active ticket if no execution data
setActiveTicket(null);
}
}, [myActiveExecutionData, t]);
useEffect(() => {
let interval: number;
if (activeTicket?.status === TicketStatus.SERVING) {
interval = window.setInterval(() => {
setServiceTime((prev) => {
const newTime = prev + 1;
// Check if time exceeded estimated time (convert minutes to seconds)
const estimatedSeconds = (activeTicket.estimatedWaitTime || 0) * 60;
setIsTimeExceeded(newTime > estimatedSeconds && estimatedSeconds > 0);
return newTime;
});
}, 1000);
}
return () => clearInterval(interval);
}, [activeTicket?.status, activeTicket?.estimatedWaitTime]);
useEffect(() => {
let interval: number;
if (activeTicket?.status === TicketStatus.CALLED) {
interval = window.setInterval(() => {
setWaitTime((prev) => prev + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [activeTicket?.status]);
// Handlers
const handleCall = useCallback(() => {
callExecution(undefined, {
onSuccess: (data) => {
setActiveTicket(data);
setWaitTime(0);
toast({
title: t("activeCallManagement.ticketCalled"),
description: `${t("activeCallManagement.ticket")} ${
data.ticketNumber
} ${t("activeCallManagement.ticketCalledMessage")}`,
variant: "default",
});
},
onError: (error) => {
toast({
title: t("activeCallManagement.callFailed"),
description: getApiErrorMessage(
error,
t("activeCallManagement.callFailedMessage")
),
variant: "destructive",
});
},
});
}, [callExecution, toast, t]);
// Helper functions
const formatTime = useCallback((seconds: number) => {
const h = Math.floor(seconds / 3600)
.toString()
.padStart(2, "0");
const m = Math.floor((seconds % 3600) / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${h}:${m}:${s}`;
}, []);
const getPriorityColor = useCallback((priority: string) => {
switch (priority) {
case "urgent":
return "bg-red-100 text-red-800 border-red-200";
case "high":
return "bg-orange-100 text-orange-800 border-orange-200";
case "normal":
return "bg-blue-100 text-blue-800 border-blue-200";
default:
return "bg-gray-100 text-gray-800 border-gray-200";
}
}, []);
// Format date for display
const formatDate = useCallback((dateString?: string) => {
if (!dateString) return t("common.na", "N/A");
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
} catch {
return dateString;
}
}, []);
// Memoized components
const TicketDisplay = useMemo(() => {
if (!activeTicket) {
return (
<Card className="h-full flex items-center justify-center border-dashed border-2 border-gray-200 bg-gradient-to-br from-gray-50 to-white rounded-2xl">
<CardContent className="py-16 text-center">
<div className="relative inline-block mb-6">
<div className="h-32 w-32 rounded-full bg-gradient-to-r from-blue-100 to-blue-50 flex items-center justify-center animate-pulse">
<TicketIcon className="h-16 w-16 text-blue-300" />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center shadow-lg">
<PlayCircleIcon className="h-6 w-6 text-white" />
</div>
</div>
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-3">
{t("activeCallManagement.readyToServe")}
</h3>
<p className="text-gray-600 max-w-md mx-auto mb-8 text-lg">
{t("activeCallManagement.noActiveCustomer")}
</p>
<div className="flex items-center justify-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>
{t(
"activeCallManagement.waitingCustomers",
"Waiting customers"
)}
</span>
</div>
<div className="w-1 h-1 bg-gray-300 rounded-full"></div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>
{t(
"activeCallManagement.realTimeUpdates",
"Real-time updates"
)}
</span>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Customer Information Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div
className={cn(
"p-6 text-white",
activeTicket.status === TicketStatus.CALLED
? "bg-gradient-to-r from-yellow-500 to-orange-500"
: "bg-gradient-to-r from-purple-500 to-indigo-500"
)}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<UserIcon className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-lg">
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.customerCalled")
: t("activeCallManagement.currentlyServing")}
</h3>
<p className="text-white/90 text-sm">
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.waitingAtCounter")
: t("activeCallManagement.assistingCustomer")}
</p>
</div>
</div>
<Badge className="bg-white/20 backdrop-blur-sm text-white border-0 px-3 py-1">
<Phone className="h-3 w-3 mr-1" />
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.called")
: t("activeCallManagement.serving")}
</Badge>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-6">
{/* Customer Name and Ticket */}
<div className="text-center p-6 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl">
<div className="text-sm text-blue-600 font-medium mb-2">
{t("activeCallManagement.customer")}
</div>
<h2 className="text-3xl font-bold text-blue-900 mb-2">
{activeTicket.customerName ||
t("activeCallManagement.customer")}
</h2>
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-full border border-blue-200">
<TicketIcon className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800">
{t("activeCallManagement.ticket")} #
{activeTicket.ticketNumber}
</span>
</div>
</div>
{/* Customer Details Grid */}
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<PhoneIcon className="h-5 w-5 text-primary-600" />
</div>
<div>
<div className="text-sm text-primary-600 font-medium">
{t("activeCallManagement.phoneNumber")}
</div>
<div className="font-semibold text-green-900">
{activeTicket.customerPhone || t("common.na", "N/A")}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<CalendarIcon className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-purple-600 font-medium">
{t("activeCallManagement.calledAt", "Called At")}
</div>
<div className="font-semibold text-purple-900">
{formatDate(activeTicket.calledAt)}
</div>
</div>
</div>
</div>
</div>
{/* Priority Badge */}
{activeTicket.priority && activeTicket.priority !== "normal" && (
<div className="flex items-center justify-center">
<Badge
className={cn(
"px-4 py-2 text-sm font-semibold",
getPriorityColor(activeTicket.priority)
)}
>
<AlertTriangleIcon className="h-4 w-4 mr-2" />
{activeTicket.priority.toUpperCase()}{" "}
{t("activeCallManagement.priority", "PRIORITY")}
</Badge>
</div>
)}
</div>
</CardContent>
</Card>
{/* Service Information Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-blue-600 to-cyan-500 text-white">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<Settings2Icon className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-lg">
{t("activeCallManagement.serviceInformation")}
</h3>
<p className="text-white/90 text-sm">
{t("activeCallManagement.currentServiceDetails")}
</p>
</div>
</div>
<Badge className="bg-white/20 backdrop-blur-sm text-white border-0 px-3 py-1">
<Settings2Icon className="h-3 w-3 mr-1" />
{t("activeCallManagement.service")}
</Badge>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-6">
{/* Service Name Display */}
<div className="text-center p-6 bg-gradient-to-r from-cyan-50 to-blue-50 rounded-xl">
<div className="text-sm text-cyan-600 font-medium mb-2">
{t(
"activeCallManagement.currentlyServingLabel",
"Currently Serving"
)}
</div>
<h2 className="text-2xl font-bold text-cyan-900 mb-4">
{typeof activeTicket.service?.name === "object"
? (activeTicket.service?.name as any)?.en ||
(activeTicket.service?.name as any)?.am ||
t("activeCallManagement.service")
: activeTicket.service?.name}
</h2>
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-full border border-cyan-200">
<BuildingIcon className="h-4 w-4 text-cyan-600" />
<span className="text-sm font-medium text-cyan-800">
{t("activeCallManagement.window")}{" "}
{activeTicket.counter?.windowNumber ||
t("common.na", "N/A")}
</span>
</div>
</div>
{/* Service Details */}
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-4 bg-orange-50 rounded-lg border border-orange-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<ClockIcon className="h-5 w-5 text-orange-600" />
</div>
<div>
<div className="text-sm text-orange-600 font-medium">
{t("activeCallManagement.estimatedTime", "Est. Time")}
</div>
<div className="font-semibold text-orange-900">
{activeTicket.estimatedWaitTime
? `${activeTicket.estimatedWaitTime} ${t(
"activeCallManagement.minutes",
"minutes"
)}`
: t(
"activeCallManagement.notSpecified",
"Not specified"
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-indigo-50 rounded-lg border border-indigo-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
<ChartBarIcon className="h-5 w-5 text-indigo-600" />
</div>
<div>
<div className="text-sm text-indigo-600 font-medium">
{t("activeCallManagement.status")}
</div>
<div className="font-semibold text-indigo-900 capitalize">
{activeTicket.status === TicketStatus.SERVING
? t("activeCallManagement.inProgress")
: activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.waiting")
: activeTicket.status}
</div>
</div>
</div>
</div>
</div>
{/* Service Start Time */}
{activeTicket.servedAt && (
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-primary-600" />
</div>
<div>
<div className="text-sm font-medium text-green-800">
{t("activeCallManagement.serviceStarted")}
</div>
<div className="text-sm text-primary-700">
{formatDate(activeTicket.servedAt)}
</div>
</div>
</div>
</div>
)}
{/* Customer Notes */}
{activeTicket.customerNotes && (
<div className="p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-lg border border-amber-200">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center mt-0.5">
<AlertCircleIcon className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm font-medium text-amber-800 mb-1">
{t("activeCallManagement.customerNotes")}
</div>
<div className="text-sm text-amber-700">
{activeTicket.customerNotes}
</div>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}, [activeTicket, handleCall, isCalling, getPriorityColor, formatDate, t]);
const ServingTime = () => {
if (!activeTicket) return null;
const time =
activeTicket.status === TicketStatus.SERVING ? serviceTime : waitTime;
const isServing = activeTicket.status === TicketStatus.SERVING;
const maxTime = (activeTicket.estimatedWaitTime || 5) * 60;
const progressValue = Math.min((time / maxTime) * 100, 100);
const isOverdue = isTimeExceeded;
return (
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div
className={cn(
"p-6",
isServing
? isOverdue
? "bg-gradient-to-r from-red-500 to-rose-500"
: "bg-gradient-to-r from-green-500 to-emerald-500"
: isOverdue
? "bg-gradient-to-r from-red-500 to-rose-500"
: "bg-gradient-to-r from-yellow-500 to-amber-500"
)}
>
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center">
<ClockIcon className="w-8 h-8" />
</div>
<div>
<h3 className="text-2xl font-bold mb-1">
{isServing
? t("activeCallManagement.serviceTime")
: t("activeCallManagement.customerWaitTime")}
</h3>
<p className="text-white/90">
{isServing
? isOverdue
? `⚠️ ${t(
"activeCallManagement.exceedingEstimatedTime"
)} ${activeTicket.customerName}`
: `${t("activeCallManagement.assisting")} ${
activeTicket.customerName
}`
: `${activeTicket.customerName} ${t(
"activeCallManagement.waitingAtCounterLabel"
)}`}
</p>
</div>
</div>
<div className="text-right">
<div className="text-5xl font-bold font-mono mb-2">
{formatTime(time)}
</div>
<div className="flex items-center gap-2">
<Badge
className={cn(
"px-3 py-1.5 text-sm font-semibold",
isOverdue
? "bg-white/20 text-white"
: "bg-white/20 text-white"
)}
>
{progressValue.toFixed(0)}%
</Badge>
<span className="text-sm text-white/80">
{Math.floor(maxTime / 60)}m {t("activeCallManagement.target")}
</span>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 p-6">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<ClockIcon className="w-5 h-5 text-gray-600" />
<span className="text-sm font-medium text-gray-700">
{t("activeCallManagement.timeElapsed")}
</span>
</div>
<div className="text-lg font-mono font-semibold text-gray-900">
{Math.floor(time / 60)}m {time % 60}s
</div>
</div>
<div className="flex items-center gap-2">
{isOverdue && (
<Badge variant="destructive" className="gap-1">
<AlertTriangleIcon className="w-3 h-3" />
{t("activeCallManagement.overdue", "Overdue")}
</Badge>
)}
<Badge
variant={isServing ? "success" : "warning"}
className="gap-1"
>
<Activity className="w-3 h-3" />
{isServing
? t("activeCallManagement.serving")
: t("activeCallManagement.waiting")}
</Badge>
</div>
</div>
<Progress
value={progressValue}
className={cn(
"h-3 rounded-full",
isOverdue
? "bg-red-100 [&>div]:bg-gradient-to-r [&>div]:from-red-500 [&>div]:to-rose-500"
: isServing
? "bg-green-100 [&>div]:bg-gradient-to-r [&>div]:from-green-500 [&>div]:to-emerald-500"
: "bg-yellow-100 [&>div]:bg-gradient-to-r [&>div]:from-yellow-500 [&>div]:to-amber-500"
)}
/>
<div className="flex justify-between text-xs text-gray-500 mt-2">
<span>0m</span>
<span>{Math.floor(maxTime / 60)}m</span>
</div>
</div>
</Card>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 p-4 md:p-6">
<div className="max-w-7xl mx-auto">
{/* Enhanced Header */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6 mb-8">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 hover:border-blue-400 hover:bg-blue-50 gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
{t("activeCallManagement.back", "Back")}
</Button>
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-2xl flex items-center justify-center shadow-lg">
<Activity className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 via-cyan-600 to-indigo-600 bg-clip-text text-transparent mb-2">
{t("activeCallManagement.title", "Active Call Management")}
</h1>
<p className="text-gray-600 text-lg flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-500" />
{t(
"activeCallManagement.description",
"Manage active customer calls and service execution in real-time"
)}
</p>
</div>
</div>
</div>
{/* Real-time Status and Controls */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-4 py-2 bg-white/80 backdrop-blur-sm rounded-xl shadow-sm border">
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-medium text-gray-700">
{t("activeCallManagement.realTimeUpdates", "Real-time Updates")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-sm text-gray-500">
{t("activeCallManagement.serviceCounter", "Service Counter")}
</p>
<Badge className="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-3 py-1.5">
{t("activeCallManagement.window", "Window")}{" "}
{activeTicket?.counter?.windowNumber || t("common.na", "N/A")}
</Badge>
</div>
<div className="h-8 w-px bg-gray-300" />
<div className="text-right">
<p className="text-sm text-gray-500">
{t("activeCallManagement.status", "Status")}
</p>
<Badge
className={cn(
"px-3 py-1.5",
activeTicket
? "bg-gradient-to-r from-green-500 to-emerald-500 text-white"
: "bg-gray-100 text-gray-800"
)}
>
{activeTicket
? t("activeCallManagement.active", "Active")
: t("activeCallManagement.ready", "Ready")}
</Badge>
</div>
</div>
</div>
</div>
{/* Enhanced Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Sidebar - Quick Actions & Stats */}
<div className="lg:col-span-1 space-y-6">
{/* Quick Actions Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-blue-600 to-blue-500 text-white">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<Zap className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-lg">
{t("activeCallManagement.quickActions")}
</h3>
<p className="text-blue-100 text-sm">
{t("activeCallManagement.commonOperations")}
</p>
</div>
</div>
</div>
<CardContent className="p-6 space-y-4">
{/* Call Next Customer Button */}
<Button
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-primary hover:to-emerald-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 h-12"
onClick={() => handleCall()}
disabled={isCalling || !!activeTicket}
>
{isCalling ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
{t("activeCallManagement.calling")}
</>
) : (
<>
<PlayCircleIcon className="h-5 w-5 mr-2" />
{t("activeCallManagement.callNextCustomer")}
</>
)}
</Button>
{/* Action Buttons Grid */}
<div className="grid grid-cols-1 gap-3">
<Button
variant="outline"
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
disabled={!activeTicket}
>
<HourglassIcon className="h-4 w-4" />
{t("activeCallManagement.hold")}
</Button>
<Button
variant="outline"
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
disabled={!activeTicket}
>
<Settings2Icon className="h-4 w-4" />
{t("activeCallManagement.transfer")}
</Button>
<Button
variant="outline"
onClick={() => {
refetchActiveExecution();
}}
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
>
<RefreshCw className="h-4 w-4" />
{t("activeCallManagement.refresh", "Refresh")}
</Button>
</div>
</CardContent>
</Card>
{/* Customer Stats Card */}
{activeTicket && (
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-purple-600 to-violet-500 text-white">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<BarChart3 className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-lg">
{t("activeCallManagement.customerStats")}
</h3>
<p className="text-purple-100 text-sm">
{t(
"activeCallManagement.realTimeMetrics",
"Real-time metrics"
)}
</p>
</div>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2">
<Settings2Icon className="h-4 w-4 text-blue-600" />
<span className="text-sm text-blue-700 font-medium">
{t("activeCallManagement.currentService")}
</span>
</div>
<span className="font-medium text-blue-900 text-sm">
{typeof activeTicket.service?.name === "object"
? (activeTicket.service?.name as any)?.en ||
(activeTicket.service?.name as any)?.am ||
t("common.na", "N/A")
: activeTicket.service?.name}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-yellow-50 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-yellow-600" />
<span className="text-sm text-yellow-700 font-medium">
{t("activeCallManagement.waitTime")}
</span>
</div>
<span className="font-medium text-yellow-900 font-mono">
{formatTime(waitTime)}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-primary-600" />
<span className="text-sm text-primary-700 font-medium">
{t("activeCallManagement.serviceTime")}
</span>
</div>
<span className="font-medium text-green-900 font-mono">
{formatTime(serviceTime)}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-purple-600" />
<span className="text-sm text-purple-700 font-medium">
{t("activeCallManagement.priority")}
</span>
</div>
<Badge
className={cn(
"px-2 py-0.5",
getPriorityColor(activeTicket.priority)
)}
>
{activeTicket.priority}
</Badge>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Main Content Area */}
<div className="lg:col-span-3 space-y-6">
{/* Timer Card */}
{activeTicket && <ServingTime />}
{/* Customer and Service Display */}
{TicketDisplay}
</div>
</div>
</div>
</div>
);
};
export default ActiveCallManagement;

View File

@@ -0,0 +1,803 @@
import { useForm, useWatch } from "react-hook-form";
import { motion } from "framer-motion";
import {
Calendar,
Target,
Users,
FileText,
Plus,
ArrowLeft,
User,
} from "lucide-react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnitContext } from "@/shared/context/UnitContext";
import { Button } from "@/shared/common/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
import { Info } from "lucide-react";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { TimeFrame } from "@/performance-management/utils/shared-field-types";
import {
useEmployeePlanMutations,
useEmployeePositions,
usePositionsByUnit,
useEmployeePlan,
useServices,
useEmployeePlanByPlanId,
} from "@/performance-management/hooks/useEmployeePlan";
import { FormField } from "@/performance-management/utils/shared-field-types";
import { useMinePlan } from "@/performance-management/hooks/usePlans";
// Define PlanType enum for employee plans
export enum EmployeePlanType {
NUMBER = "number",
PERCENTAGE = "percent",
BOOLEAN = "boolean",
}
export interface EmployeePlanFormValues {
parentEmployeePlanId?: string;
employeePositionId: string;
planId: string;
serviceId: string;
planType: EmployeePlanType;
expectedQuantity: number;
week: string | number;
month: string | number;
quarter: string | number;
timeframe: string;
}
interface EmployeePlanFormProps {
parentEmployeePlanId?: string;
onSuccess?: () => void;
}
export default function EmployeePlanForm({
parentEmployeePlanId: propParentId,
onSuccess,
}: EmployeePlanFormProps = {}) {
const navigate = useNavigate();
const { t } = useTranslation();
const {
id,
employeePositionId: pathEmployeePositionId,
planId: pathPlanId,
serviceId: pathServiceId,
} = useParams<{
id: string;
employeePositionId?: string;
planId?: string;
serviceId?: string;
}>();
const [searchParams] = useSearchParams();
const urlParentId = searchParams.get("parentEmployeePlanId");
const urlEmployeePositionId = searchParams.get("employeePositionId");
const urlPlanId = searchParams.get("planId");
const urlServiceId = searchParams.get("serviceId");
const parentId = propParentId || urlParentId;
const employeePositionId = urlEmployeePositionId || pathEmployeePositionId;
const planId = urlPlanId || pathPlanId;
const serviceId = urlServiceId || pathServiceId;
const { user, selectedPositionId } = useAuth();
const localizedName = useLocalizedName();
const isEdit = !!id;
// Pick the employee record matching the globally-selected position, so a
// user with positions in multiple orgs uses the right org. Falls back to
// the first employee record when nothing is selected.
const organizationId = useMemo(() => {
const employees = user?.employee ?? [];
if (selectedPositionId) {
const match = employees.find((emp) =>
emp.positions?.some(
(p) =>
p.employeePositionId === selectedPositionId ||
p.id === selectedPositionId,
),
);
if (match?.organizationId) return match.organizationId;
}
return employees[0]?.organizationId;
}, [user, selectedPositionId]);
const unitContext = useUnitContext();
const unitsQuery = unitContext?.getList(organizationId!, {
take: 300,
skip: 0,
});
const unitId = unitsQuery?.data?.data?.items?.[0]?.id ?? "";
const { data: positionsData } = usePositionsByUnit(unitId);
const { data: employeePlanDetail } = useEmployeePlan(id || "");
const { create: createMutation, update: updateMutation } =
useEmployeePlanMutations();
const { data: myPlans } = useMinePlan();
console.log("my plans ", myPlans);
//
const { data: employeePositionsData } = useEmployeePositions();
console.log("employee positoin ", employeePositionsData);
// Fetch services and parent employee plan details
const { data: servicesData } = useServices();
console.log("services ", servicesData);
// Fetch parent employee plan detail if parentId is present in URL
const { data: urlParentEmployeePlanDetail } = useEmployeePlan(parentId || "");
const normalizeTimeframe = (val: unknown): string => {
if (!val) return TimeFrame.YEAR;
const str = String(val).toLowerCase();
if (str.includes("year")) return TimeFrame.YEAR;
if (str.includes("quarter")) return TimeFrame.QUARTER;
if (str.includes("month")) return TimeFrame.MONTH;
if (str.includes("week")) return TimeFrame.WEEK;
if (str.includes("day")) return TimeFrame.DAY;
return TimeFrame.YEAR;
};
const initialEmployeePlanFormValues = useMemo(
() => ({
parentEmployeePlanId:
employeePlanDetail?.data?.parentEmployeePlanId || parentId || undefined,
employeePositionId:
employeePlanDetail?.data?.employeePositionId ||
employeePositionId ||
"",
planId: employeePlanDetail?.data?.planId || planId || "",
serviceId: employeePlanDetail?.data?.serviceId || serviceId || "",
planType: employeePlanDetail?.data?.planType || EmployeePlanType.NUMBER,
expectedQuantity: employeePlanDetail?.data?.expectedQuantity || 0,
week: employeePlanDetail?.data?.week?.toString() || "0",
month: employeePlanDetail?.data?.month?.toString() || "0",
quarter: employeePlanDetail?.data?.quarter?.toString() || "0",
timeframe: (() => {
const rawTimeframe = employeePlanDetail?.data?.timeframe;
if (rawTimeframe) return normalizeTimeframe(rawTimeframe);
// Fallback for sub-plan creation when parent data is missing but parentId is present
const parent = urlParentEmployeePlanDetail?.data;
if (!parentId || !parent) {
return parentId ? TimeFrame.QUARTER : TimeFrame.YEAR;
}
const pt = normalizeTimeframe(parent.timeframe);
if (pt === TimeFrame.YEAR) return TimeFrame.QUARTER;
if (pt === TimeFrame.QUARTER) return TimeFrame.QUARTER;
if (pt === TimeFrame.MONTH) return TimeFrame.WEEK;
if (pt === TimeFrame.WEEK) return TimeFrame.DAY;
return TimeFrame.YEAR;
})(),
}),
[
employeePlanDetail?.data,
parentId,
employeePositionId,
planId,
serviceId,
urlParentEmployeePlanDetail?.data,
]
);
const form = useForm<EmployeePlanFormValues>({
defaultValues: initialEmployeePlanFormValues,
values: isEdit || !!parentId ? initialEmployeePlanFormValues : undefined,
});
const watchedPlanId = useWatch({
control: form.control,
name: "planId",
});
const { data: parentEmployeePlanDetail } = useEmployeePlanByPlanId(
watchedPlanId || ""
);
const positionOptions =
positionsData?.data?.items?.map(
(position: { id: string; title: string }) => ({
value: position.id,
label: position.title,
icon: User,
})
) || [];
const employeePositionOptions =
employeePositionsData?.items?.flatMap((emp) =>
emp.employeePositions.map((empPos) => ({
value: empPos.id, // employeePositionId
label: `${emp.name.en} ${empPos.position?.name?.am}`,
icon: User,
}))
) || [];
// Mock data - replace with your actual data fetching
const planOptions =
myPlans?.data?.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
value: plan.id,
label: localizedName(plan.name),
icon: FileText,
})
) || [];
const serviceOptions =
servicesData?.data?.items?.map(
(service: { id: string; name: { am: string; en: string } }) => ({
value: service.id,
label: localizedName(service.name),
icon: Users,
})
) || [];
// Determine allowed timeframe options based on parent employee plan
const timeframeOptions = useMemo(() => {
// Current parent detail either from URL (parentId) or watched result
const parent =
urlParentEmployeePlanDetail?.data || parentEmployeePlanDetail?.data;
if (!parentId && !parent) {
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
];
}
const parentTimeframe = normalizeTimeframe(parent?.timeframe);
switch (parentTimeframe) {
case TimeFrame.YEAR:
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
{
value: TimeFrame.QUARTER,
label: t("employeePlans.quarterly", "Quarterly"),
},
];
case TimeFrame.QUARTER:
return [
{
value: TimeFrame.QUARTER,
label: t("employeePlans.quarterly", "Quarterly"),
},
];
case TimeFrame.MONTH:
return [
{
value: TimeFrame.WEEK,
label: t("employeePlans.weekly", "Weekly"),
},
];
case TimeFrame.WEEK:
return [
{
value: TimeFrame.DAY,
label: t("employeePlans.daily", "Daily"),
},
];
default:
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
];
}
}, [
parentId,
parentEmployeePlanDetail?.data,
urlParentEmployeePlanDetail?.data,
t,
]);
// Auto-select timeframe if there's only one option
useEffect(() => {
if (timeframeOptions.length === 1) {
const currentVal = form.getValues("timeframe");
if (currentVal !== timeframeOptions[0].value) {
form.setValue("timeframe", timeframeOptions[0].value as string, {
shouldValidate: true,
shouldDirty: true,
});
}
}
}, [timeframeOptions, form]);
useEffect(() => {
const current = form.getValues("timeframe");
const isValid = timeframeOptions.some((opt) => opt.value === current);
if (!isValid && timeframeOptions.length > 0) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}, [timeframeOptions, form]);
const planTypeOptions = Object.values(EmployeePlanType).map((type) => ({
value: type,
label: type.charAt(0).toUpperCase() + type.slice(1).toLowerCase(),
}));
// TODO: Fetch employee plans for parent selection
const employeePlanOptions =
parentEmployeePlanDetail?.data.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
value: plan.id,
label: localizedName(plan.name),
icon: FileText,
})
) || [];
const watchedTimeframe = useWatch({
control: form.control,
name: "timeframe",
});
// Reset hidden fields when timeframe changes
useEffect(() => {
if (watchedTimeframe === TimeFrame.YEAR) {
form.setValue("quarter", "0");
form.setValue("month", "0");
form.setValue("week", "0");
} else if (watchedTimeframe === TimeFrame.QUARTER) {
form.setValue("month", "0");
form.setValue("week", "0");
} else if (watchedTimeframe === TimeFrame.MONTH) {
form.setValue("week", "0");
}
// For WEEK and DAY, no reset needed as they inherit from parent timeframes
}, [watchedTimeframe, form]);
// Inherit context from parent employee plan
useEffect(() => {
if (parentId && parentEmployeePlanDetail?.data && !isEdit) {
const parent = parentEmployeePlanDetail.data;
// Inherit timeframe context
switch (parent.timeframe) {
case TimeFrame.YEAR:
form.setValue("timeframe", TimeFrame.QUARTER);
break;
case TimeFrame.QUARTER:
form.setValue("timeframe", TimeFrame.QUARTER);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
break;
case TimeFrame.MONTH:
form.setValue("timeframe", TimeFrame.WEEK);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
if (parent.month) form.setValue("month", parent.month.toString());
break;
case TimeFrame.WEEK:
form.setValue("timeframe", TimeFrame.DAY);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
if (parent.month) form.setValue("month", parent.month.toString());
if (parent.week) form.setValue("week", parent.week.toString());
break;
default:
break;
}
}
}, [parentId, parentEmployeePlanDetail, isEdit, form]);
const handleSubmit = (values: EmployeePlanFormValues) => {
// Prepare timeframe-specific fields based on selected timeframe
const getTimeframeFields = (): {
quarter: number;
month: number;
week: number;
} => {
const fields = {
quarter: 0,
month: 0,
week: 0,
};
switch (values.timeframe) {
case TimeFrame.QUARTER:
fields.quarter = Number(values.quarter);
break;
case TimeFrame.MONTH:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
break;
case TimeFrame.WEEK:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
fields.week = Number(values.week);
break;
case TimeFrame.DAY:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
fields.week = Number(values.week);
break;
// For YEAR, all fields remain 0
}
return fields;
};
const dto = {
id: isEdit ? id : undefined,
parentEmployeePlanId: values.parentEmployeePlanId,
employeePositionId: values.employeePositionId,
planId: values.planId,
serviceId: values.serviceId,
planType: values.planType,
expectedQuantity: Number(values.expectedQuantity),
timeframe: values.timeframe,
...getTimeframeFields(),
};
const mutation = isEdit ? updateMutation : createMutation;
mutation.mutate(dto, {
onSuccess: () => {
toast.success(
isEdit
? t(
"employeePlans.planUpdatedSuccess",
"Employee plan updated successfully!"
)
: t(
"employeePlans.planCreatedSuccess",
"Employee plan created successfully!"
),
{
description: `${t("employeePlans.plan", "Employee plan")} ${
isEdit
? t("common.updated", "updated")
: t("common.created", "created")
}.`,
}
);
onSuccess?.();
if (!isEdit) {
navigate(-1);
}
},
onError: (error: { message?: string }) => {
toast.error(
isEdit
? t(
"employeePlans.planUpdateFailed",
"Failed to update employee plan"
)
: t(
"employeePlans.planCreateFailed",
"Failed to create employee plan"
),
{
description:
error?.message ||
t("common.checkInput", "Please check your input and try again."),
}
);
},
});
};
// Define form fields
const employeePlanFormFields: FormField<EmployeePlanFormValues>[] = [
// Basic Assignment Section
{
name: "employeePositionId",
label: t("employeePlans.employeePosition", "Employee Position"),
type: "select",
options:
employeePositionOptions.length > 0
? employeePositionOptions
: positionOptions,
required: true,
icon: User,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "serviceId",
label: t("employeePlans.service", "Service"),
type: "select",
options: serviceOptions,
required: true,
icon: Users,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "planId",
label: t("employeePlans.plan", "Plan"),
type: "select",
options: planOptions,
required: true,
icon: FileText,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "parentEmployeePlanId",
label: t("employeePlans.parentEmployeePlan", "Parent Employee Plan"),
type: "select",
options: employeePlanOptions,
icon: FileText,
section: t("employeePlans.assignment", "Assignment"),
disabled: !!parentId,
colSpan: 6,
defaultValue: parentId || undefined,
},
// Plan Details Section
{
name: "planType",
label: t("employeePlans.planType", "Plan Type"),
type: "select",
options: planTypeOptions,
required: true,
icon: Target,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
{
name: "expectedQuantity",
label: t("employeePlans.expectedQuantity", "Expected Quantity"),
type: "number",
required: true,
icon: Target,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
{
name: "timeframe",
label: t("employeePlans.timeFrame", "Time Frame"),
type: "select",
options: timeframeOptions,
required: true,
disabled: timeframeOptions.length === 1,
icon: Calendar,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
// Timeframe Context Section
{
name: "quarter",
label: t("employeePlans.quarter", "Quarter"),
type: "number",
placeholder: t("employeePlans.enterQuarter", "Enter quarter (1-4)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
watchedTimeframe === TimeFrame.YEAR &&
(!form.getValues("quarter") ||
String(form.getValues("quarter")) === "0"),
colSpan: 4,
min: 1,
max: 4,
},
{
name: "month",
label: t("employeePlans.month", "Month"),
type: "number",
placeholder: t("employeePlans.enterMonth", "Enter month (1-12)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
![TimeFrame.MONTH, TimeFrame.WEEK, TimeFrame.DAY].includes(
watchedTimeframe as TimeFrame
) &&
(!form.getValues("month") || String(form.getValues("month")) === "0"),
colSpan: 4,
min: 1,
max: 12,
},
{
name: "week",
label: t("employeePlans.week", "Week"),
type: "number",
placeholder: t("employeePlans.enterWeek", "Enter week (1-52)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
![TimeFrame.WEEK, TimeFrame.DAY].includes(
watchedTimeframe as TimeFrame
) &&
(!form.getValues("week") || String(form.getValues("week")) === "0"),
colSpan: 4,
min: 1,
max: 52,
},
];
// Filter fields based on timeframe
const filteredFields = employeePlanFormFields.filter((field) => {
if (field.hidden) return false;
return true;
});
const handleCancel = () => {
navigate(-1);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 sm:p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 text-gray-700 hover:bg-gray-50">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{isEdit ? "Edit Employee Plan" : "Create Employee Plan"}
</h1>
<p className="text-gray-600 mt-1">
{isEdit
? "Update employee plan assignment and details"
: "Assign a plan to an employee with specific targets"}
</p>
</div>
</div>
<div className="flex items-center space-x-2 bg-white/80 backdrop-blur-sm rounded-2xl px-4 py-2 shadow-sm border border-gray-200">
<div
className={`w-2 h-2 rounded-full ${
isEdit ? "bg-amber-500" : "bg-green-500"
} animate-pulse`}></div>
<span className="text-sm font-medium text-gray-700">
{isEdit ? "Editing Mode" : "Creation Mode"}
</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}>
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-blue-500 to-cyan-500 text-white pb-6">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
{isEdit ? (
<FileText className="h-6 w-6 text-white" />
) : (
<Plus className="h-6 w-6 text-white" />
)}
</div>
<div>
<CardTitle className="text-2xl font-bold text-white">
{isEdit
? t(
"employeePlans.updateEmployeePlan",
"Update Employee Plan"
)
: t(
"employeePlans.newEmployeePlanAssignment",
"New Employee Plan Assignment"
)}
</CardTitle>
<CardDescription className="text-blue-100">
{isEdit
? t(
"employeePlans.modifyPlanDetails",
"Modify the employee plan details below"
)
: t(
"employeePlans.assignPlanWithTimeframe",
"Assign plan to employee with specific timeframe and targets"
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
{/* Parent Plan Info Alert */}
{parentId && parentEmployeePlanDetail?.data && (
<Alert className="mb-6 bg-blue-50/80 border-blue-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-blue-600" />
<AlertTitle className="text-blue-900 font-semibold">
{t(
"employeePlans.creatingSubEmployeePlan",
"Creating Sub-Employee Plan"
)}
</AlertTitle>
<AlertDescription className="text-blue-700">
{t(
"employeePlans.subPlanUnder",
"This employee plan will be created under"
)}
:{" "}
<strong className="text-blue-900">
{parentEmployeePlanDetail.data.name ||
t("employeePlans.parentPlan", "Parent Plan")}
</strong>
</AlertDescription>
</Alert>
)}
<GenericForm
form={form}
fields={filteredFields}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={
createMutation.isPending || updateMutation.isPending
}
submitButtonText={
isEdit
? t(
"employeePlans.updateEmployeePlan",
"Update Employee Plan"
)
: t(
"employeePlans.createEmployeePlan",
"Create Employee Plan"
)
}
cancelButtonText={t("employeePlans.cancel", "Cancel")}
successMessage={
isEdit
? t(
"employeePlans.planUpdatedSuccess",
"Employee plan updated successfully!"
)
: t(
"employeePlans.planCreatedSuccess",
"Employee plan created successfully!"
)
}
errorMessage={
isEdit
? t(
"employeePlans.planUpdateFailed",
"Failed to update employee plan"
)
: t(
"employeePlans.planCreateFailed",
"Failed to create employee plan"
)
}
/>
</CardContent>
</Card>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,317 @@
import { useState, useEffect } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Loader2, ArrowLeft } from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
updateEmployeePlan,
getEmployeePlan,
getPlansByUnit,
} from "@/performance-management/services/api/employeePlanService";
const EditEmployeePlanPage = () => {
const { id } = useParams<{ id: string }>();
const { toast } = useToast();
const { t } = useTranslation();
const navigate = useNavigate();
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingPlans, setIsLoadingPlans] = useState(false);
const [plans, setPlans] = useState<any[]>([]);
const [formData, setFormData] = useState({
planId: "",
employeePositionId: "",
serviceId: "",
timeframe: "",
expectedQuantity: 0,
planType: "number" as "number" | "percent",
});
// Fetch employee plan data when component mounts
useEffect(() => {
if (id) {
fetchEmployeePlan(id);
fetchPlans();
}
}, [id]);
const fetchEmployeePlan = async (planId: string) => {
setIsLoading(true);
try {
const response = await getEmployeePlan(planId);
const plan = response.data;
// Convert ISO dates to YYYY-MM-DD format for input fields
setFormData({
planId: plan.planId || "",
employeePositionId: plan.employeePositionId || "",
serviceId: plan.serviceId || "",
timeframe: plan.timeframe || "",
expectedQuantity: plan.expectedQuantity || 0,
planType: plan.planType || "number",
});
} catch (error) {
console.error("Failed to fetch employee plan:", error);
toast({
title: "Error",
description: "Failed to fetch employee plan data",
variant: "destructive",
});
navigate("/performance-management/employee-plans");
} finally {
setIsLoading(false);
}
};
const fetchPlans = async () => {
setIsLoadingPlans(true);
try {
const response = await getPlansByUnit("");
const plansData = response.data?.items || [];
setPlans(Array.isArray(plansData) ? plansData : []);
} catch (error) {
console.error("Failed to fetch plans:", error);
toast({
title: "Error",
description: "Failed to fetch plans",
variant: "destructive",
});
} finally {
setIsLoadingPlans(false);
}
};
const handleChange = (field: string, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id) {
toast({
title: "Error",
description: "Employee plan ID is missing",
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
await updateEmployeePlan(id, {
parentEmployeePlanId: formData.planId,
employeePositionId: formData.employeePositionId,
planId: formData.planId,
serviceId: formData.serviceId,
planType: formData.planType,
expectedQuantity: Number(formData.expectedQuantity),
timeframe: formData.timeframe,
});
toast({
title: "Success",
description: "Employee plan updated successfully",
variant: "default",
});
// Navigate back to list
navigate("/performance-management/employee-plans");
} catch (error: any) {
console.error("Failed to update employee plan:", error);
const errorMessage =
error.response?.data?.message ||
error.message ||
"Failed to update employee plan";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<div className="p-6 flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
</div>
);
}
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-6">
<Button
variant="ghost"
onClick={() => navigate("/performance-management/employee-plans")}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t("common.back", "Back")}
</Button>
<h1 className="text-2xl font-bold text-gray-900">
{t("employeePlans.editPlan", "Edit Employee Plan")}
</h1>
<p className="text-gray-600 mt-1">
{t(
"employeePlans.editDescription",
"Update the details of this employee performance plan"
)}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>
{t("employeePlans.planDetails", "Plan Details")}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Plan Selection */}
<div className="space-y-2">
<Label htmlFor="planId">
{t("employeePlans.plan", "Plan")}
<span className="text-red-500">*</span>
</Label>
{isLoadingPlans ? (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
{t("loading", "Loading plans...")}
</div>
) : plans.length > 0 ? (
<Select
value={formData.planId}
onValueChange={(value) => handleChange("planId", value)}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
<SelectContent>
{plans.map((plan: any) => (
<SelectItem key={plan.id} value={plan.id}>
{typeof plan.name === "string"
? plan.name
: plan.name?.en || plan.name?.am || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-gray-500">
{t("employeePlans.noPlans", "No plans available")}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Start Date */}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Expected Quantity */}
<div className="space-y-2">
<Label htmlFor="expectedQuantity">
{t("employeePlans.expectedQuantity", "Expected Quantity")}
</Label>
<Input
id="expectedQuantity"
type="number"
min="0"
step="0.01"
value={formData.expectedQuantity}
onChange={(e) =>
handleChange(
"expectedQuantity",
parseFloat(e.target.value) || 0
)
}
placeholder="Enter expected quantity"
/>
</div>
{/* Plan Type */}
<div className="space-y-2">
<Label htmlFor="planType">
{t("employeePlans.planType", "Plan Type")}
</Label>
<Select
value={formData.planType}
onValueChange={(value) => handleChange("planType", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select plan type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="number">
{t("employeePlans.number", "Number")}
</SelectItem>
<SelectItem value="percent">
{t("employeePlans.percentage", "Percent")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate("/performance-management/employee-plans")
}
disabled={isSaving}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
type="submit"
disabled={isSaving}
className="bg-purple-600 hover:bg-purple-700"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("saving", "Saving...")}
</>
) : (
t("employeePlans.update", "Update Plan")
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export default EditEmployeePlanPage;

View File

@@ -0,0 +1,203 @@
import { Users, AlertTriangle, CheckCircle2 } from "lucide-react";
import { useQueries, useQuery } from "@tanstack/react-query";
import {
getChildPositions,
getEmployeesUnderPosition,
} from "@/user-management/services/api/positionService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Avatar, AvatarFallback } from "@/shared/common/ui/avatar";
import { Badge } from "@/shared/common/ui/badge";
import type { PositionDto } from "@/user-management/dto/positions/positionDto";
/**
* Direct reports of the currently-active position.
*
* 1. GET /positions/children/:myPositionId → direct child positions
* (each item has parentPositionId === myPositionId)
* 2. For each child position, GET /positions/current/:childPositionId/employees
* → employees holding that position
*
* This bypasses the buggy /employees/immediate-child endpoint which was
* returning peers (rows sharing the same position_id) instead of children.
*/
export const MyTeamPanel = () => {
const { user, selectedPositionId } = useAuth();
const localizedName = useLocalizedName();
// Resolve the active position so we can use its `id` (not employeePositionId).
const allPositions =
user?.employee?.flatMap((emp) => emp.positions ?? []) ?? [];
const activePosition =
allPositions.find(
(p) =>
p.employeePositionId === selectedPositionId ||
p.id === selectedPositionId,
) ?? allPositions[0];
const myPositionId = activePosition?.id;
const {
data: childrenResp,
isLoading: childrenLoading,
error: childrenError,
} = useQuery({
queryKey: ["positionChildren", myPositionId],
queryFn: () => getChildPositions(myPositionId!),
enabled: !!myPositionId,
});
const childPositions: PositionDto[] = childrenResp?.data?.items ?? [];
// Fetch employees per direct-child position in parallel.
const employeeQueries = useQueries({
queries: childPositions.map((cp) => ({
queryKey: ["positionEmployees", cp.id],
queryFn: () => getEmployeesUnderPosition(cp.id),
enabled: !!cp.id,
})),
});
if (childrenLoading) {
return <Skeleton className="h-32 w-full rounded-xl mb-6" />;
}
if (childrenError) {
return (
<Card className="mb-6 border-red-200 bg-red-50">
<CardContent className="p-4 text-sm text-red-700">
Failed to load child positions:{" "}
{(childrenError as Error).message}
</CardContent>
</Card>
);
}
const totalEmployees = employeeQueries.reduce(
(sum, q) => sum + ((q.data?.data?.items?.length ?? q.data?.data?.length) || 0),
0,
);
return (
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-5 w-5" />
My Team
<Badge variant="secondary" className="ml-1">
{totalEmployees}
</Badge>
</CardTitle>
<div className="flex flex-wrap gap-2 text-xs mt-2 text-gray-500">
<span>
Acting as position id: <code>{myPositionId ?? "—"}</code>
</span>
<span>·</span>
<span>
{childPositions.length} direct child position
{childPositions.length === 1 ? "" : "s"}
</span>
</div>
</CardHeader>
<CardContent>
{childPositions.length === 0 ? (
<p className="text-sm text-gray-500">
No child positions exist under your current position.
</p>
) : (
<div className="space-y-4">
{childPositions.map((cp, idx) => {
const empQuery = employeeQueries[idx];
const employeesRaw =
empQuery?.data?.data?.items ?? empQuery?.data?.data ?? [];
const employees = Array.isArray(employeesRaw) ? employeesRaw : [];
// Sanity-check: every returned position should have
// parentPositionId === myPositionId. Anything else is suspicious.
const isCorrectChild = cp.parentPositionId === myPositionId;
return (
<div
key={cp.id}
className={`rounded-xl border p-3 ${
isCorrectChild
? "bg-white"
: "bg-amber-50 border-amber-200"
}`}
>
<div className="flex items-center gap-2 mb-3">
<Badge variant="outline">
{localizedName(cp.name) || cp.key}
</Badge>
{!isCorrectChild && (
<span
className="inline-flex items-center gap-1 text-xs text-amber-700"
title={`Expected parentPositionId=${myPositionId}, got ${cp.parentPositionId}`}
>
<AlertTriangle className="h-3 w-3" /> wrong parent
</span>
)}
<span className="text-xs text-gray-500 ml-auto">
{empQuery?.isLoading
? "loading…"
: `${employees.length} employee${employees.length === 1 ? "" : "s"}`}
</span>
</div>
{empQuery?.error ? (
<p className="text-xs text-red-600">
Failed to load employees for this position
</p>
) : employees.length === 0 && !empQuery?.isLoading ? (
<p className="text-xs text-gray-500">
No employees currently hold this position.
</p>
) : (
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{employees.map((emp: any) => {
const name = localizedName(emp.name);
const initials = name
?.split(" ")
.map((s: string) => s[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
return (
<li
key={emp.id}
className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-sm"
>
<Avatar className="h-8 w-8">
<AvatarFallback>
{initials || "?"}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="font-medium truncate text-xs">
{name}
</p>
</div>
<CheckCircle2 className="h-3 w-3 text-green-500 ml-auto" />
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,693 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/shared/common/ui/button";
import {
Loader2,
Target,
Hash,
Percent,
ArrowLeft,
Save,
Check,
CalendarDays,
Grid,
List,
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import { useToast } from "@/shared/common/ui/use-toast";
import { Badge } from "@/shared/common/ui/badge";
import {
ExtendedEmployeePlanDto,
useEmployeePlanMutations,
useSubEmployeePlanByPlanId,
} from "@/performance-management/hooks/useEmployeePlan";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/shared/common/ui/tabs";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import { cn } from "@/shared/lib/utils";
// Interface for the data passed via navigation state
interface NavigationStateData {
parentEmployeePlanId: string;
employeePositionId: string;
planId: string;
serviceId: string;
planType: "number" | "percent" | string;
currentQuantity: number;
expectedQuantity: number;
month: number;
timeframe: string;
planName: string;
serviceName: string;
weight: number;
status: string;
}
// Week data interface
interface WeekData {
weekNumber: number;
planned: number;
employeePlanId: string | null;
}
const WeeklyPlanAssignmentPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { yearId, monthId } = useParams();
const { toast } = useToast();
const { handleError } = useErrorHandler(t);
const [activeTab, setActiveTab] = useState<"table" | "calendar">("table");
const [isLoadingExistingPlans, setIsLoadingExistingPlans] = useState(false);
const queryClient = useQueryClient();
const { create, update } = useEmployeePlanMutations();
// Get data from navigation state
const navigationState = location.state as NavigationStateData;
// Destructure all necessary data from navigation state
const {
parentEmployeePlanId,
employeePositionId,
planId,
serviceId,
planType: initialPlanType,
expectedQuantity,
planName,
serviceName,
status,
month: monthFromPlan,
} = navigationState;
// Get month from URL param or plan data
const monthFromUrl = monthId ? parseInt(monthId) : monthFromPlan;
const [weeks, setWeeks] = useState<WeekData[]>([
{ weekNumber: 1, planned: 0, employeePlanId: null },
{ weekNumber: 2, planned: 0, employeePlanId: null },
{ weekNumber: 3, planned: 0, employeePlanId: null },
{ weekNumber: 4, planned: 0, employeePlanId: null },
]);
const [selectedPlanType, setSelectedPlanType] = useState<
"number" | "percent"
>(initialPlanType === "percent" ? "percent" : "number");
const [isSaving, setIsSaving] = useState(false);
const { data: existingPlanData, isLoading: isPlanLoading } =
useSubEmployeePlanByPlanId(navigationState.parentEmployeePlanId);
// Fetch existing weekly plans for this month.
// API shape:
// { "<monthNumber>": { "week_1": { id, quantity }, "week_2": { id, quantity }, ... } }
useEffect(() => {
if (!existingPlanData?.data || !monthFromUrl) return;
const monthData = existingPlanData.data[monthFromUrl.toString()];
if (!monthData) return;
// Build the weeks list dynamically so we render all weeks the API knows
// about (some months have 5 weeks, others 4).
const weekEntries = Object.keys(monthData)
.filter((k) => k.startsWith("week_"))
.map((k) => {
const weekNumber = parseInt(k.replace("week_", ""), 10);
const cell = monthData[k] as
| { id: string | null; quantity: number }
| number
| null
| undefined;
// Defensive: handle both the new shape ({ id, quantity }) and any
// legacy shape that may have returned a bare number.
if (cell && typeof cell === "object") {
return {
weekNumber,
planned: Number(cell.quantity) || 0,
employeePlanId: cell.id ?? null,
};
}
return {
weekNumber,
planned: typeof cell === "number" ? cell : 0,
employeePlanId: null,
};
})
.sort((a, b) => a.weekNumber - b.weekNumber);
if (weekEntries.length > 0) {
setWeeks(weekEntries);
}
}, [existingPlanData, monthFromUrl]);
// Sync loading state
useEffect(() => {
setIsLoadingExistingPlans(isPlanLoading);
}, [isPlanLoading]);
// Calculate total planned
const totalPlanned = weeks.reduce((sum, week) => sum + week.planned, 0);
// Update a week's value in local state
const updateWeekValue = (weekNumber: number, value: string) => {
const newValue = parseInt(value) || 0;
setWeeks((prevWeeks) =>
prevWeeks.map((w) =>
w.weekNumber === weekNumber ? { ...w, planned: newValue } : w,
),
);
};
// Quick set helper
const quickSetForAllWeeks = (value: number) => {
setWeeks((prevWeeks) => prevWeeks.map((w) => ({ ...w, planned: value })));
};
// Save all changes
const handleSaveAll = async () => {
if (!planId || !monthFromUrl) return;
// Check if plan is already accepted
if (status === "accepted") {
toast({
title: t("plans.planAlreadyAccepted", "Plan Already Accepted"),
description: t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
),
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
// Run sequentially so each create's returned id can be threaded into the
// next iteration's local state — prevents a duplicate-key error on a
// second save attempt where the previous create succeeded but the local
// state still shows employeePlanId = null.
for (const week of weeks) {
const employeePlanData: ExtendedEmployeePlanDto = {
employeePositionId,
planId,
serviceId,
planType: selectedPlanType === "number" ? "number" : selectedPlanType,
expectedQuantity: week.planned,
timeframe: "weekly",
week: week.weekNumber,
month: monthFromUrl,
parentEmployeePlanId,
};
try {
if (week.employeePlanId) {
await update.mutateAsync({
...employeePlanData,
id: week.employeePlanId,
});
} else {
const response = await create.mutateAsync(employeePlanData);
const newId =
(response as any)?.data?.id ?? (response as any)?.id;
if (newId) {
// Persist the new id so subsequent saves PUT instead of POST.
setWeeks((prev) =>
prev.map((w) =>
w.weekNumber === week.weekNumber
? { ...w, employeePlanId: newId }
: w,
),
);
week.employeePlanId = newId;
}
}
} catch (err: any) {
// 400 with duplicate-key constraint → the row already exists in the
// DB even though our local state didn't have its id. Refetch and
// bail out of this iteration; the user can re-save and the latest
// ids will be in state.
const message: string =
err?.response?.data?.message ?? err?.message ?? "";
const isDuplicate =
err?.response?.status === 400 &&
(message.toLowerCase().includes("duplicate") ||
err?.response?.data?.exception?.driverError?.code === "23505");
if (isDuplicate) {
await queryClient.invalidateQueries({
queryKey: ["employee-plans", planId],
});
throw new Error(
"This week already has a saved plan. Refreshing — please try save again.",
);
}
throw err;
}
}
queryClient.invalidateQueries({
queryKey: ["employee-plans", planId],
});
toast({
title: "Success",
description: "All weekly targets have been saved successfully.",
});
} catch (error) {
console.error("Failed to save plans:", error);
handleError(error);
} finally {
setIsSaving(false);
}
};
// Get month name
const getMonthName = (monthNum: number): string => {
const monthNames = [
"September",
"October",
"November",
"December",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
];
return monthNames[monthNum - 1] || `Month ${monthNum}`;
};
if (!navigationState) {
return (
<div className="p-6">
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6 text-center">
<h3 className="text-lg font-semibold text-red-700">
Missing Plan Data
</h3>
<p className="text-red-600 mt-2">
Please go back and select a plan to continue.
</p>
<Button
onClick={() =>
navigate(`/performance-management/${yearId}/employee-plans`)
}
className="mt-4"
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Plans
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(`/performance-management/${yearId}/employee-plans`)
}
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Plans
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Weekly Plan Assignment - {planName}
</h1>
<p className="text-gray-600 mt-1">
Set weekly targets for {getMonthName(monthFromUrl)} (4 weeks)
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={handleSaveAll}
disabled={
isSaving ||
create.isPending ||
update.isPending ||
status === "accepted"
}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white shadow-lg"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</div>
{/* Alert for Accepted Plans */}
{status === "accepted" && (
<Card className="mb-6 border-red-200 bg-red-50">
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<div className="text-red-600 mt-0.5">
<svg
className="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-red-900">
{t("plans.planAlreadyAccepted", "Plan Already Accepted")}
</h3>
<p className="text-red-700 text-sm mt-1">
{t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
)}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{[
{
title: "Service",
value: serviceName,
icon: Target,
color: "blue",
},
{
title: "Monthly Target",
value: expectedQuantity,
icon: selectedPlanType === "number" ? Hash : Percent,
color: "green",
subtitle: selectedPlanType === "number" ? "Number" : "Number",
},
{
title: "Current Total",
value: totalPlanned,
icon: Target,
color: "purple",
},
{
title: "Month",
value: getMonthName(monthFromUrl),
icon: CalendarDays,
color: "red",
subtitle: `${weeks.length} weeks`,
},
].map((card, idx) => (
<Card key={idx}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
{card.title}
</p>
<p className="text-2xl font-bold">{card.value}</p>
{card.subtitle && (
<p className="text-xs text-gray-500 mt-1">
{card.subtitle}
</p>
)}
</div>
<div className={`p-2 bg-${card.color}-100 rounded-lg`}>
<card.icon className={`h-6 w-6 text-${card.color}-600`} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* View Toggle */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col space-y-4">
<Tabs
value={activeTab}
onValueChange={(v: string) =>
setActiveTab(v as "table" | "calendar")
}
>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="table" className="flex items-center gap-2">
<List className="h-4 w-4" /> Table View
</TabsTrigger>
<TabsTrigger
value="calendar"
className="flex items-center gap-2"
>
<Grid className="h-4 w-4" /> Calendar View
</TabsTrigger>
</TabsList>
</Tabs>
{/* Plan Type Selection */}
<div className="flex items-center gap-4">
<div>
<Label>Plan Type</Label>
<div className="flex gap-2 mt-1">
<Button
variant={
selectedPlanType === "number" ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedPlanType("number")}
className="gap-2"
>
<Hash className="h-4 w-4" /> Number
</Button>
<Button
variant={
selectedPlanType === "number" ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedPlanType("number")}
className="gap-2"
>
<Percent className="h-4 w-4" /> Percent
</Button>
</div>
</div>
<div className="ml-auto">
<div className="flex gap-2 items-center">
<p className="text-sm text-gray-500 mr-2">Quick Set:</p>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(10)}
>
10
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(25)}
>
25
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(50)}
>
50
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(0)}
>
Clear
</Button>
</div>
</div>
</div>
{/* Month Info */}
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-gray-900">
{getMonthName(monthFromUrl)} Weekly Planning
</h4>
<p className="text-sm text-gray-600">
Set weekly targets below. Don't forget to save your changes.
</p>
</div>
<Badge
variant={status === "accepted" ? "default" : "secondary"}
>
Status: {status}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Loading State */}
{isLoadingExistingPlans ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
<span className="ml-2 text-gray-600">
Loading existing weekly plans...
</span>
</div>
) : activeTab === "table" ? (
<Card className="overflow-hidden">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>
Weekly Targets for {getMonthName(monthFromUrl)}
</CardTitle>
<CardDescription>
Enter targets for each week directly in the input fields.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-medium">
{selectedPlanType === "number" ? "Units" : "Percent"}
</Badge>
<Badge className="bg-blue-100 text-blue-800 border-blue-200">
Total Planned: {totalPlanned}
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Week</TableHead>
<TableHead className="text-center">Week 1</TableHead>
<TableHead className="text-center">Week 2</TableHead>
<TableHead className="text-center">Week 3</TableHead>
<TableHead className="text-center">Week 4</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-gray-50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-purple-600 font-medium">M</span>
</div>
<div>
<p className="font-medium text-gray-900">
My Weekly Targets
</p>
<p className="text-sm text-gray-500">
Month {monthFromUrl}
</p>
</div>
</div>
</TableCell>
{/* Weekly Cells */}
{weeks.map((week) => (
<TableCell
key={week.weekNumber}
className="text-center p-2"
>
<Input
type="number"
min="0"
value={week.planned || ""}
onChange={(e) =>
updateWeekValue(week.weekNumber, e.target.value)
}
className={cn(
"h-10 w-24 text-center mx-auto transition-all",
week.planned > 0
? "border-purple-300 bg-purple-50 font-semibold text-purple-900"
: "border-gray-200 text-gray-500",
)}
/>
</TableCell>
))}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2 text-sm text-gray-500">
{totalPlanned > 0 ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<div className="h-4 w-4" />
)}
<span>Total: {totalPlanned}</span>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Calendar View</CardTitle>
<CardDescription>
Coming soon: Interactive calendar view for weekly planning
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<CalendarDays className="h-12 w-12 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900">
Calendar View in Development
</h3>
<p className="text-gray-600 mt-2">
This feature will be available in the next update.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
};
export default WeeklyPlanAssignmentPage;

View File

@@ -0,0 +1,207 @@
import React, { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useMetabaseDashboard } from "../hooks/useMetabaseDashboard";
import { usePlanYear } from "../hooks/usePlanYear";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { Alert, AlertDescription } from "@/shared/common/ui/alert";
import { DashboardDropdown } from "@/shared/components/ui/DashboardDropdown";
import {
RefreshCw,
BarChart3,
AlertCircle,
ExternalLink,
Loader2,
Calendar,
} from "lucide-react";
import { useLocalizedName } from "@/shared/common/localizedName";
import { getFormattedDashboardLabel } from "../utils/dashboardUtils";
interface MetabaseDashboardProps {
className?: string;
planYearId?: string;
}
export const MetabaseDashboard: React.FC<MetabaseDashboardProps> = ({
className = "",
planYearId,
}) => {
const { yearId } = useParams<{ yearId: string }>();
const currentPlanYearId = planYearId || yearId;
const localizedName = useLocalizedName();
const {
dashboards,
selectedDashboard,
iframeUrl,
loading,
error,
fetchDashboards,
selectDashboard,
getFilteredDashboardUrl,
} = useMetabaseDashboard();
// Fetch plan year details if we have a plan year ID
const { data: planYearData } = usePlanYear(currentPlanYearId || "");
const planYear = planYearData?.data;
const handleRefresh = () => {
if (selectedDashboard && currentPlanYearId) {
getFilteredDashboardUrl(selectedDashboard.id, currentPlanYearId);
} else {
fetchDashboards();
}
};
// Auto-load filtered dashboard when plan year or selected dashboard changes
useEffect(() => {
if (selectedDashboard && currentPlanYearId) {
getFilteredDashboardUrl(selectedDashboard.id, currentPlanYearId);
}
}, [selectedDashboard, currentPlanYearId, getFilteredDashboardUrl]);
const openInNewTab = () => {
if (iframeUrl) {
window.open(iframeUrl, "_blank");
}
};
if (error) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
Dashboard Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button onClick={handleRefresh} className="mt-4" variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</CardContent>
</Card>
);
}
return (
<div className={`space-y-4 ${className}`}>
{/* Dashboard Header */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-3">
<BarChart3 className="h-6 w-6 text-indigo-600" />
<div>
<p className="text-sm text-muted-foreground mt-1">
{planYear
? `${planYear.year} - ${localizedName(
planYear.name
)} Performance Insights`
: "Real-time performance insights powered by Tria"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<DashboardDropdown
dashboards={dashboards}
selectedDashboard={selectedDashboard}
onSelect={selectDashboard}
isLoading={loading}
/>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
{iframeUrl && (
<Button
variant="outline"
size="icon"
onClick={openInNewTab}
title="Open in new tab"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{planYear && (
<Badge variant="secondary" className="bg-green-50 text-primary-700">
<Calendar className="h-3 w-3 mr-1" />
{planYear.year} - {localizedName(planYear.name)}
</Badge>
)}
{selectedDashboard && (
<>
<Badge
variant="secondary"
className="bg-indigo-50 text-indigo-700"
>
{getFormattedDashboardLabel(selectedDashboard.name)}
</Badge>
</>
)}
</div>
</CardHeader>
</Card>
{/* Dashboard Content */}
<Card className="min-h-[600px]">
<CardContent className="p-0">
{loading && !iframeUrl ? (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-indigo-600" />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
) : iframeUrl ? (
<div className="relative w-full h-[600px] rounded-lg overflow-hidden">
<iframe
src={iframeUrl}
className="w-full h-full border-0"
title={`${getFormattedDashboardLabel(selectedDashboard?.name) || "Performance"} Dashboard`}
allow="fullscreen"
loading="lazy"
/>
</div>
) : (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
{dashboards.length === 0
? "No dashboards available"
: "Select a dashboard to view analytics"}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import React from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { MetabaseDashboard } from "./MetabaseDashboard";
export const PerformanceDashboardPage = () => {
const { yearId } = useParams<{ yearId: string }>();
const { t } = useTranslation();
return (
<div className="p-4 md:p-6 space-y-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-800">
<span className="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{t(
"performanceDashboard.title",
"Performance Management Dashboard",
)}
</span>
</h1>
<p className="text-muted-foreground text-sm mt-1">
{t(
"performanceDashboard.description",
"Comprehensive performance analytics and insights",
)}
</p>
</div>
</div>
<MetabaseDashboard planYearId={yearId} />
</div>
);
};

View File

@@ -0,0 +1,902 @@
import { useForm, useWatch } from "react-hook-form";
import { motion } from "framer-motion";
import { Calendar, Target, FileText, ArrowLeft } from "lucide-react";
import {
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
usePlan,
usePlanMutations,
} from "@/performance-management/hooks/usePlans";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnitContext } from "@/shared/context/UnitContext";
import { usePositions } from "@/user-management/hooks/usePosition";
import {
usePlanYears,
usePlanYearById,
} from "@/performance-management/hooks/usePlanYear";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
import { Info } from "lucide-react";
import { GenericForm } from "@/performance-management/utils/generic-form"; // Adjust import path as needed
import PlanType, {
getDayList,
getMonthList,
getQuarterList,
getWeekList,
getPlanTypeOptions,
} from "@/performance-management/utils/plan-unit";
import {
FormField,
TimeFrame,
} from "@/performance-management/utils/shared-field-types";
export interface PlanFormValues {
nameAm: string;
nameEn: string;
descriptionAm: string;
descriptionEn: string;
positionId: string;
parentPlanId?: string;
planType: PlanType;
planYearId: string;
// initialGoal: string;
// expectedGoal: string;
weight: string;
month: string | number;
week: string | number;
day: string | number;
quarter: string | number;
timeframe: string | number;
planFor?: string;
}
// Removed local TimeFrame enum definition
interface PlanFormProps {
parentPlanId?: string;
onSuccess?: () => void;
}
export default function PlanForm({
parentPlanId: propParentId,
onSuccess,
}: PlanFormProps = {}) {
const navigate = useNavigate();
const { t } = useTranslation();
const {
id,
yearId: pathYearId,
positionId,
planId,
} = useParams<{
id: string;
yearId?: string;
positionId?: string;
planId?: string;
}>();
console.log("positon id", positionId);
const [searchParams] = useSearchParams();
const urlParentId =
searchParams.get("parentPlanId") || searchParams.get("parentId");
const queryYearId = searchParams.get("yearId");
// Get yearId from query params first (UUID), then fallback to path params
const urlYearId = queryYearId || pathYearId;
const location = useLocation();
const planFor = location.state?.planFor as string | undefined;
console.log("plan for in form", planFor);
const parentId =
propParentId || urlParentId || (planId === "root" ? undefined : planId);
const { user } = useAuth();
const localizedName = useLocalizedName();
const isEdit = !!id;
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const unitContext = useUnitContext();
const unitsQuery = unitContext?.getList(organizationId!, {
take: 300,
skip: 0,
});
const unitId = unitsQuery?.data?.data?.items?.[0]?.id ?? "";
// Fetch the specific plan being edited
const { data: planDetail } = usePlan(id ?? "");
// Fetch parent plan data if parentId is present
const { data: parentPlanDetail } = usePlan(parentId ?? "");
const { usePositionListByUnitId } = usePositions();
usePositionListByUnitId(unitId, { take: 100, skip: 0 });
const { data: planYearsData } = usePlanYears(organizationId ?? "", {
take: 100,
});
// Fetch specific plan year if yearId is provided in URL
const { data: currentPlanYearData } = usePlanYearById(urlYearId ?? "");
const { create: createMutation, update: updateMutation } = usePlanMutations();
const normalizeTimeframe = (val: unknown): string => {
if (!val) return TimeFrame.YEAR;
const str = String(val).toLowerCase();
if (str.includes("year")) return TimeFrame.YEAR;
if (str.includes("quarter")) return TimeFrame.QUARTER;
if (str.includes("month")) return TimeFrame.MONTH;
if (str.includes("week")) return TimeFrame.WEEK;
if (str.includes("day")) return TimeFrame.DAY;
return TimeFrame.YEAR;
};
const initialPlanFormValues = useMemo(
() => ({
nameAm: planDetail?.data?.name?.am || "",
nameEn: planDetail?.data?.name?.en || "",
descriptionAm: planDetail?.data?.description?.am || "",
descriptionEn: planDetail?.data?.description?.en || "",
positionId: positionId || "",
parentPlanId:
planDetail?.data?.parentPlanId || parentId || planId || undefined,
planType: planDetail?.data?.planType || "percent",
planYearId: planDetail?.data?.planYearId || urlYearId || "",
// initialGoal: planDetail?.data?.initialGoal?.toString() || "",
// expectedGoal: planDetail?.data?.expectedGoal?.toString() || "",
weight: planDetail?.data?.weight?.toString() || "",
month: planDetail?.data?.month?.toString() || "0",
week: planDetail?.data?.week?.toString() || "0",
day: planDetail?.data?.day?.toString() || "0",
quarter: planDetail?.data?.quarter?.toString() || "0",
timeframe: (() => {
const rawTimeframe = planDetail?.data?.timeframe;
if (rawTimeframe) return normalizeTimeframe(rawTimeframe);
// Fallback for sub-plan creation when parent data is missing but parentId is present
if (!parentId || !parentPlanDetail?.data) {
return parentId ? TimeFrame.QUARTER : TimeFrame.YEAR;
}
const pt = normalizeTimeframe(parentPlanDetail.data.timeframe);
if (pt === TimeFrame.YEAR) return TimeFrame.QUARTER;
if (pt === TimeFrame.QUARTER) return TimeFrame.QUARTER;
if (pt === TimeFrame.MONTH) return TimeFrame.WEEK;
if (pt === TimeFrame.WEEK) return TimeFrame.DAY;
return TimeFrame.YEAR;
})(),
}),
[
planDetail?.data,
positionId,
parentId,
planId,
urlYearId,
parentPlanDetail?.data,
],
);
const form = useForm<PlanFormValues>({
defaultValues: initialPlanFormValues,
values: isEdit || !!parentId ? initialPlanFormValues : undefined,
});
const watchedParentPlanId = useWatch({
control: form.control,
name: "parentPlanId",
});
const { data: watchedParentPlanDetail } = usePlan(
watchedParentPlanId && watchedParentPlanId !== "root"
? watchedParentPlanId
: "",
);
const effectiveParentDetail = watchedParentPlanDetail || parentPlanDetail;
const planOptions =
planDetail?.data?.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
label: localizedName(plan.name),
value: plan.id,
icon: FileText,
}),
) || [];
// Add parent plan to options if it exists and isn't already in the list
if (parentId && parentPlanDetail?.data) {
const parentPlanExists = planOptions.some(
(option: { label: string; value: string }) => option.value === parentId,
);
if (!parentPlanExists) {
planOptions.unshift({
label: localizedName(parentPlanDetail.data.name),
value: parentId,
icon: FileText,
});
}
}
// If yearId is in URL, show only that year; otherwise show all years
const planYearOptions =
urlYearId && currentPlanYearData?.data
? [
{
value: currentPlanYearData.data.id,
label: `${currentPlanYearData.data.year}`,
icon: Calendar,
},
]
: planYearsData?.data?.items?.map(
(year: { id: string; year: number }) => ({
value: year.id,
label: `${year.year}`,
icon: Calendar,
}),
) || [];
const planTypeOptions = useMemo(() => getPlanTypeOptions(), []);
const timeframeOptions = useMemo(() => {
const parent = effectiveParentDetail?.data;
// If no parent plan, only allow Yearly
if (!parentId && !parent && !watchedParentPlanId) {
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly"),
},
];
}
const parentTimeframe = normalizeTimeframe(parent?.timeframe);
// Based on parent timeframe, allow the next level down
switch (parentTimeframe) {
case TimeFrame.YEAR:
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly", "Yearly"),
},
{
value: TimeFrame.QUARTER,
label: t("plans.Quarterly"),
},
];
case TimeFrame.QUARTER:
return [
{
value: TimeFrame.QUARTER,
label: t("plans.Quarterly", "Quarterly"),
},
];
case TimeFrame.MONTH:
return [
{
value: TimeFrame.WEEK,
label: t("plans.Weekly"),
},
];
case TimeFrame.WEEK:
return [
{
value: TimeFrame.DAY,
label: t("plans.Daily"),
},
];
default:
// Fallback to yearly if parent timeframe is unknown
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly"),
},
];
}
}, [parentId, effectiveParentDetail?.data, watchedParentPlanId, t]);
// Memoize the list options to ensure they update when language changes
const monthListOptions = useMemo(() => getMonthList(t), [t]);
const weekListOptions = useMemo(() => getWeekList(t), [t]);
const dayListOptions = useMemo(() => getDayList(t), [t]);
const quarterListOptions = useMemo(() => getQuarterList(t), [t]);
const watchedTimeframe = useWatch({
control: form.control,
name: "timeframe",
});
// Reset hidden fields when timeframe changes, but preserve context for "drill-down" scenarios
useEffect(() => {
if (watchedTimeframe === TimeFrame.QUARTER) {
form.setValue("month", "0");
form.setValue("week", "0");
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.MONTH) {
form.setValue("week", "0");
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.WEEK) {
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.YEAR) {
form.setValue("quarter", "0");
form.setValue("month", "0");
form.setValue("week", "0");
form.setValue("day", "0");
}
}, [watchedTimeframe, form]);
// Auto-select timeframe if there's only one option
useEffect(() => {
if (timeframeOptions.length === 1) {
const currentVal = form.getValues("timeframe");
if (currentVal !== timeframeOptions[0].value) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}
}, [timeframeOptions, form]);
useEffect(() => {
const current = form.getValues("timeframe");
const isValid = timeframeOptions.some((opt) => opt.value === current);
if (!isValid && timeframeOptions.length > 0) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}, [timeframeOptions, form]);
// Inherit timeframe context from parent plan
useEffect(() => {
if (parentId && parentPlanDetail?.data && !isEdit) {
const parent = parentPlanDetail.data;
// Inherit Plan Year
if (parent.planYearId) {
form.setValue("planYearId", parent.planYearId);
}
// Determine Child Timeframe and Context based on Parent
switch (parent.timeframe) {
case TimeFrame.YEAR:
form.setValue("timeframe", TimeFrame.QUARTER);
break;
case TimeFrame.QUARTER:
form.setValue("timeframe", TimeFrame.QUARTER);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
break;
case TimeFrame.MONTH:
form.setValue("timeframe", TimeFrame.WEEK);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
if (parent.month) {
form.setValue("month", parent.month.toString());
}
break;
case TimeFrame.WEEK:
form.setValue("timeframe", TimeFrame.DAY);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
if (parent.month) {
form.setValue("month", parent.month.toString());
}
if (parent.week) {
form.setValue("week", parent.week.toString());
}
break;
default:
break;
}
}
}, [parentId, parentPlanDetail, isEdit, form]);
// Auto-populate plan year when yearId is in URL or when plan year data loads
useEffect(() => {
// If yearId is in URL, use the current plan year data
if (urlYearId && currentPlanYearData?.data) {
form.setValue("planYearId", currentPlanYearData.data.id);
} else if (planYearsData?.data?.items) {
const currentVal = form.getValues("planYearId");
// If current value is a year number (e.g. "2022"), try to find the UUID
const matched = planYearsData.data.items.find(
(y: { id: string; year: number }) =>
y.year.toString() === currentVal || y.id === currentVal,
);
if (matched && matched.id !== currentVal) {
form.setValue("planYearId", matched.id);
}
}
}, [form, planYearsData, urlYearId, currentPlanYearData]);
// Helper function to convert TimeFrame enum to number
// (Removed - not currently used)
const handleSubmit = (values: PlanFormValues) => {
// Check if plan is already accepted (case-insensitive)
const planStatus = planDetail?.data?.status?.toLowerCase();
console.log("Plan status:", planStatus, "Plan detail:", planDetail?.data);
if (isEdit && planStatus === "accepted") {
toast.error(t("plans.planAlreadyAccepted", "Plan is already accepted"), {
description: t(
"plans.cannotEditAcceptedPlan",
"You cannot edit a plan that has already been accepted",
),
});
return;
}
// Create base DTO without timeframe-specific fields
const baseDto = {
id: isEdit ? id : undefined,
name: {
am: values.nameAm,
en: values.nameEn,
},
description: {
am: values.descriptionAm,
en: values.descriptionEn,
},
positionId: positionId || values.positionId || undefined,
parentPlanId:
planId === "root" || !values.parentPlanId
? undefined
: (values.parentPlanId as string),
planType: values.planType || PlanType.PERCENTAGE,
planYearId: values.planYearId,
timeframe: values.timeframe as string,
// initialGoal: Number(values.initialGoal),
// expectedGoal: Number(values.expectedGoal),
weight: Number(values.weight),
planFor: planFor || values.planFor || undefined,
};
// Add timeframe-specific fields based on the selected timeframe
let timeframeSpecificFields = {};
switch (values.timeframe) {
case TimeFrame.YEAR:
// Yearly plans don't need quarter, month, week, day
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: undefined,
};
break;
case TimeFrame.QUARTER:
// Quarterly plans need quarter only
timeframeSpecificFields = {
quarter: Number(values.quarter),
month: undefined,
week: undefined,
day: undefined,
};
break;
case TimeFrame.MONTH:
// Monthly plans need quarter and month
timeframeSpecificFields = {
quarter: undefined,
month: Number(values.month),
week: undefined,
day: undefined,
};
break;
case TimeFrame.WEEK:
// Weekly plans need quarter, month, and week
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: Number(values.week),
day: undefined,
};
break;
case TimeFrame.DAY:
// Daily plans need all timeframe fields
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: Number(values.day),
};
break;
default:
// Fallback - set all to 0
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: undefined,
};
}
// Combine base DTO with timeframe-specific fields
const dto = {
...baseDto,
...timeframeSpecificFields,
};
const mutation = isEdit ? updateMutation : createMutation;
mutation.mutate(dto, {
onSuccess: () => {
toast.success(
isEdit
? t("plans.planUpdatedSuccess", "Plan updated successfully!")
: t("plans.planCreatedSuccess", "Plan created successfully!"),
{
description: `"${values.nameEn}" ${t(
"common.hasBeenText",
"has been",
)} ${
isEdit
? t("common.updated", "updated")
: t("common.created", "created")
}.`,
},
);
onSuccess?.();
if (!isEdit) {
// After creating a plan, navigate to the plans-table view for the
// appropriate year / position / parent plan. Use path params first
// (if the form was opened via a route containing them), otherwise
// fall back to the submitted form values.
const targetYear = urlYearId || values.planYearId || pathYearId;
const targetPosition = positionId || values.positionId;
const targetPlan =
planId || (values.parentPlanId as string) || parentId;
if (targetYear && targetPosition && targetPlan) {
navigate(
`/performance-management/${targetYear}/plans-table/${targetPosition}/${targetPlan}`,
);
} else if (targetYear && targetPosition) {
navigate(
`/performance-management/${targetYear}/plans-table/${targetPosition}`,
);
} else if (targetYear) {
navigate(`/performance-management/${targetYear}/plans`);
} else {
navigate(`/performance-management`);
}
}
},
});
};
// Define form fields with sections
const planFormFields: FormField<PlanFormValues>[] = [
// Basic Information Section
{
name: "nameAm",
label: t("plans.nameAmharic", "Name (Amharic)"),
type: "text",
placeholder: t("plans.planNameAmharic", "የዕቅድ ስም አማርኛ"),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 6,
},
{
name: "nameEn",
label: t("plans.nameEnglish", "Name (English)"),
type: "text",
placeholder: t("plans.planNameEnglish", "Plan Name in English"),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 6,
},
{
name: "descriptionEn",
label: t("plans.descriptionEnglish", "Description (English)"),
type: "textarea",
placeholder: t(
"plans.describePlanEnglish",
"Describe the plan in English...",
),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 12,
},
{
name: "descriptionAm",
label: t("plans.descriptionAmharic", "Description (Amharic)"),
type: "textarea",
placeholder: t("plans.describePlanAmharic", "ዕቅዱን በአማርኛ ይግለጹ..."),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 12,
},
// Organization Section
{
name: "planYearId",
label: t("plans.planYear", "Plan Year"),
type: "select",
options: planYearOptions,
required: true,
icon: Calendar,
section: t("plans.organization", "Organization"),
colSpan: 4,
disabled: !!urlYearId, // Disable if yearId is in URL params
},
{
name: "timeframe",
label: t("plans.timeFrame", "Time Frame"),
type: "select",
options: timeframeOptions,
placeholder: t("plans.enterTimeFrame", "Enter time frame"),
required: true,
disabled: timeframeOptions.length === 1,
icon: Calendar,
section: t("plans.organization", "Organization"),
colSpan: 4,
},
// {
// name: "initialGoal",
// label: t("plans.initialGoal", "Initial Goal"),
// type: "number",
// required: true,
// icon: Target,
// section: t("plans.organization", "Organization"),
// colSpan: 2,
// },
// {
// name: "expectedGoal",
// label: t("plans.expectedGoal", "Expected Goal"),
// type: "number",
// placeholder: t("plans.enterExpectedGoal", "Enter expected goal"),
// icon: Users,
// section: t("plans.organization", "Organization"),
// colSpan: 2,
// },
// Plan Details Section
{
name: "parentPlanId",
label: t("plans.parentPlan", "Parent Plan"),
type: "select",
options: planOptions,
icon: FileText,
section: t("plans.planDetails", "Plan Details"),
disabled: !!parentId || !!planId,
colSpan: 4,
defaultValue: parentId || planId || undefined,
},
{
name: "weight",
label: t("plans.weight", "Weight"),
type: "number",
placeholder: t("plans.enterWeight", "Enter weight"),
icon: Target,
section: t("plans.planDetails", "Plan Details"),
colSpan: 2,
},
{
name: "month",
label: t("plans.month", "Month"),
type: "select",
placeholder: t("plans.enterMonth", "Enter month"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.MONTH &&
(!form.getValues("month") || form.getValues("month") === "0"),
colSpan: 3,
options: monthListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.MONTH,
},
{
name: "week",
label: t("plans.week", "Week"),
type: "select",
placeholder: t("plans.enterWeek", "Enter week"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.WEEK &&
(!form.getValues("week") || form.getValues("week") === "0"),
colSpan: 4,
options: weekListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.WEEK,
},
{
name: "day",
label: t("plans.day", "Day"),
type: "select",
placeholder: t("plans.enterDay", "Enter day"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden: watchedTimeframe !== TimeFrame.DAY,
colSpan: 4,
options: dayListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.DAY,
},
{
name: "quarter",
label: t("plans.quarter", "Quarter"),
type: "select",
placeholder: t("plans.enterQuarter", "Enter quarter"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.QUARTER &&
(!form.getValues("quarter") || form.getValues("quarter") === "0"),
colSpan: 4,
options: quarterListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.QUARTER,
},
{
name: "planType",
label: t("plans.planType", "Plan Type"),
type: "select",
options: planTypeOptions,
icon: FileText,
section: t("plans.planDetails", "Plan Details"),
colSpan: 4,
},
];
// Filter fields based on timeframe
const filteredFields = planFormFields.filter((field) => {
if (field.hidden) return false;
return true;
});
const handleCancel = () => {
navigate(-1);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 sm:p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-8"
>
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t("common.back", "Back")}
</Button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{isEdit
? t("plans.editPlan", "Edit Plan")
: t("plans.createNewPlan", "Create New Plan")}
</h1>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-xl overflow-hidden">
<CardContent className="p-6">
{/* Plan Already Accepted Alert */}
{isEdit &&
planDetail?.data?.status?.toLowerCase() === "accepted" && (
<Alert className="mb-6 bg-red-50/80 border-red-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-red-600" />
<AlertTitle className="text-red-900 font-semibold">
{t("plans.planAlreadyAccepted", "Plan Already Accepted")}
</AlertTitle>
<AlertDescription className="text-red-700">
{t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
)}
</AlertDescription>
</Alert>
)}
{/* Parent Plan Info Alert */}
{parentId && parentPlanDetail?.data && (
<Alert className="mb-6 bg-blue-50/80 border-blue-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-blue-600" />
<AlertTitle className="text-blue-900 font-semibold">
{t("plans.creatingSubPlan", "Creating Sub-Plan")}
</AlertTitle>
<AlertDescription className="text-blue-700">
{t(
"plans.subPlanDescription",
"This plan will be created as a sub-plan under:",
)}{" "}
<strong className="text-blue-900">
{localizedName(parentPlanDetail.data.name)}
</strong>
</AlertDescription>
</Alert>
)}
<GenericForm
form={form}
fields={filteredFields.map((field) => ({
...field,
disabled:
field.disabled ||
(isEdit &&
planDetail?.data?.status?.toLowerCase() === "accepted"),
}))}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={
createMutation.isPending || updateMutation.isPending
}
submitButtonText={
isEdit
? t("plans.updatePlan", "Update Plan")
: t("plans.createPlan", "Create Plan")
}
cancelButtonText={t("common.cancel", "Cancel")}
successMessage={
isEdit
? t(
"plans.planUpdatedSuccessfully",
"Plan updated successfully!",
)
: t(
"plans.planCreatedSuccessfully",
"Plan created successfully!",
)
}
errorMessage={
isEdit
? t("plans.failedToUpdatePlan", "Failed to update plan")
: t("plans.failedToCreatePlan", "Failed to create plan")
}
/>
</CardContent>
</Card>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,317 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeftIcon,
EditIcon,
CalendarIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Separator } from "@/shared/common/ui/separator";
import { usePlan, checkIsUnitAdmin } from "../../hooks/usePlans";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useMemo, useState } from "react";
import PlanForm from "./PlanForm";
const PlanDetails = () => {
const { id } = useParams();
const navigate = useNavigate();
const localizedName = useLocalizedName();
const { user } = useAuth();
const { yearId } = useParams();
const [searchParams] = useSearchParams();
const positionId = searchParams.get("positionId");
// Check if user is unit admin
const isUnitAdmin = useMemo(() => checkIsUnitAdmin(user), [user]);
// State to control sub-plan form visibility
const [showSubPlanForm, setShowSubPlanForm] = useState(false);
// TanStack Query hook
const { data, isLoading, isError, error, refetch } = usePlan(id || "");
const plan = data?.data;
if (isLoading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-2/5" />
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
</CardContent>
</Card>
<div className="flex gap-4">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</div>
);
}
if (isError) {
return (
<div className="p-6 space-y-6">
<Card>
<CardContent className="p-6">
<div className="text-red-600 text-lg mb-4">
Error loading plan details
</div>
<div className="text-muted-foreground mb-4">
{error?.message || "Failed to load plan data. Please try again."}
</div>
<div className="flex gap-4">
<Button onClick={() => refetch()} variant="outline">
Retry
</Button>
<Button
variant="ghost"
onClick={() => navigate("/performance-management/plans")}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
if (!plan) {
return (
<div className="p-6 space-y-6">
<Card>
<CardContent className="p-6">
<div className="text-lg mb-4">Plan not found</div>
<Button
variant="ghost"
onClick={() =>
navigate(
`/performance-management/${yearId}/plans-table/${positionId}`
)
}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold">Plan Details</h2>
<p className="text-muted-foreground">Plan ID: {plan.id}</p>
</div>
<Button
variant="ghost"
onClick={() =>
navigate(
`/performance-management/${yearId}/plans-table/${positionId}`
)
}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
<Card>
<CardContent className="p-6 space-y-6">
{/* Plan Name */}
<div>
<div className="text-sm text-muted-foreground mb-2">Plan Name</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base font-semibold">
{localizedName(plan.name)}
</div>
</div>
</div>
{/* Description */}
<div>
<div className="text-sm text-muted-foreground mb-2">
Description
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">{localizedName(plan.description)}</div>
</div>
</div>
{/* Plan Details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Calendar Year
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-blue-600" />
<div className="text-base font-semibold">
{plan.calendarYear}
</div>
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">
Start Date
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{new Date(plan.startDate).toLocaleDateString()}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">End Date</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{new Date(plan.endDate).toLocaleDateString()}
</div>
</div>
</div>
</div>
{/* Related IDs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Position ID
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div
className="text-base font-mono truncate"
title={plan.positionId}>
{plan.positionId}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Unit ID</div>
<div className="p-3 border rounded-md bg-muted/50">
<div
className="text-base font-mono truncate"
title={plan.unitId}>
{plan.unitId}
</div>
</div>
</div>
</div>
<Separator />
{/* Timestamps */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Created At
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{plan.createdAt
? new Date(plan.createdAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">
Last Updated
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{plan.updatedAt
? new Date(plan.updatedAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end pt-4 gap-4">
<Button variant="outline" onClick={() => navigate(-1)}>
Back to Plans
</Button>
{/* Show Edit button only for Unit Admins */}
{isUnitAdmin && (
<Button
onClick={() =>
navigate(`/performance-management/plans/${id}/edit`)
}
className="flex items-center gap-2">
<EditIcon className="h-4 w-4" />
Edit Plan
</Button>
)}
{/* Show Add Sub-Plan button only for Non-Unit Admins */}
{/* {!isUnitAdmin && (
<Button
onClick={() => setShowSubPlanForm(!showSubPlanForm)}
className="flex items-center gap-2 bg-gradient-to-r from-purple-500 to-violet-600 hover:from-purple-600 hover:to-violet-700 text-white">
{showSubPlanForm ? (
<>
<XIcon className="h-4 w-4" />
Cancel
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Add Sub-Plan
</>
)}
</Button>
)} */}
</div>
</CardContent>
</Card>
{/* Inline Sub-Plan Form */}
{!isUnitAdmin && showSubPlanForm && (
<Card className="border-purple-200 border-2">
<CardContent className="p-6">
<div className="mb-4">
<h3 className="text-xl font-bold text-purple-700">
Create Sub-Plan
</h3>
<p className="text-sm text-muted-foreground">
Creating a sub-plan under:{" "}
<span className="font-semibold">
{localizedName(plan.name)}
</span>
</p>
</div>
<Separator className="mb-6" />
<PlanForm
parentPlanId={id}
onSuccess={() => {
setShowSubPlanForm(false);
refetch();
}}
/>
</CardContent>
</Card>
)}
</div>
);
};
export default PlanDetails;

Some files were not shown because too many files have changed in this diff Show More