mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix ui
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
12
apps/edr-freight-web/backoffice/src/DMS/api/dms.api.ts
Normal file
12
apps/edr-freight-web/backoffice/src/DMS/api/dms.api.ts
Normal 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 [];
|
||||
}
|
||||
24
apps/edr-freight-web/backoffice/src/DMS/api/dms.http.ts
Normal file
24
apps/edr-freight-web/backoffice/src/DMS/api/dms.http.ts
Normal 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>()),
|
||||
};
|
||||
12
apps/edr-freight-web/backoffice/src/DMS/api/folders.api.ts
Normal file
12
apps/edr-freight-web/backoffice/src/DMS/api/folders.api.ts
Normal 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 [];
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 8–9 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const COMPLAINT_SUBMIT_PATH =
|
||||
"/external-portal/portal-outgoing/submit-letter";
|
||||
|
||||
export const COMPLAINT_RECORDS_PATH = "/external-portal/portal-outgoing";
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import UserTypeSelection from "./UserTypeSelection";
|
||||
|
||||
export default function ExternalAuthPage() {
|
||||
return <UserTypeSelection />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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: "",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// import VerifyOtp from "../components/verifyOTP";
|
||||
|
||||
// export const OtpVerificationPage = () => {
|
||||
// return <VerifyOtp />;
|
||||
// // }
|
||||
@@ -0,0 +1,5 @@
|
||||
import PortalOutgoing from "../components/outgoing/portalOutgoing"
|
||||
|
||||
export const PortalOutgoingPage =()=>{
|
||||
return <PortalOutgoing/>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Signup from "../components/signUp"
|
||||
|
||||
export const SignUpPage = ()=>{
|
||||
return <Signup/>
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
const UploadStepsPage = () => {
|
||||
return (<div></div>);
|
||||
};
|
||||
|
||||
export default UploadStepsPage;
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import LandingPageLayout from "../components/landingOutlet";
|
||||
|
||||
export const LandingPage =()=>{
|
||||
return <LandingPageLayout />;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
22
apps/edr-freight-web/backoffice/src/i18n/index.ts
Normal file
22
apps/edr-freight-web/backoffice/src/i18n/index.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
156
apps/edr-freight-web/backoffice/src/layout/AppSidebar.tsx
Normal file
156
apps/edr-freight-web/backoffice/src/layout/AppSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
12
apps/edr-freight-web/backoffice/src/layout/FayidaSignup.tsx
Normal file
12
apps/edr-freight-web/backoffice/src/layout/FayidaSignup.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
const FayidaSignup = ()=> {
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default FayidaSignup;
|
||||
357
apps/edr-freight-web/backoffice/src/layout/TopBar.tsx
Normal file
357
apps/edr-freight-web/backoffice/src/layout/TopBar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
374
apps/edr-freight-web/backoffice/src/layout/components/Footer.tsx
Normal file
374
apps/edr-freight-web/backoffice/src/layout/components/Footer.tsx
Normal 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">
|
||||
© {t("landingPage.copyright")}
|
||||
{new Date().getFullYear()} {t("landingPage.rightsReserved")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
703
apps/edr-freight-web/backoffice/src/layout/components/Header.tsx
Normal file
703
apps/edr-freight-web/backoffice/src/layout/components/Header.tsx
Normal 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;
|
||||
@@ -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;
|
||||
609
apps/edr-freight-web/backoffice/src/layout/components/Howto.tsx
Normal file
609
apps/edr-freight-web/backoffice/src/layout/components/Howto.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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 Ethiopia’s 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 Ababa–Djibouti 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 Ababa–Djibouti 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 world’s 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 Ethiopia’s 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 Ethiopia’s 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 Ethiopia’s 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 Ethiopia’s 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 Ethiopia’s 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;
|
||||
};
|
||||
@@ -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;
|
||||
39
apps/edr-freight-web/backoffice/src/layout/landingpage.tsx
Normal file
39
apps/edr-freight-web/backoffice/src/layout/landingpage.tsx
Normal 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;
|
||||
268
apps/edr-freight-web/backoffice/src/pages/ActivityLogPage.tsx
Normal file
268
apps/edr-freight-web/backoffice/src/pages/ActivityLogPage.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@/shared/common/ui/table";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { listAuditLogExtensions } from "@/shared/services/audit/audit.api";
|
||||
|
||||
interface ActivityLog {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
entityName: string;
|
||||
queryMethod: "INSERT" | "UPDATE" | "DELETE" | string;
|
||||
user: {
|
||||
id: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
email: string;
|
||||
username?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export default function ActivityLogPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [allLogs, setAllLogs] = useState<ActivityLog[]>([]);
|
||||
const [filteredLogs, setFilteredLogs] = useState<ActivityLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const fetchAuditLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch all 1000 records from super admin endpoint
|
||||
const data = await listAuditLogExtensions(
|
||||
"/audit-log-extensions/audit/superAdmin",
|
||||
{
|
||||
skip: 0,
|
||||
take: 1000,
|
||||
orderBy: "createdAt:DESC",
|
||||
}
|
||||
);
|
||||
|
||||
const logs = (data.items || []) as ActivityLog[];
|
||||
setAllLogs(logs);
|
||||
setCurrentPage(1);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching audit logs:", error);
|
||||
setAllLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchAuditLogs, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchAuditLogs]);
|
||||
|
||||
// Paginate the logs whenever currentPage changes
|
||||
useEffect(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
let filtered = allLogs.slice(startIndex, endIndex);
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(
|
||||
(log) =>
|
||||
log.entityName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.user.name.am.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.user.name.en.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.user.email.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredLogs(filtered);
|
||||
}, [allLogs, currentPage, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(allLogs.length / ITEMS_PER_PAGE);
|
||||
const hasNextPage = currentPage < totalPages;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{t("activityLogPage.title")}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={fetchAuditLogs}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
{t("common.refresh", "Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
placeholder={t("activityLogPage.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("activityLogPage.table.time")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[400px]">
|
||||
{t("activityLogPage.table.description")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("activityLogPage.table.performedBy")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span>{t("common.loading", "Loading...")}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log) => {
|
||||
const userName =
|
||||
i18n.language === "am" ? log.user.name.am : log.user.name.en;
|
||||
const entityLabel = log.entityName.replace(/_/g, " ");
|
||||
const actionVerb =
|
||||
log.queryMethod.toLowerCase() === "insert"
|
||||
? "created"
|
||||
: log.queryMethod.toLowerCase() === "update"
|
||||
? "updated"
|
||||
: log.queryMethod.toLowerCase() === "delete"
|
||||
? "deleted"
|
||||
: log.queryMethod.toLowerCase();
|
||||
const timestamp = new Date(log.createdAt).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-medium">{timestamp}</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="font-semibold">{userName}</span>
|
||||
{" "}
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{actionVerb}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
||||
{entityLabel}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{userName}</p>
|
||||
<p className="text-xs text-gray-500">{log.user.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
{t("activityLogPage.table.noLogs")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
{allLogs.length > 0 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t("common.showing", "Showing")} {(currentPage - 1) * ITEMS_PER_PAGE + 1}-
|
||||
{Math.min(currentPage * ITEMS_PER_PAGE, allLogs.length)}{" "}
|
||||
{t("common.of", "of")} {allLogs.length}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={!hasPrevPage || loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
|
||||
{currentPage} / {totalPages || 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={!hasNextPage || loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import SitesPage from "@/super-admin/components/sites/SitesPage";
|
||||
|
||||
const AddSitePage = () => {
|
||||
return <SitesPage />;
|
||||
};
|
||||
|
||||
export default AddSitePage;
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArchiveRestore, Building2, UserSquare2, Trash2, Search, Filter } from "lucide-react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import {
|
||||
useArchivedPositions,
|
||||
useArchivedUnits,
|
||||
useArchiveActions,
|
||||
} from "@/user-management/hooks/useArchived";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { deleteUnit } from "@/user-management/services/api/unitService";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMyAdminOrganizations } from "@/user-management/hooks/useOrgAdminOrganizations";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
|
||||
type Tab = "units" | "positions";
|
||||
|
||||
const ArchivedUnitsPositionsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { getAccessibleList } = useUnit();
|
||||
const { organizations: myAdminOrganizations } = useMyAdminOrganizations();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("units");
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>("");
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [unitToDelete, setUnitToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const { data: unitsResponse, isLoading: isLoadingUnits } = getAccessibleList(
|
||||
selectedOrgId || "",
|
||||
{ take: 300, skip: 0 },
|
||||
!!selectedOrgId
|
||||
);
|
||||
|
||||
const unitList: UnitDto[] = selectedOrgId ? (unitsResponse?.data?.items ?? []) : [];
|
||||
|
||||
const { data: archivedUnitsData, refetch: refetchArchivedUnits, isLoading: isLoadingArchivedUnits } =
|
||||
useArchivedUnits(selectedOrgId || undefined);
|
||||
const { data: archivedPositionsData, refetch: refetchArchivedPositions, isLoading: isLoadingArchivedPositions } =
|
||||
useArchivedPositions(selectedUnitId || undefined);
|
||||
|
||||
const { restoreUnit, isRestoringUnit, restorePosition, isRestoringPosition } =
|
||||
useArchiveActions();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "units" && !selectedOrgId && myAdminOrganizations.length > 0) {
|
||||
setSelectedOrgId(myAdminOrganizations[0].id);
|
||||
}
|
||||
}, [activeTab, myAdminOrganizations.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "positions" && !selectedUnitId && unitList.length > 0) {
|
||||
setSelectedUnitId(unitList[0].id);
|
||||
}
|
||||
}, [activeTab, unitList.length]);
|
||||
|
||||
const handlePermanentDelete = async () => {
|
||||
if (!unitToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteUnit(unitToDelete.id);
|
||||
toast.success(t("archive.unitDeletedPermanently", "Unit permanently deleted"));
|
||||
setDeleteConfirmOpen(false);
|
||||
setUnitToDelete(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["archived-units"] });
|
||||
refetchArchivedUnits();
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 400 && error?.response?.data?.message) {
|
||||
const errorMessage = error?.response?.data?.message;
|
||||
|
||||
if (errorMessage.includes("Referenced Entity") || errorMessage.includes("referenced")) {
|
||||
toast.error(
|
||||
t("archive.cannotDeleteUnitWithPositions", "Cannot delete unit with related positions"),
|
||||
{
|
||||
description: t("archive.deletePositionsFirst", "Please delete or reassign all positions first."),
|
||||
duration: 5000
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(t("archive.deleteUnitFailed", "Failed to delete unit"));
|
||||
}
|
||||
} else {
|
||||
toast.error(t("archive.deleteUnitFailed", "Failed to delete unit"));
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const archivedUnits: any[] = useMemo(
|
||||
() => archivedUnitsData?.items ?? archivedUnitsData ?? [],
|
||||
[archivedUnitsData],
|
||||
);
|
||||
const archivedPositions: any[] = useMemo(
|
||||
() => archivedPositionsData?.items ?? archivedPositionsData ?? [],
|
||||
[archivedPositionsData],
|
||||
);
|
||||
|
||||
const unitColumns = [
|
||||
{
|
||||
id: "name",
|
||||
header: t("organization.name", "Name"),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{localizedName(row.original?.name) || "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t("archive.archivedAt", "Archived")}: {new Date(row.original?.archivedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "key",
|
||||
header: t("organization.key", "Key"),
|
||||
cell: ({ row }: any) => (
|
||||
<Badge variant="outline" className="font-mono bg-gray-50">
|
||||
{row.original?.key || "—"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: t("common.status", "Status"),
|
||||
cell: ({ row }: any) => (
|
||||
<Badge variant="destructive" className="bg-amber-100 text-amber-800 hover:bg-amber-100">
|
||||
{t("archive.archived", "Archived")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: t("userIncomingretun.Actions", "Actions"),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isRestoringUnit}
|
||||
onClick={() => restoreUnit(row.original.id)}>
|
||||
<ArchiveRestore className="h-4 w-4 text-emerald-600" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setUnitToDelete({
|
||||
id: row.original.id,
|
||||
name: localizedName(row.original?.name),
|
||||
});
|
||||
setDeleteConfirmOpen(true);
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const positionColumns = [
|
||||
{
|
||||
id: "name",
|
||||
header: t("organization.name", "Name"),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<UserSquare2 className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{localizedName(row.original?.name) || "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t("archive.archivedAt", "Archived")}: {new Date(row.original?.archivedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "key",
|
||||
header: t("organization.key", "Key"),
|
||||
cell: ({ row }: any) => (
|
||||
<Badge variant="outline" className="font-mono bg-gray-50">
|
||||
{row.original?.key || "—"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: t("common.status", "Status"),
|
||||
cell: ({ row }: any) => (
|
||||
<Badge variant="destructive" className="bg-amber-100 text-amber-800 hover:bg-amber-100">
|
||||
{t("archive.archived", "Archived")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: t("userIncomingretun.Actions", "Actions"),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isRestoringPosition}
|
||||
onClick={() => restorePosition(row.original.id)}
|
||||
className="gap-1.5 border-emerald-200 text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800">
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
<span>{t("archive.restore", "Restore")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const isLoading = activeTab === "units" ? isLoadingArchivedUnits : isLoadingArchivedPositions;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent">
|
||||
{t("archive.archivedItems", "Archived Items")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||
{t("archive.manageArchivedContent", "Restore or permanently delete archived content")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{activeTab === "units" ? archivedUnits.length : archivedPositions.length} {t("common.items", "items")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Buttons */}
|
||||
<div className="flex gap-2 border-b border-gray-200 bg-white rounded-t-xl p-1 shadow-sm">
|
||||
<Button
|
||||
variant={activeTab === "units" ? "default" : "ghost"}
|
||||
onClick={() => setActiveTab("units")}
|
||||
className={`flex-1 md:flex-none gap-2 transition-all ${
|
||||
activeTab === "units"
|
||||
? "shadow-md"
|
||||
: "hover:bg-gray-50"
|
||||
}`}>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span>{t("archive.archivedUnits", "Archived Units")}</span>
|
||||
{activeTab === "units" && (
|
||||
<span className="ml-1 text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{archivedUnits.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "positions" ? "default" : "ghost"}
|
||||
onClick={() => setActiveTab("positions")}
|
||||
className={`flex-1 md:flex-none gap-2 transition-all ${
|
||||
activeTab === "positions"
|
||||
? "shadow-md"
|
||||
: "hover:bg-gray-50"
|
||||
}`}>
|
||||
<UserSquare2 className="h-4 w-4" />
|
||||
<span>{t("archive.archivedPositions", "Archived Positions")}</span>
|
||||
{activeTab === "positions" && (
|
||||
<span className="ml-1 text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{archivedPositions.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
{activeTab === "units"
|
||||
? t("organization.selectOrganization", "Select Organization")
|
||||
: t("organization.selectUnit", "Select Unit")}
|
||||
</label>
|
||||
{activeTab === "units" ? (
|
||||
myAdminOrganizations.length === 0 ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedOrgId}
|
||||
onValueChange={(value) => setSelectedOrgId(value)}>
|
||||
<SelectTrigger className="w-full border-gray-300 focus:ring-2 focus:ring-primary/20">
|
||||
<SelectValue
|
||||
placeholder={t("organization.selectOrganization")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{myAdminOrganizations.map((org: any) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
{localizedName(org.name)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
) : (
|
||||
unitList.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-2">
|
||||
{isLoadingUnits ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
t("common.noData", "No units available")
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedUnitId}
|
||||
onValueChange={(value) => setSelectedUnitId(value)}>
|
||||
<SelectTrigger className="w-full border-gray-300 focus:ring-2 focus:ring-primary/20">
|
||||
<SelectValue placeholder={t("organization.selectUnit")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitList.map((unit) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
{localizedName(unit.name)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{activeTab === "units" && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{t("common.filter", "Filter")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="shadow-sm border border-gray-200 overflow-hidden">
|
||||
<CardHeader className="bg-gray-50/50 border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
{activeTab === "units" ? (
|
||||
<>
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
{t("archive.archivedUnits", "Archived Units")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserSquare2 className="h-5 w-5 text-blue-600" />
|
||||
{t("archive.archivedPositions", "Archived Positions")}
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (activeTab === "units") {
|
||||
refetchArchivedUnits();
|
||||
} else {
|
||||
refetchArchivedPositions();
|
||||
}
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700">
|
||||
<span className="sr-only">{t("common.refresh", "Refresh")}</span>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === "units" ? (
|
||||
archivedUnits.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<Building2 className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">
|
||||
{t("common.noData", "No archived units")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("archive.noArchivedUnits", "No archived units found for this organization")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={unitColumns as any}
|
||||
data={archivedUnits}
|
||||
tableName="ArchivedUnits"
|
||||
toolBarPosition="right"
|
||||
itemCount={archivedUnits.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
nextFunction={() => {}}
|
||||
prevFunction={() => {}}
|
||||
refresh={refetchArchivedUnits}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
archivedPositions.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<UserSquare2 className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">
|
||||
{t("common.noData", "No archived positions")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("archive.noArchivedPositions", "No archived positions found for this unit")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={positionColumns as any}
|
||||
data={archivedPositions}
|
||||
tableName="ArchivedPositions"
|
||||
toolBarPosition="right"
|
||||
itemCount={archivedPositions.length}
|
||||
pageIndex={0}
|
||||
onPageChange={() => {}}
|
||||
nextFunction={() => {}}
|
||||
prevFunction={() => {}}
|
||||
refresh={refetchArchivedPositions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||
<Trash2 className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<AlertDialogTitle className="text-center">
|
||||
{t("archive.deletePermanentlyConfirm", "Permanently delete unit?")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-center">
|
||||
<span className="block mb-2">
|
||||
{t("archive.deletePermanentlyDescription", "This will permanently delete")}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 block text-base">
|
||||
"{unitToDelete?.name}"
|
||||
</span>
|
||||
<span className="block mt-2 text-red-600 text-sm font-medium">
|
||||
{t("archive.cannotBeUndone", "This action cannot be undone.")}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex gap-2">
|
||||
<AlertDialogCancel disabled={isDeleting} className="flex-1">
|
||||
{t("common.Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handlePermanentDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 focus:ring-red-500">
|
||||
{isDeleting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
{t("common.deleting", "Deleting...")}
|
||||
</span>
|
||||
) : (
|
||||
t("archive.deletePermanently", "Delete Permanently")
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchivedUnitsPositionsPage;
|
||||
@@ -0,0 +1,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
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user