mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
train
This commit is contained in:
@@ -65,11 +65,9 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
|
||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
@@ -80,11 +78,6 @@ import {
|
||||
isEthiopianGl,
|
||||
isSuperAdmin,
|
||||
} from "./lib/permissions";
|
||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
@@ -136,6 +129,8 @@ 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";
|
||||
import SetPassword from "./shared/components/SetPassword";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -148,7 +143,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
{
|
||||
label: "Staff",
|
||||
href: "/um",
|
||||
href: "/user-management",
|
||||
icon: <Users />,
|
||||
},
|
||||
{
|
||||
@@ -678,7 +673,11 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||
<Route
|
||||
path="um/set-password"
|
||||
element={<SetPassword />}
|
||||
/>
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
@@ -687,7 +686,8 @@ const App = () => {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
{UserManagementRoutes()}
|
||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||
<Route path="/health" element={<HealthCheck />} />
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
@@ -697,6 +697,11 @@ const App = () => {
|
||||
/>
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
{/* Dev/testing page for the mock AI booking assistant. */}
|
||||
<Route
|
||||
path="ai-booking-mock-test"
|
||||
element={<AiBookingMockTestPage />}
|
||||
/>
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
@@ -1308,18 +1313,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"
|
||||
@@ -1402,10 +1407,6 @@ const App = () => {
|
||||
path="rule-engine/:resource"
|
||||
element={<RuleEngineLegacyRedirect />}
|
||||
/>
|
||||
|
||||
<Route path="user1" element={<DemoUser1Page />} />
|
||||
<Route path="user2" element={<DemoUser2Page />} />
|
||||
|
||||
<Route path="org-structure" element={<Navigate to="/um" replace />} />
|
||||
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
|
||||
</Route>
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export interface ActionShellProps {
|
||||
subtitle?: string;
|
||||
/** When true the action is already done — children are hidden, a done badge shows. */
|
||||
done?: boolean;
|
||||
/**
|
||||
* Keep the input controls mounted alongside the done badge. For actions whose
|
||||
* value stays correctable after completion (e.g. customs risk), rather than
|
||||
* the default one-and-done actions.
|
||||
*/
|
||||
keepChildrenWhenDone?: boolean;
|
||||
doneLabel?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -22,6 +28,7 @@ export function ActionShell({
|
||||
title,
|
||||
subtitle,
|
||||
done,
|
||||
keepChildrenWhenDone,
|
||||
doneLabel,
|
||||
children,
|
||||
}: ActionShellProps) {
|
||||
@@ -64,7 +71,7 @@ export function ActionShell({
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
{!done ? children : null}
|
||||
{!done || keepChildrenWhenDone ? children : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -15,22 +15,42 @@ const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
|
||||
export function AssignRiskCard({
|
||||
bookingId,
|
||||
milestone,
|
||||
locked = false,
|
||||
}: {
|
||||
bookingId: string;
|
||||
milestone: Freight.IClearanceMilestone;
|
||||
/**
|
||||
* Duty has already been advised off this risk level, so the decision is now
|
||||
* final. Until then a mis-assigned level must stay correctable — the server
|
||||
* accepts reassignment and overwrites the milestone metadata.
|
||||
*/
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const assign = useAssignRisk(bookingId);
|
||||
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
|
||||
|
||||
const assigned = milestone.status === "COMPLETED";
|
||||
const current = milestone.metadata?.riskLevel;
|
||||
const [level, setLevel] = useState<Freight.CustomsRiskLevel>(
|
||||
current ?? "GREEN",
|
||||
);
|
||||
|
||||
// The milestone loads (and refetches after a reassignment) after first render,
|
||||
// so mirror the persisted level onto the control whenever it changes.
|
||||
useEffect(() => {
|
||||
if (current) setLevel(current);
|
||||
}, [current]);
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={ShieldAlert}
|
||||
title="Customs risk"
|
||||
subtitle="Assign the customs examination risk level."
|
||||
subtitle={
|
||||
assigned && !locked
|
||||
? "Reassign the customs examination risk level."
|
||||
: "Assign the customs examination risk level."
|
||||
}
|
||||
done={assigned}
|
||||
keepChildrenWhenDone={!locked}
|
||||
doneLabel={
|
||||
current ? (
|
||||
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
|
||||
@@ -60,9 +80,10 @@ export function AssignRiskCard({
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={assign.isPending}
|
||||
disabled={assigned && level === current}
|
||||
onClick={() => assign.mutate({ riskLevel: level })}
|
||||
>
|
||||
Assign risk
|
||||
{assigned ? "Reassign risk" : "Assign risk"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -70,7 +70,11 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
||||
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
||||
|
||||
{riskMs ? (
|
||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||
<AssignRiskCard
|
||||
bookingId={bookingId}
|
||||
milestone={riskMs}
|
||||
locked={dutyMs?.status === "COMPLETED"}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<IncidentReportCard bookingId={bookingId} />
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Camera, PenLine } from "lucide-react";
|
||||
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface ProofOfDeliveryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
lastMileId: string | null;
|
||||
reference?: string | null;
|
||||
/** Called after a successful capture so the caller can refetch. */
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proof of delivery capture for an EDR last-mile leg: recipient name, a drawn
|
||||
* signature, and proof photos. On confirm it uploads everything and completes
|
||||
* the delivery (marks the leg DELIVERED).
|
||||
*/
|
||||
export function ProofOfDeliveryModal({
|
||||
opened,
|
||||
onClose,
|
||||
lastMileId,
|
||||
reference,
|
||||
onDone,
|
||||
}: ProofOfDeliveryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [signatureUrl, setSignatureUrl] = useState<string | null>(null);
|
||||
const [photos, setPhotos] = useState<File[]>([]);
|
||||
|
||||
const reset = () => {
|
||||
setRecipient("");
|
||||
setNotes("");
|
||||
setSignatureUrl(null);
|
||||
setPhotos([]);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!lastMileId) throw new Error("No delivery selected");
|
||||
const signature = signatureUrl
|
||||
? await (await fetch(signatureUrl)).blob()
|
||||
: null;
|
||||
return lastMileService.recordProofOfDelivery(lastMileId, {
|
||||
recipientName: recipient.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
signature,
|
||||
photos,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Proof of delivery recorded",
|
||||
description: "The delivery has been completed.",
|
||||
});
|
||||
reset();
|
||||
onDone();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not record delivery",
|
||||
description: e instanceof Error ? e.message : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
// Require a recipient plus at least one form of proof (signature or a photo).
|
||||
const canSubmit =
|
||||
recipient.trim().length > 0 && (Boolean(signatureUrl) || photos.length > 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={`Record delivery${reference ? ` — ${reference}` : ""}`}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Received by"
|
||||
placeholder="Recipient's name"
|
||||
required
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Recipient signature
|
||||
</Text>
|
||||
<ContractSignaturePad onChange={setSignatureUrl} />
|
||||
</div>
|
||||
|
||||
<FileInput
|
||||
label="Proof photos"
|
||||
placeholder="Attach delivery photo(s)"
|
||||
leftSection={<Camera size={16} />}
|
||||
accept="image/*"
|
||||
multiple
|
||||
clearable
|
||||
value={photos}
|
||||
onChange={setPhotos}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional delivery notes"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Provide a signature or at least one photo. Confirming completes the delivery.
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={close} disabled={submit.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<PenLine size={16} />}
|
||||
loading={submit.isPending}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit.mutate()}
|
||||
>
|
||||
Confirm delivery
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const links = [
|
||||
{
|
||||
title: "User management",
|
||||
description: "Employees, roles, and permissions",
|
||||
href: "/dashboard/user-management",
|
||||
href: "/user-management",
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
|
||||
|
||||
import { useAccrualDashboard } from '@/hooks/useWarehouses';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
|
||||
|
||||
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
|
||||
CHARGING: { color: 'red', label: 'Charging' },
|
||||
WARNING: { color: 'orange', label: 'Free days ending' },
|
||||
OK: { color: 'teal', label: 'Within free days' },
|
||||
};
|
||||
|
||||
function money(amount: number, currency: string): string {
|
||||
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
function freeDaysLabel(row: AccrualDashboardRow): string {
|
||||
if (row.charging) return 'charging now';
|
||||
if (row.freeDaysLeft == null) return '—';
|
||||
return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live accrual dashboard: storage / demurrage ticking per in-warehouse item,
|
||||
* sorted so items already charging (or about to) surface first. Read-only.
|
||||
*/
|
||||
export function AccrualDashboard() {
|
||||
const { data: rows = [], isLoading } = useAccrualDashboard();
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const refresh = () =>
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] });
|
||||
|
||||
const ack = useMutation({
|
||||
mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) =>
|
||||
warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}),
|
||||
onSuccess: (_r, v) => {
|
||||
toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' });
|
||||
void refresh();
|
||||
},
|
||||
onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }),
|
||||
});
|
||||
const unack = useMutation({
|
||||
mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Acknowledgement removed' });
|
||||
void refresh();
|
||||
},
|
||||
onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }),
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const currency = rows[0]?.currency ?? 'USD';
|
||||
return {
|
||||
currency,
|
||||
charging: rows.filter((r) => r.alert === 'CHARGING').length,
|
||||
atRisk: rows.filter((r) => r.alert === 'WARNING').length,
|
||||
totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="sm">
|
||||
<StatCard
|
||||
icon={<DollarSign size={18} />}
|
||||
label="Accruing now"
|
||||
value={money(summary.totalAccruing, summary.currency)}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle size={18} />}
|
||||
label="Charging"
|
||||
value={summary.charging}
|
||||
color={summary.charging > 0 ? 'red' : 'gray'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock size={18} />}
|
||||
label="Free days ending (≤2d)"
|
||||
value={summary.atRisk}
|
||||
color={summary.atRisk > 0 ? 'orange' : 'gray'}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
{rows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No in-warehouse items are accruing fees.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Accrued</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Alert</Table.Th>
|
||||
<Table.Th ta="right" />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const meta = ALERT_META[row.alert];
|
||||
const busy = ack.isPending || unack.isPending;
|
||||
return (
|
||||
<Table.Tr key={row.inventoryId} style={{ opacity: row.acknowledged ? 0.55 : 1 }}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{row.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fw={600} size="sm" c={row.accruedAmount > 0 ? 'red' : undefined}>
|
||||
{money(row.accruedAmount, row.currency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c={row.charging ? 'red' : undefined}>
|
||||
{freeDaysLabel(row)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.acknowledged ? (
|
||||
<Badge color="gray" variant="light" size="sm" leftSection={<Check size={11} />}>
|
||||
Reviewed{row.snoozeUntil ? ' (snoozed)' : ''}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" loading={busy} aria-label="Accrual actions">
|
||||
<MoreVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{row.acknowledged ? (
|
||||
<Menu.Item
|
||||
leftSection={<Bell size={14} />}
|
||||
onClick={() => unack.mutate(row.inventoryId)}
|
||||
>
|
||||
Un-acknowledge
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<Check size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId })}
|
||||
>
|
||||
Mark reviewed
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<BellOff size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })}
|
||||
>
|
||||
Snooze 3 days
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<BellOff size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })}
|
||||
>
|
||||
Snooze 7 days
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color={color} variant="light" size={40} radius="md">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={800} fz={20} lh={1.1} truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import { openPdfBlob, saveBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
}
|
||||
};
|
||||
|
||||
// One-click bundle: download every available document for the item (GRN +
|
||||
// gate clearance / release order + handover). Best-effort — docs that aren't
|
||||
// generatable yet for this item are skipped.
|
||||
const downloadDocumentBundle = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
const ref = item.booking?.reference ?? item.bookingId ?? item.id;
|
||||
const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [
|
||||
{ name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) },
|
||||
{ name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) },
|
||||
{ name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) },
|
||||
];
|
||||
let saved = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const response = await job.fn();
|
||||
saveBlob(response.data, job.name);
|
||||
saved += 1;
|
||||
} catch {
|
||||
// Document not available for this item yet — skip it.
|
||||
}
|
||||
}
|
||||
setBusyId(null);
|
||||
if (saved === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'No documents available',
|
||||
description: 'This item has no GRN, gate clearance or handover document yet.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`,
|
||||
description: `Bundle for ${ref} (available documents only).`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
||||
const reference = item.booking?.reference;
|
||||
if (!reference) {
|
||||
@@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onHandoverDocument={openHandoverDocument}
|
||||
onDownloadBundle={downloadDocumentBundle}
|
||||
onLastMile={onLastMile ? acceptLastMile : undefined}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -51,6 +51,7 @@ import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
EligibleBooking,
|
||||
InventoryInquiryFilter,
|
||||
InventoryStatus,
|
||||
InventoryInquiryResult,
|
||||
ImportTrain,
|
||||
ImportTrainItem,
|
||||
@@ -63,6 +64,7 @@ import type {
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||
@@ -253,6 +255,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
const SUB_STAGE_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray',
|
||||
RECEIVED: 'blue',
|
||||
GRN: 'teal',
|
||||
ASSIGNED: 'indigo',
|
||||
LOADED: 'grape',
|
||||
LEFT: 'orange',
|
||||
DELIVERED: 'green',
|
||||
};
|
||||
|
||||
/**
|
||||
* Expanded booking row: the booking's containers / bulk items with their
|
||||
* lifecycle stage. Shares the ['container-items', bookingId] cache with
|
||||
* ContainerItemsModal, so expanding after using the modal is instant.
|
||||
*/
|
||||
function BookingItemsExpansion({
|
||||
bookingId,
|
||||
colSpan,
|
||||
bulkFallback,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
colSpan: number;
|
||||
bulkFallback?: string;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: ['container-items', bookingId],
|
||||
queryFn: () => warehouseService.getContainerItems(bookingId as string),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="sm">
|
||||
<Loader size="xs" />
|
||||
</Group>
|
||||
) : items.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" py={6}>
|
||||
{bulkFallback ?? 'No container units recorded on this booking.'}
|
||||
</Text>
|
||||
) : (
|
||||
<Table verticalSpacing={4} fz="xs" withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((i) => (
|
||||
<Table.Tr key={i.containerNumber}>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>{i.containerNumber}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{i.goods ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={SUB_STAGE_COLOR[i.stage] ?? 'gray'}>
|
||||
{i.stage}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.grnNumber ?? '—'}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void };
|
||||
|
||||
/** One-click bulk actions are irreversible — make the click deliberate. */
|
||||
function ConfirmActionModal({
|
||||
action,
|
||||
onClose,
|
||||
}: {
|
||||
action: ConfirmAction | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal opened={Boolean(action)} onClose={onClose} title={action?.title ?? ''} centered size="sm">
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{action?.message}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
action?.run();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{action?.confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** "3 skipped — Booking not PAID" instead of a bare count. */
|
||||
const skippedSummary = (
|
||||
skippedCount: number,
|
||||
results: Array<{ reason?: string; message?: string }>,
|
||||
): string | undefined => {
|
||||
if (!skippedCount) return undefined;
|
||||
const reason = results.find((x) => x.reason || x.message);
|
||||
return `${skippedCount} skipped${reason ? ` — ${reason.reason ?? reason.message}` : ''}`;
|
||||
};
|
||||
|
||||
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
|
||||
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
|
||||
return unique.length === 1 ? unique[0] : '';
|
||||
@@ -860,7 +983,7 @@ function EligibleTab({
|
||||
});
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
|
||||
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
||||
@@ -1030,7 +1153,7 @@ function EligibleTab({
|
||||
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1700}>
|
||||
<Table.ScrollContainer minWidth={1350}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -1043,8 +1166,6 @@ function EligibleTab({
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Origin</Table.Th>
|
||||
<Table.Th>Destination</Table.Th>
|
||||
@@ -1077,12 +1198,6 @@ function EligibleTab({
|
||||
{r.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.id.slice(0, 8)}…</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customer ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.origin ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.destination ?? '—'}</Table.Td>
|
||||
@@ -1256,6 +1371,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
|
||||
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
|
||||
@@ -1279,7 +1396,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
||||
toast({
|
||||
title: `${r.inspectedCount} marked inspected`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
description: skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
setSelected(new Set());
|
||||
onChanged?.();
|
||||
@@ -1300,7 +1417,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={inspectMutation.isPending}
|
||||
onClick={markInspected}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Mark inspected',
|
||||
message: `Mark ${selected.size} selected item(s) as inspection PASSED?`,
|
||||
confirmLabel: `Mark ${selected.size} inspected`,
|
||||
run: markInspected,
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark Selected as Inspected
|
||||
</Button>
|
||||
@@ -1315,10 +1439,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
No received export items awaiting inspection.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1700}>
|
||||
<Table.ScrollContainer minWidth={1350}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={34} />
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
@@ -1329,8 +1454,6 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container / Cargo Items</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
@@ -1345,7 +1468,18 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
{rows.map((r: ReadyToLoadRow) => {
|
||||
const selectable = r.inspectionStatus !== 'PASSED';
|
||||
return (
|
||||
<Table.Tr key={r.id}>
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Show containers"
|
||||
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||
>
|
||||
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
@@ -1355,20 +1489,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||
@@ -1382,9 +1507,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
@@ -1392,6 +1515,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expandedRow === r.id && (
|
||||
<BookingItemsExpansion
|
||||
bookingId={r.bookingId}
|
||||
colSpan={18}
|
||||
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
@@ -1404,6 +1535,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
opened={Boolean(inspectId)}
|
||||
onClose={() => setInspectId(null)}
|
||||
/>
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1415,29 +1547,10 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
||||
);
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
|
||||
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
|
||||
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||
queryKey: ['warehouse-inventory', 'loadable-trains'],
|
||||
queryFn: () => warehouseService.getLoadableTrains(),
|
||||
enabled: enabled && trainPickerOpen,
|
||||
});
|
||||
const loadOntoTrain = useMutation({
|
||||
mutationFn: async (scheduleId: string) => {
|
||||
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
||||
const loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
|
||||
if (!loadableIds.length) {
|
||||
throw new Error('No ready items with an allocated wagon on this train');
|
||||
}
|
||||
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||
},
|
||||
});
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
@@ -1449,13 +1562,47 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
return next;
|
||||
});
|
||||
|
||||
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
|
||||
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||
queryKey: ['warehouse-inventory', 'loadable-trains'],
|
||||
queryFn: () => warehouseService.getLoadableTrains(),
|
||||
enabled: enabled && trainPickerOpen,
|
||||
});
|
||||
const loadOntoTrain = useMutation({
|
||||
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
|
||||
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
||||
let loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
|
||||
// When rows are checked, load only those; otherwise load every loadable item.
|
||||
if (onlyIds.length) {
|
||||
const picked = new Set(onlyIds);
|
||||
loadableIds = loadableIds.filter((id) => picked.has(id));
|
||||
}
|
||||
if (!loadableIds.length) {
|
||||
throw new Error(
|
||||
onlyIds.length
|
||||
? 'None of the selected items have an allocated wagon on this train'
|
||||
: 'No ready items with an allocated wagon on this train',
|
||||
);
|
||||
}
|
||||
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const confirmLoad = async () => {
|
||||
if (!targetScheduleId) {
|
||||
toast({ variant: 'destructive', title: 'Select a train to load onto' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await loadOntoTrain.mutateAsync(targetScheduleId);
|
||||
const r = await loadOntoTrain.mutateAsync({
|
||||
scheduleId: targetScheduleId,
|
||||
onlyIds: [...selected],
|
||||
});
|
||||
const train = trains.find((t) => t.scheduleId === targetScheduleId);
|
||||
toast({
|
||||
title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(),
|
||||
@@ -1476,7 +1623,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
<b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load
|
||||
{selected.size > 0 ? (
|
||||
<><b>{selected.size}</b> of {rows.length} selected</>
|
||||
) : (
|
||||
<><b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load</>
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -1486,7 +1637,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
disabled={rows.length === 0}
|
||||
onClick={() => setTrainPickerOpen(true)}
|
||||
>
|
||||
Auto Load Ready Items
|
||||
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1544,7 +1695,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
No EXPORT items with inspection PASSED waiting to be loaded.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1600}>
|
||||
<Table.ScrollContainer minWidth={1200}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -1556,10 +1707,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th w={34} />
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
@@ -1571,7 +1721,8 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
@@ -1580,20 +1731,21 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Show containers"
|
||||
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||
>
|
||||
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||
@@ -1607,11 +1759,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="teal" variant="light" size="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expandedRow === r.id && (
|
||||
<BookingItemsExpansion
|
||||
bookingId={r.bookingId}
|
||||
colSpan={11}
|
||||
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
@@ -1638,6 +1796,8 @@ function LoadedExportTab({
|
||||
const { data: rows = [], isLoading } = useQuery(
|
||||
api.warehouses.loadedExport.queryOptions({ enabled }),
|
||||
);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const bulkDispatch = useMutation(
|
||||
api.warehouses.bulkDispatchExport.mutationOptions(),
|
||||
);
|
||||
@@ -1662,7 +1822,7 @@ function LoadedExportTab({
|
||||
const r = await bulkDispatch.mutateAsync(inventoryIds);
|
||||
toast({
|
||||
title: `${r.dispatchedCount} dispatched`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
description: skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
setSelected(new Set());
|
||||
onChanged?.();
|
||||
@@ -1692,7 +1852,14 @@ function LoadedExportTab({
|
||||
variant="default"
|
||||
disabled={rows.length === 0}
|
||||
loading={bulkDispatch.isPending}
|
||||
onClick={() => dispatch(rows.map((r) => r.id))}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Dispatch all',
|
||||
message: `Dispatch all ${rows.length} loaded item(s)? They leave warehouse inventory for the train.`,
|
||||
confirmLabel: `Dispatch ${rows.length}`,
|
||||
run: () => dispatch(rows.map((r) => r.id)),
|
||||
})
|
||||
}
|
||||
>
|
||||
Dispatch All
|
||||
</Button>
|
||||
@@ -1702,7 +1869,14 @@ function LoadedExportTab({
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={bulkDispatch.isPending}
|
||||
onClick={() => dispatch([...selected])}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Dispatch selected',
|
||||
message: `Dispatch ${selected.size} selected item(s)? They leave warehouse inventory for the train.`,
|
||||
confirmLabel: `Dispatch ${selected.size}`,
|
||||
run: () => dispatch([...selected]),
|
||||
})
|
||||
}
|
||||
>
|
||||
Dispatch Selected
|
||||
</Button>
|
||||
@@ -1719,7 +1893,7 @@ function LoadedExportTab({
|
||||
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1600}>
|
||||
<Table.ScrollContainer minWidth={1200}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -1733,10 +1907,9 @@ function LoadedExportTab({
|
||||
/>
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th w={34} />
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
@@ -1747,7 +1920,8 @@ function LoadedExportTab({
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
{dispatchable && (
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
@@ -1758,20 +1932,21 @@ function LoadedExportTab({
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Show containers"
|
||||
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||
>
|
||||
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||
@@ -1780,16 +1955,23 @@ function LoadedExportTab({
|
||||
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expandedRow === r.id && (
|
||||
<BookingItemsExpansion
|
||||
bookingId={r.bookingId}
|
||||
colSpan={11}
|
||||
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1891,9 +2073,7 @@ function ImportTrainDetailTable({
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
@@ -1927,15 +2107,9 @@ function ImportTrainDetailTable({
|
||||
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}…</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>{it.bookingReference ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{it.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{it.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
||||
@@ -2025,6 +2199,7 @@ function ImportArriveQueueTab({
|
||||
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
|
||||
);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
|
||||
Record<string, Record<string, ImportUnloadAssignmentDraft>>
|
||||
@@ -2066,7 +2241,7 @@ function ImportArriveQueueTab({
|
||||
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
|
||||
const firstReason = r.results.find((item) => item.reason)?.reason;
|
||||
const extra = [
|
||||
r.skippedCount ? `${r.skippedCount} skipped` : '',
|
||||
skippedSummary(r.skippedCount, r.results) ?? '',
|
||||
r.failedCount ? `${r.failedCount} failed` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -2162,7 +2337,14 @@ function ImportArriveQueueTab({
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={busyId === t.scheduleId}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
|
||||
onClick={() => autoUnload(t)}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Auto unload train',
|
||||
message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`,
|
||||
confirmLabel: 'Unload train',
|
||||
run: () => autoUnload(t),
|
||||
})
|
||||
}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
|
||||
</Button>
|
||||
@@ -2201,6 +2383,7 @@ function ImportArriveQueueTab({
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -2221,6 +2404,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
);
|
||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
@@ -2252,7 +2437,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
||||
toast({
|
||||
title: `${r.inspectedCount} marked inspected`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
description: skippedSummary(r.skippedCount, r.results),
|
||||
});
|
||||
setSelected(new Set());
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
@@ -2356,7 +2541,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={inspectMutation.isPending}
|
||||
onClick={markInspected}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Mark inspected',
|
||||
message: `Mark ${selected.size} selected item(s) as inspection PASSED? Passed import items become ready for pickup.`,
|
||||
confirmLabel: `Mark ${selected.size} inspected`,
|
||||
run: markInspected,
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark Selected as Inspected
|
||||
</Button>
|
||||
@@ -2372,10 +2564,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
No unloaded import items. Items appear here after Auto Unload on an arrived train.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={2000}>
|
||||
<Table.ScrollContainer minWidth={1650}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={34} />
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
@@ -2384,10 +2577,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
onChange={() => (allSelected ? unselectAll() : selectAll())}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Arrival Time</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
@@ -2403,7 +2594,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ImportUnloadedItem) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Fragment key={r.id}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Show containers"
|
||||
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||
>
|
||||
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
@@ -2412,20 +2614,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatDate(r.arrivalTime)}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
@@ -2444,7 +2637,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
|
||||
<InventoryStatusBadge status={r.currentStatus as InventoryStatus} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
@@ -2538,6 +2731,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expandedRow === r.id && (
|
||||
<BookingItemsExpansion
|
||||
bookingId={r.bookingId}
|
||||
colSpan={16}
|
||||
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
@@ -2566,6 +2767,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
bookingId={containerItemsItem?.booking?.id ?? null}
|
||||
bookingReference={containerItemsItem?.booking?.reference ?? null}
|
||||
/>
|
||||
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,8 +202,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||
// operator types the plate manually in the field below.
|
||||
const truckSelectOptions = assignedTruckOptions;
|
||||
// operator types the plate manually in the field below. Deduped by plate:
|
||||
// duplicate option values crash Mantine's Select.
|
||||
const truckSelectOptions = [
|
||||
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
|
||||
];
|
||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||
@@ -216,10 +219,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const containerWeightByNumber = new Map(
|
||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||
);
|
||||
const containerSelectData = containerWeights.map((c) => ({
|
||||
value: c.containerNumber,
|
||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
}));
|
||||
// Mantine Selects throw on duplicate option values — legacy bookings can carry
|
||||
// the same container number on two lines, so dedupe defensively.
|
||||
const containerSelectData = [
|
||||
...new Map(
|
||||
containerWeights.map((c) => [
|
||||
c.containerNumber,
|
||||
{
|
||||
value: c.containerNumber,
|
||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||
const selectedCargoWeight = Number(
|
||||
selectedContainerNumbers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
@@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps {
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onDownloadBundle?: (item: WarehouseInventoryItem) => void;
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
@@ -110,6 +111,7 @@ export function WarehouseInventoryTable({
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onHandoverDocument,
|
||||
onDownloadBundle,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
@@ -285,6 +287,13 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadBundle && item.grnNumber && (
|
||||
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
|
||||
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||
<Tooltip label="Last mile delivery" withArrow>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react";
|
||||
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { useWarehouseOpsStats } from "@/hooks/useWarehouses";
|
||||
|
||||
/**
|
||||
* At-a-glance warehouse ops KPIs (received today, pending inspection, trucks
|
||||
* on-site, items aging). Drop-in for any warehouse ops page header.
|
||||
*/
|
||||
export function WarehouseOpsKpiStrip() {
|
||||
const { data, isLoading } = useWarehouseOpsStats();
|
||||
|
||||
return (
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Received today",
|
||||
value: data?.receivedToday ?? 0,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Pending inspection",
|
||||
value: data?.pendingInspection ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Trucks on-site",
|
||||
value: data?.trucksOnSite ?? 0,
|
||||
icon: Truck,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Items aging (>7d)",
|
||||
value: data?.itemsAging ?? 0,
|
||||
icon: AlertTriangle,
|
||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||
hint: "In warehouse over 7 days",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Badge, Card, Group, Loader, Progress, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
|
||||
import { useZoneOccupancy } from '@/hooks/useWarehouses';
|
||||
import type { ZoneOccupancy } from '@/types/warehouse';
|
||||
|
||||
/** Green < 60%, amber 60–85%, red > 85%. */
|
||||
function tone(pct: number | null): { color: string; label: string } {
|
||||
if (pct == null) return { color: 'gray', label: 'No capacity set' };
|
||||
if (pct > 85) return { color: 'red', label: 'Full' };
|
||||
if (pct >= 60) return { color: 'orange', label: 'Filling' };
|
||||
return { color: 'teal', label: 'Space' };
|
||||
}
|
||||
|
||||
function capacityLabel(z: ZoneOccupancy): string {
|
||||
if (z.capacityContainers && z.capacityContainers > 0) {
|
||||
return `${z.usedItems} / ${z.capacityContainers} items`;
|
||||
}
|
||||
if (z.capacityWeight && z.capacityWeight > 0) {
|
||||
return `${z.usedItems} item(s) · ${z.usedWeight.toLocaleString()} kg`;
|
||||
}
|
||||
return `${z.usedItems} item(s)`;
|
||||
}
|
||||
|
||||
interface ZoneOccupancyHeatmapProps {
|
||||
/** Scope to one yard; omit for all zones. */
|
||||
yardId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
|
||||
* container-count based (unit-consistent); weight is shown as context only.
|
||||
*/
|
||||
export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
|
||||
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (zones.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No active zones to show occupancy for.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<LayoutGrid size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
Zone occupancy
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
({zones.length} zone{zones.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
|
||||
{zones.map((z) => {
|
||||
const t = tone(z.occupancyPct);
|
||||
const pct = z.occupancyPct ?? 0;
|
||||
return (
|
||||
<Card key={z.id} withBorder radius="md" padding="sm">
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text fw={600} size="sm" truncate title={z.name}>
|
||||
{z.name}
|
||||
</Text>
|
||||
<Badge color={t.color} variant="light" size="sm">
|
||||
{z.occupancyPct == null ? '—' : `${Math.round(pct)}%`}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Progress value={Math.min(pct, 100)} color={t.color} size="lg" radius="sm" />
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{capacityLabel(z)}
|
||||
</Text>
|
||||
<Text size="xs" c={t.color === 'gray' ? 'dimmed' : t.color}>
|
||||
{t.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -29,3 +29,6 @@ export { VisualEmptyState } from './VisualEmptyState';
|
||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||
export { AccrualDashboard } from './AccrualDashboard';
|
||||
|
||||
@@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window
|
||||
URL.revokeObjectURL(url);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Force a browser download of a blob under the given filename (no preview tab). */
|
||||
export function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Delay revoke so the download has time to start (esp. for rapid multi-saves).
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
|
||||
@@ -486,6 +486,11 @@ export const URL_CONSTANTS = {
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||
yardId
|
||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||
: "/warehouse-inventory/zone-occupancy",
|
||||
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
|
||||
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
||||
UNLOAD_BOOKING: (bookingId: string) =>
|
||||
@@ -553,6 +558,9 @@ export const URL_CONSTANTS = {
|
||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||
FEE_PREVIEW: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
||||
ACCRUAL_ACK: (inventoryId: string) =>
|
||||
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
@@ -617,6 +625,7 @@ export const URL_CONSTANTS = {
|
||||
BASE: "/last-mile",
|
||||
BY_ID: (id: string) => `/last-mile/${id}`,
|
||||
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
||||
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
||||
},
|
||||
|
||||
DRIVERS: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -62,6 +62,10 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "Paid",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
TRUCK_ASSIGNED: {
|
||||
label: "Truck Assigned",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
label: "In Transit",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
@@ -206,6 +210,12 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 4,
|
||||
},
|
||||
TRUCK_ASSIGNED: {
|
||||
title: "Truck Assigned",
|
||||
description: "Customer truck assigned for self-haul; ready for operations.",
|
||||
color: "text-[color:var(--freight-brand)]",
|
||||
stage: 4,
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
title: "In Transit",
|
||||
description: "Shipment is on the railway network.",
|
||||
@@ -300,7 +310,7 @@ export const BOOKING_LIST_TABS = [
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
|
||||
statuses: ["PAID", "TRUCK_ASSIGNED", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
|
||||
},
|
||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
@@ -338,7 +348,7 @@ export const WORKFLOW_STAGES = [
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "ARRIVED"],
|
||||
statuses: ["PAID", "TRUCK_ASSIGNED", "IN_TRANSIT", "ARRIVED"],
|
||||
},
|
||||
{ label: "Done", statuses: ["COMPLETED"] },
|
||||
] as const;
|
||||
|
||||
@@ -136,6 +136,30 @@ export function useAllWarehouseZones() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Live per-zone occupancy for the heatmap (optionally scoped to one yard). */
|
||||
export function useZoneOccupancy(yardId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
|
||||
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** At-a-glance warehouse ops counters for the KPI strip. */
|
||||
export function useWarehouseOpsStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'ops-stats'],
|
||||
queryFn: () => warehouseService.opsStats().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateZone() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
48
apps/edr-freight-web/backoffice/src/i18n/index.ts
Normal file
48
apps/edr-freight-web/backoffice/src/i18n/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
// Locale bundles live inside this app (src/locales) so they're part of the
|
||||
// package and survive `turbo prune` in the Docker build.
|
||||
import en from "../locales/en/translation.json";
|
||||
import am from "../locales/am/translation.json";
|
||||
import af from "../locales/af/translation.json";
|
||||
import fr from "../locales/fr/translation.json";
|
||||
import or from "../locales/or/translation.json";
|
||||
import sm from "../locales/sm/translation.json";
|
||||
import tg from "../locales/tg/translation.json";
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ["en", "am", "af", "fr", "or", "sm", "tg"] as const;
|
||||
|
||||
/**
|
||||
* i18next singleton for the vendored IAM UI (shared/super-admin/
|
||||
* user-management), imported as `@/i18n`. Loads the shared translation bundles
|
||||
* and enables language detection/persistence so the header language switcher
|
||||
* (i18n.changeLanguage) works and survives reloads.
|
||||
*/
|
||||
if (!i18n.isInitialized) {
|
||||
void i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
am: { translation: am },
|
||||
af: { translation: af },
|
||||
fr: { translation: fr },
|
||||
or: { translation: or },
|
||||
sm: { translation: sm },
|
||||
tg: { translation: tg },
|
||||
},
|
||||
fallbackLng: "en",
|
||||
supportedLngs: SUPPORTED_LANGUAGES as unknown as string[],
|
||||
interpolation: { escapeValue: false },
|
||||
returnNull: false,
|
||||
detection: {
|
||||
order: ["localStorage", "navigator", "htmlTag"],
|
||||
caches: ["localStorage"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
36
apps/edr-freight-web/backoffice/src/layout/AppLayout.tsx
Normal file
36
apps/edr-freight-web/backoffice/src/layout/AppLayout.tsx
Normal file
@@ -0,0 +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>
|
||||
);
|
||||
};
|
||||
312
apps/edr-freight-web/backoffice/src/layout/AppMenuTabs.tsx
Normal file
312
apps/edr-freight-web/backoffice/src/layout/AppMenuTabs.tsx
Normal file
@@ -0,0 +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>
|
||||
);
|
||||
};
|
||||
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,598 @@
|
||||
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: "#0EA371",
|
||||
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);
|
||||
// Accent is a subtle hover/highlight background (dropdown items, menus) that
|
||||
// must stay readable under --accent-foreground text in BOTH color schemes.
|
||||
// Full-strength brand color here produced dark-pill-with-dark-text hovers,
|
||||
// so use a translucent tint of the brand color instead.
|
||||
root.style.setProperty(
|
||||
"--accent",
|
||||
`color-mix(in oklab, ${primary} 15%, transparent)`,
|
||||
);
|
||||
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;
|
||||
977
apps/edr-freight-web/backoffice/src/locales/af/translation.json
Normal file
977
apps/edr-freight-web/backoffice/src/locales/af/translation.json
Normal file
@@ -0,0 +1,977 @@
|
||||
{
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"outgoing": "Outgoing",
|
||||
"incoming": "Incoming",
|
||||
"external": "External",
|
||||
"internal": "Internal",
|
||||
"cc": "CC",
|
||||
"approval": "Approval",
|
||||
"pending": "Pending",
|
||||
"Record Management": "Record Management"
|
||||
},
|
||||
"initiationType": {
|
||||
"self": "self",
|
||||
"team": "team"
|
||||
},
|
||||
"dashboard": {
|
||||
"totalOutgoing": "Total Outgoing",
|
||||
"totalIncoming": "Total Incoming",
|
||||
"ccRecords": "CC Records",
|
||||
"totalApproval": "Total Approval",
|
||||
"lastUpdated": "Last updated",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"never": "Never",
|
||||
"approvalStats": "Approval Stats",
|
||||
"externalIncoming": "External Incoming",
|
||||
"internalIncoming": "Internal Incoming",
|
||||
"incomingStats": "Incoming Stats",
|
||||
"outgoingStats": "Outgoing Stats",
|
||||
"externalIncomingRecords": "External Incoming Records",
|
||||
"internalIncomingRecords": "Internal Incoming Records",
|
||||
"status": {
|
||||
"inProgress": "In Progress",
|
||||
"accepted": "Accepted",
|
||||
"reassigned": "Reassigned",
|
||||
"draft": "Draft",
|
||||
"forward": "Forward",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"adjusted": "Adjusted",
|
||||
"sent": "Sent",
|
||||
"approve": "Approve"
|
||||
},
|
||||
"Percentage": "Percentage",
|
||||
"Count": "Count",
|
||||
"Status": "Status",
|
||||
"Total": "Total",
|
||||
"pieChart": "Pie Chart",
|
||||
"barChart": "Bar Chart",
|
||||
"table": "Table",
|
||||
"monthlyIncoming": "Monthly Incoming Records",
|
||||
"monthlyOutgoing": "Monthly Outgoing Records",
|
||||
"totalInVsOut": "Total Incoming vs Outgoing",
|
||||
"monthlyInVsOut": "Monthly Incoming vs Outgoing",
|
||||
"weeklyInVsOut": "Weekly Incoming vs Outgoing",
|
||||
"dailyInVsOut": "Daily Incoming vs Outgoing",
|
||||
"exportPdf": "Export PDF",
|
||||
"exporting": "Exporting...",
|
||||
"print": "Print"
|
||||
},
|
||||
"status": {
|
||||
"inProgress": "In Progress",
|
||||
"accepted": "Accepted",
|
||||
"reassigned": "Reassigned",
|
||||
"draft": "Draft",
|
||||
"forward": "Forward",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"adjusted": "Adjusted",
|
||||
"sent": "Sent",
|
||||
"all": "All"
|
||||
},
|
||||
"welcome": "Welcome",
|
||||
"change_password": "Change Password",
|
||||
"sign_out": "Sign Out",
|
||||
"view_profile": "View Profile",
|
||||
"user": "User",
|
||||
"common": {
|
||||
"back": "Back",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"na": "N/A",
|
||||
"Submit": "Submit",
|
||||
"Cancel": "Cancel",
|
||||
"noResult": "No Result",
|
||||
"create": "Create",
|
||||
"Edit": "Edit",
|
||||
"Delete": "Delete",
|
||||
"Deleting": "Deleting...",
|
||||
"DeletedSuccessfully": "Deleted successfully",
|
||||
"FailedToDelete": "Failed to delete",
|
||||
"ConfirmDelete": "Confirm Delete",
|
||||
"DeleteConfirmationMessage": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone."
|
||||
},
|
||||
"collaboration": {
|
||||
"inProgress": "In Progress",
|
||||
"done": "Done",
|
||||
"all": "All",
|
||||
"list": "Collaborations"
|
||||
},
|
||||
"profile": {
|
||||
"recentActivity": "Recent Activity",
|
||||
"loading": "Loading profile...",
|
||||
"error": "Failed to load profile",
|
||||
"tryAgain": "Try Again",
|
||||
"noName": "No name",
|
||||
"signOut": "Sign Out",
|
||||
"email": "Email",
|
||||
"userType": "User Type",
|
||||
"userTypes": {
|
||||
"employee": "Employee"
|
||||
},
|
||||
"activity": {
|
||||
"login": "Login",
|
||||
"passwordChanged": "Password Changed"
|
||||
},
|
||||
"passwordSet": "Password Set",
|
||||
"status": {
|
||||
"accepted": "Accepted"
|
||||
},
|
||||
"positionDetails": "Position Details",
|
||||
"positionName": "Position Name",
|
||||
"positionKey": "Position Key",
|
||||
"isDelegate": "Is Delegate",
|
||||
"employeeId": "Employee ID",
|
||||
"permissions": "Permissions",
|
||||
"noPermissions": "No permissions assigned",
|
||||
"accountSecurity": "Account Security",
|
||||
"password": "Password",
|
||||
"passwordSetStatus": "Password is set",
|
||||
"passwordNotSetStatus": "Password not set",
|
||||
"changePassword": "Change Password",
|
||||
"updateProfile": "Update Profile",
|
||||
"profile": "profile",
|
||||
"englishName": "Please Enter name in english",
|
||||
"amharicName": "Please Enter name in amharic",
|
||||
"username": "Please Enter Username",
|
||||
"phoneNumber": "Please Enter Phone Number",
|
||||
"emailmsg": "Please Enter Email",
|
||||
"emailRequired": "Email is required",
|
||||
"usernameRequired": "Username is required",
|
||||
"phoneRequired": "Phone number is required",
|
||||
"englishNameRequired": "Name in English is required",
|
||||
"amharicNameRequired": "Name in Amharic is required",
|
||||
"invalidPhoneFormat": "Invalid phone number format.",
|
||||
"validPhoneFormat": "Please enter a valid phone number in the format +2519XXXXXXXX or 09XXXXXXXX",
|
||||
"invalidEmailFormat": "Invalid email format",
|
||||
"validEmailFormat": "Please enter a valid email address",
|
||||
"profileUpdateSuccess": "Profile updated successfully",
|
||||
"profileUpdated": "Your profile is updated",
|
||||
"profileUpdateFailed": "Failed to update profile",
|
||||
"updating": "Updating"
|
||||
},
|
||||
"header": {
|
||||
"tigrigna": "Tigrigna",
|
||||
"affar": "Affar",
|
||||
"somali": "Somali",
|
||||
"oromo": "Affan Oromo",
|
||||
"user": "User",
|
||||
"management": "Management",
|
||||
"notifications": "Notifications",
|
||||
"english": "English",
|
||||
"englishShort": "ENG",
|
||||
"amharic": "Amharic",
|
||||
"amharicShort": "አማ",
|
||||
"viewProfile": "View Profile",
|
||||
"changePassword": "Change Password",
|
||||
"signOut": "Sign Out",
|
||||
"settings": "Settings",
|
||||
"addTeeter": "Add Teeter and Signature",
|
||||
"addSignature": "Add Signature",
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
"outgoing": "Outgoing",
|
||||
"incoming": "Incoming",
|
||||
"approval": "Approval",
|
||||
"delegation": "Delegation",
|
||||
"pending": "Pending",
|
||||
"settings": "Settings",
|
||||
"collaborations": "Collaborations"
|
||||
},
|
||||
"editProfile": "Edit Profile"
|
||||
},
|
||||
"addRecord": {
|
||||
"EditRecord": "Edit Record",
|
||||
"Record Type": "Record Type",
|
||||
"Select Record Type": "Select Record Type",
|
||||
"Initiation Type": "Initiation Type",
|
||||
"Select Initiation Type": "Select Initiation Type",
|
||||
"Receiving Unit": "Receiving Unit",
|
||||
"Select Receiving Units": "Select Receiving Unit",
|
||||
"Receiving Users": "Receiving Users",
|
||||
"Select Receiving Users": "Select Receiving Users",
|
||||
"Prefix": "Prefix",
|
||||
"Select Prefix": "Select Prefix",
|
||||
"Non Smart Receiving Unit": "Non Smart Receiving Unit",
|
||||
"Enter Non Smart Receiving Organization": "Enter Non Smart Receiving Organization",
|
||||
"To Departments": "To Departments",
|
||||
"Select Department": "Select Departments",
|
||||
"Preferred Language": "Preferred Language",
|
||||
"Select Language": "Select Language",
|
||||
"Suffix": "Suffix",
|
||||
"Select Suffix": "Select Suffix",
|
||||
"Subject": "Subject",
|
||||
"Enter Subject": "Enter Subject",
|
||||
"Letter Template": "Letter Template",
|
||||
"Select Letter Template": "Select Letter Template",
|
||||
"Body": "Body",
|
||||
"External CC": "External CC",
|
||||
"Select External CC": "Select External CC",
|
||||
"Internal CC": "Internal CC",
|
||||
"Select Internal CC": "Select Internal CC",
|
||||
"Non Smart CC Unit": "Non Smart CC Unit",
|
||||
"Enter Non Smart CC Organization": "Enter Non Smart CC Organization",
|
||||
"Cancel": "Cancel",
|
||||
"Add": "+ Add",
|
||||
"English": "English",
|
||||
"Amharic": "Amharic",
|
||||
"Header": "Letter's Header",
|
||||
"Select Header": "Select Header",
|
||||
"No Header": "No Header",
|
||||
"Footer": "Letter's Footer",
|
||||
"Select Footer": "Select Footer",
|
||||
"No Footer": "No Footer",
|
||||
"Collaborators": "Collaborators",
|
||||
"record": "Record",
|
||||
"attachment": "Attachment",
|
||||
"failedToCreateRecord": "Failed To Create Record",
|
||||
"recordSubmittedSuccessfully": "Record Forwarded Successfully.",
|
||||
"createRecordFirst": "Please Create a Record First To Upload Attachments.",
|
||||
"previous": "Previous",
|
||||
"submit": "Submit",
|
||||
"recordNotCreatedYet": "Record Has Not Been Created Yet.",
|
||||
"submitting": "Submitting...",
|
||||
"selectCollaborators": "Select Collaborators",
|
||||
"Enter CC Prefix": "Enter CC Prefix",
|
||||
"CC Prefix": "CC Prefix",
|
||||
"Enter CC Suffix": "Enter CC Suffix",
|
||||
"CC Suffix": "CC Suffix",
|
||||
"Sign Status": "Sign Status",
|
||||
"ccUsers": "CC Users",
|
||||
"selectCCUsers": "Select CC Users",
|
||||
"refreshOptions": "Refresh Options",
|
||||
"isTeamSig": "Is With Team Signature"
|
||||
},
|
||||
"Preview Record": "Preview Record",
|
||||
"userRecord": {
|
||||
"All": "All",
|
||||
"recordCreatedAndSent": "Record created and sent",
|
||||
"Draft": "Draft",
|
||||
"Submitted": "Fowarded",
|
||||
"Accepted": "Accepted",
|
||||
"Approved": "Approved",
|
||||
"Rejected": "Rejected",
|
||||
"Returned": "Returned",
|
||||
"Adjustment": "Adjustment",
|
||||
"Sent": "Sent",
|
||||
"Letters": "Letters",
|
||||
"To": "To",
|
||||
"Subject": "Subject",
|
||||
"Date": "Date",
|
||||
"Search": "Search",
|
||||
"Add Record": "Add Record",
|
||||
"Error loading records.": "Error Loading Records.",
|
||||
"Receiver Info": "Receiver Info",
|
||||
"No receivers": "No Receivers",
|
||||
"No receiving unit": "No Receiving Unit",
|
||||
"Status": "Status",
|
||||
"Actions": "Actions",
|
||||
"View Detail": "View Detail",
|
||||
"Open actions menu": "Open actions menu",
|
||||
"Approve": "Approve",
|
||||
"Forward": "Forward",
|
||||
"Edit": "Edit",
|
||||
"Delete": "Delete",
|
||||
"Download": "Download",
|
||||
"DirectSubmit": "Direct Submit",
|
||||
"directSubmitTitle": "Direct submit record",
|
||||
"directSubmitDescription": "Select a signature to submit this record directly.",
|
||||
"SelectSignature": "Signature",
|
||||
"SelectSignaturePlaceholder": "Select a signature",
|
||||
"noSignatureAvailable": "No signature is available for direct submit.",
|
||||
"directSubmitSuccess": "Record submitted directly successfully.",
|
||||
"directSubmitError": "Failed to submit the record directly.",
|
||||
"Record approved successfully!": "Record Approved Successfully!",
|
||||
"Failed to approve record.": "Failed To Approve Record.",
|
||||
"Approve Record": "Approve Record",
|
||||
"Record is deleted successfully": "Record is Deleted Successfully",
|
||||
"Confirm Delete": "Confirm Delete",
|
||||
"Delete Record": "Delete Record",
|
||||
"Downloaded": "Downloaded",
|
||||
"Download Record PDF": "Download Record PDF",
|
||||
"Failed to download PDF.": "Failed To Download PDF.",
|
||||
"Download successful!": "Download successful!",
|
||||
"Confirm Forward": "Confirm Forward",
|
||||
"Forward Record": "Forward Record",
|
||||
"Failed to forward the record": "Failed To Forward The Record",
|
||||
"Record forwarded successfully": "Record Forwarded Successfully",
|
||||
"Confirm Approve": "Confirm Approve",
|
||||
"Reference": "Reference Number",
|
||||
"Reject": "Reject",
|
||||
"Filter by": "Filter by",
|
||||
"Reference Number": "Reference Number",
|
||||
"Enter the subject of the letter": "Enter the subject of the letter",
|
||||
"Delivered By": "Delivered By",
|
||||
"Enter Delivered By": "Enter Delivered By",
|
||||
"Letter Number": "Letter Number",
|
||||
"Enter Letter Number": "Enter Letter Number",
|
||||
"Failed to create letter": "Failed to create letter",
|
||||
"Letter Created successfully": "Letter Created successfully",
|
||||
"Please select file": "Please select file",
|
||||
"Created At": "Created At",
|
||||
"Sign Status": "Sign Status",
|
||||
"signStatus": "Sign Status",
|
||||
"createdAt": "Created At",
|
||||
"View": "View",
|
||||
"Date Received": "Date Received",
|
||||
"Attachment": "Attachment",
|
||||
"ReceivingUnit": "Receiver",
|
||||
"Review your record before submission": "Review your record before submission",
|
||||
"Back": "Back",
|
||||
"Submit Record": "Submit Record",
|
||||
"Submitting": "Submitting",
|
||||
"Generating preview": "Generating preview",
|
||||
"PDF Preview": "PDF Preview",
|
||||
"Unable to generate preview": "Unable to generate preview",
|
||||
"Retry": "Retry",
|
||||
"Record Summary": "Record Summary",
|
||||
"No subject": "No subject",
|
||||
"Record Type": "Record Type",
|
||||
"Not specified": "Not specified",
|
||||
"Language": "Language",
|
||||
"Initiation Type": "Initiation Type",
|
||||
"Preview generation failed": "Preview generation failed"
|
||||
},
|
||||
"statusBar": {
|
||||
"Pending": "Pending",
|
||||
"Draft": "Draft",
|
||||
"draft": "In Progress",
|
||||
"InProgress": "In Progress",
|
||||
"In Progress": "In Progress",
|
||||
"active": "In Progress",
|
||||
"Reassigned": "Reassigned",
|
||||
"re_assigned": "Reassigned",
|
||||
"Approved": "Approved",
|
||||
"Accepted": "Accepted",
|
||||
"accepted": "Accepted",
|
||||
"Dispatched": "Sent",
|
||||
"Adjustment": "Adjustment",
|
||||
"adjustment": "Adjustment",
|
||||
"Rejected": "Rejected",
|
||||
"rejected": "Rejected",
|
||||
"Submitted": "Fowarded",
|
||||
"All": "All",
|
||||
"Reject": "Reject",
|
||||
"Accept": "Accept",
|
||||
"Approve": "Approve",
|
||||
"Adjust": "Adjust",
|
||||
"Sent": "Sent",
|
||||
"Send": "Send",
|
||||
"Team Signature": "Pending Signatures",
|
||||
"Team Rejected": "Team Rejected",
|
||||
"Team Approved": "Team Approved",
|
||||
"signed": "Signed",
|
||||
"activated": "Activated",
|
||||
"deactivated": "Deactivated",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"totalAllocations": "Total Allocations",
|
||||
"assignmentStatistics": "Assignment Statistics",
|
||||
"Outgoing": "Outgoing",
|
||||
"Incoming": "Incoming",
|
||||
"Returned": "Returned",
|
||||
"Adjusted": "Adjusted"
|
||||
},
|
||||
"userIncoming": {
|
||||
"From Date": "From",
|
||||
"To Date": "To",
|
||||
"Letters": "Letters",
|
||||
"Department": "Department",
|
||||
"Subject": "Subject",
|
||||
"Date": "Date",
|
||||
"Search": "Search",
|
||||
"Error loading records.": "Error Loading Records.",
|
||||
"Status": "Status",
|
||||
"Actions": "Actions",
|
||||
"View Detail": "View Detail",
|
||||
"Open actions menu": "Open actions menu",
|
||||
"Sending Department": "Sending Department",
|
||||
"No Department": "No Department",
|
||||
"No Subject": "No Subject",
|
||||
"Sending Organization": "Sending Organization",
|
||||
"Accept": "Accept",
|
||||
"Assign": "Assign",
|
||||
"Edit": "Edit",
|
||||
"Delete": "Delete",
|
||||
"Download": "Download",
|
||||
"Reject": "Reject",
|
||||
"Cancel": "Cancel",
|
||||
"Record accepted successfully": "Record Accepted Successfully",
|
||||
"Failed to accept record": "Failed To Accept Record",
|
||||
"Confirm Acceptance": "Confirm Acceptance",
|
||||
"Are you sure you want to accept this record?": "Are You Sure You Want To Accept This Record?",
|
||||
"Confirm": "Confirm",
|
||||
"Processing...": "Processing...",
|
||||
"Departments": "Departments",
|
||||
"Select department(s)...": "Select Department(s)...",
|
||||
"Employees": "Employees",
|
||||
"Select employee(s)...": "Select employee(s)...",
|
||||
"Remark": "Remark",
|
||||
"Failed to Assign a Record": "Failed To Assign a Record",
|
||||
"Assign this record to one or more departments": "Assign This Record To One Or More Departments",
|
||||
"Assign Record": "Assign Record",
|
||||
"An error occurred during assignment.": "An Error Occurred During Assignment.",
|
||||
"Enter your remarks...": "Enter Your Remarks...",
|
||||
"Assigning...": "Assigning...",
|
||||
"Are you sure you want to reject this record?": "Are You Sure You Want To Reject This Record?",
|
||||
"Failed to rejected record": "Failed To Reject Record",
|
||||
"Record rejected successfully": "Record Rejected Successfully",
|
||||
"To": "To",
|
||||
"ConvertedBy": "Converted By",
|
||||
"ReceiverDepartment": "Receiving Department",
|
||||
"ReceiverEmployee": "Receiving Employee",
|
||||
"return": "Return",
|
||||
"returnQues": "Are you sure you want to return this record?",
|
||||
"returing": "Returning...",
|
||||
"conformReturn": "Confirm Return"
|
||||
},
|
||||
"ApprovalPage": {
|
||||
"Letters": "Letters",
|
||||
"Department": "Department",
|
||||
"Subject": "Subject",
|
||||
"Date": "Date",
|
||||
"Search": "Search",
|
||||
"Error loading records.": "Error Loading Records.",
|
||||
"Status": "Status",
|
||||
"Actions": "Actions",
|
||||
"View Detail": "View Detail",
|
||||
"Open actions menu": "Open actions menu",
|
||||
"To": "To",
|
||||
"Receiver": "Receiver",
|
||||
"No Subject": "No Subject",
|
||||
"Record accepted successfully": "Record Accepted Successfully",
|
||||
"Confirm": "Confirm",
|
||||
"Remark": "Remark",
|
||||
"Accept Record": "Accept Record",
|
||||
"Record Rejected successfully": "Record Rejected Successfully",
|
||||
"recordAdjustedSuccessfully": "Record Adjusted Successfully",
|
||||
"confirmAdjustment": "Confirm Adjustment",
|
||||
"adjustRecord": "Adjust Record",
|
||||
"selectOrUploadSignature": "Please Select or Upload a Signature",
|
||||
"selectOrUploadTeeter": "Please Select or Upload a Teeter",
|
||||
"recordApprovedSuccessfully": "Record Approved Successfully!",
|
||||
"failedToApproveRecord": "Failed To Approve Record.",
|
||||
"approveRecord": "Approve Record",
|
||||
"confirmApprove": "Confirm Approve",
|
||||
"confirmRejection": "Confirm Rejection",
|
||||
"rejectRecord": "Reject Record",
|
||||
"RecordType": "Record Type",
|
||||
"selectStamp": "Select Stamp",
|
||||
"selectedStamp": "Selected Stamp:",
|
||||
"signature": "Signature",
|
||||
"yourSignature": "Your Signature:",
|
||||
"selectedSignature": "Selected Signature:",
|
||||
"selectSignature": "Select a signature",
|
||||
"approving": "Approving..."
|
||||
},
|
||||
"viewDetail": {
|
||||
"letterPreview": "Letter Preview",
|
||||
"view": "View",
|
||||
"fullScreen": "Full Screen",
|
||||
"back": "Back",
|
||||
"receivingOrganizations": "Receiving Organizations",
|
||||
"receivingUserEmployees": "Receiving User Employees",
|
||||
"sendingDepartment": "Sending Department",
|
||||
"externalCcOrganizations": "External CC Organizations",
|
||||
"internalCcDepartments": "Internal CC Departments",
|
||||
"receivingDepartments": "Receiving Departments",
|
||||
"sentDate": "Sent Date",
|
||||
"referenceNumber": "Reference Number",
|
||||
"attachments": "Attachments",
|
||||
"remarks": "Remarks",
|
||||
"loadingRemarks": "Loading Remarks...",
|
||||
"failedToLoadRemarks": "Failed To Load Remarks.",
|
||||
"noRemarksAvailable": "No Remarks Available For This Record.",
|
||||
"action": "Action",
|
||||
"comment": "Comment:",
|
||||
"noAttachmentsAvailable": "No Attachments Available.",
|
||||
"open": "Open",
|
||||
"subject": "Subject",
|
||||
"letterNumber": "Letter Number",
|
||||
"deliveredBy": "Delivered By",
|
||||
"personalRemark": "Personal Remark",
|
||||
"noPersonalRemarkProvided": "No Personal Remark Available",
|
||||
"regeneratePdf": "Regenerate PDF",
|
||||
"regeneratingPdf": "Regenerating...",
|
||||
"pdfRegenerationSuccess": "PDF regenerated successfully",
|
||||
"pdfRegenerationFailed": "Failed to regenerate PDF",
|
||||
"sendingBureau": "Sending Bureau",
|
||||
"collaborators": "Collaborators",
|
||||
"recordDetail": "Record Detail",
|
||||
"commentorEmployee": "Commentor Employee",
|
||||
"commentorDepartment": "Commentor Department",
|
||||
"noComment": "No Comment"
|
||||
},
|
||||
"PDF": {
|
||||
"dropFilesHere": "Drop files here",
|
||||
"dragDropInstruction": "Drag And Drop Files Here, Or Click To Select Files",
|
||||
"youCanUpload": "You Can Upload",
|
||||
"removeFile": "Remove File",
|
||||
"filesUpTo": "Files (up to ",
|
||||
"aFileWith": "A File With",
|
||||
"file": "File",
|
||||
"wasRejected": "Was Rejected",
|
||||
"uploaded": "Uploaded",
|
||||
"failedToUpload": "Failed To Upload",
|
||||
"uploading": "Uploading...",
|
||||
"cannotUploadMoreThan": "Cannot Upload More Than "
|
||||
},
|
||||
"convert": {
|
||||
"recordConverted": "Record Converted Successfully.",
|
||||
"selectSeal": "Please Select a Seal.",
|
||||
"convertRecord": "Convert Record",
|
||||
"recordType": "Record Type:",
|
||||
"subject": "Subject:",
|
||||
"convertAndDispatch": "Convert & Dispatch",
|
||||
"selectSealLabel": "Select Seal:",
|
||||
"loadingSeals": "Loading seals...",
|
||||
"selectSealPlaceholder": "-- Select a Seal --",
|
||||
"selectedSealPreview": "Selected Seal Preview:"
|
||||
},
|
||||
"delegation": {
|
||||
"delegatedPosition": "Delegated Position",
|
||||
"selectDelegatedPosition": "Select Delegated Position",
|
||||
"delegateTo": "Delegate To (Employee)",
|
||||
"selectEmployee": "Select Employee",
|
||||
"noEmployeesAvailable": "No Employees Available For This Position",
|
||||
"startDate": "Start Date",
|
||||
"pickDate": "Pick a date",
|
||||
"endDate": "End Date",
|
||||
"update": "Update",
|
||||
"save": "Save",
|
||||
"title": "Delegation",
|
||||
"newDelegation": "New Delegation",
|
||||
"editDelegation": "Edit Delegation",
|
||||
"addDelegation": "Add Delegation",
|
||||
"position": "Delegated Position",
|
||||
"active": "Active",
|
||||
"delegator": "Delegator",
|
||||
"delegatedEmployee": "Delegated Employee",
|
||||
"currentEndDate": "Current End Date",
|
||||
"select": " Select",
|
||||
"acting": "Acting As",
|
||||
"switchRole": "Switch Role",
|
||||
"successDelete": "Delegation is deleted successfully",
|
||||
"activeDelegationExists": "active delegation exists and either edit the active delegation or delete and create again"
|
||||
},
|
||||
"signatureUpload": {
|
||||
"signature": "Signature",
|
||||
"teeter": "Teeter",
|
||||
"uploadSignature": "Upload Signature",
|
||||
"uploadTeeter": "Upload Teeter",
|
||||
"uploadSignatureNote": "Upload Signature (PNG only)",
|
||||
"uploadTeeterNote": "Upload Teeter (PNG only)",
|
||||
"uploadedSignature": "Uploaded Signature:",
|
||||
"uploadedTeeter": "Uploaded Teeter:",
|
||||
"enterFileName": "Enter File Name (without extension)",
|
||||
"preview": "Preview:",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading...",
|
||||
"remove": "Remove",
|
||||
"removing": "Removing...",
|
||||
"removedSuccess": "Removed Successfully.",
|
||||
"removeTeeterFailed": "Remove Teeter Failed",
|
||||
"removeFailed": "Failed To Remove.",
|
||||
"selectPngFirst": "Please Select a PNG File First.",
|
||||
"onlyPngAllowed": "Only PNG Files Are Allowed.",
|
||||
"signatureExists": "The Signature Already Exists. Remove It To Upload a New One.",
|
||||
"uploadSuccess": "Upload Successful!",
|
||||
"uploadError": "Upload Error:",
|
||||
"uploadFailed": "Upload Failed.",
|
||||
"openSignatureForm": "Open Signature Drawer"
|
||||
},
|
||||
"assignment": {
|
||||
"assignedBy": "Assigned By",
|
||||
"assignedTo": "Assigned To",
|
||||
"assignedOn": "Assigned On:",
|
||||
"comment": "Comment:",
|
||||
"assignmentFlow": "Assignment Flow",
|
||||
"noAssignments": "No Assignments Have Been Made For This Record Yet.",
|
||||
"responded": "Responded"
|
||||
},
|
||||
"auth": {
|
||||
"welcomeBack": "Welcome Back",
|
||||
"enterCredentials": "Please Enter Your Credentials To Continue",
|
||||
"email": "Email",
|
||||
"phoneNumber": "Phone Number",
|
||||
"username": "User Name",
|
||||
"password": "Password",
|
||||
"rememberMe": "Remember Me",
|
||||
"forgotPassword": "Forgot Password?",
|
||||
"loggingIn": "Logging In...",
|
||||
"login": "Login",
|
||||
"welcomeHeadline": "Welcome to Your Smart Office",
|
||||
"paperlessOffice": "Paperless Office",
|
||||
"welcomeSubtext": "Sign In To Streamline Your Workflow and Eliminate The Clutter.",
|
||||
"emailAddress": "Email address",
|
||||
"err": "Invalid Ethiopian Phone Number It Should Start With +251",
|
||||
"invalidEmail": "Please enter a valid email address"
|
||||
},
|
||||
"msg": {
|
||||
"sent": "Letter is sent",
|
||||
"failedToCreate": "Failed To Create Record",
|
||||
"failedToGetUrl": "Failed To Get Upload URL",
|
||||
"registrationSuccess": "Registered. We Have Sent An SMS To Set Your Password Successfully.",
|
||||
"errorOccurred": "An Error Occurred.",
|
||||
"pdfRegenerated": "PDF Regenerated Successfully.",
|
||||
"approveFailed": "Failed To Approve Record.",
|
||||
"approveSuccess": "Record Approved Successfully",
|
||||
"selectPrompt": "Please Select a Stamp.",
|
||||
"extendedSuccess": "Delegation Extended Successfully.",
|
||||
"letter": "Letter",
|
||||
"approvedSuccess": "Record Approved Successfully!",
|
||||
"submittedSuccess": "Record Submitted Successfully!",
|
||||
"reviewSuccess": "Record Made Under Review Successfully!",
|
||||
"markedForAdjustment": "Marked For Adjustment",
|
||||
"sentToOfficer": "Sent To Record Officer",
|
||||
"errorSendingToOfficer": "Error Sending To Record Officer",
|
||||
"success": "Delegation Successful.",
|
||||
"failed": "Delegation Failed.",
|
||||
"deletedSuccess": "Delegation Deleted Successfully.",
|
||||
"deleteFailed": "Failed To Delete Delegation.",
|
||||
"failedCreateOrGetUrl": "Failed To Create Record or Get Upload URL.",
|
||||
"sentAndUploadedSuccess": "Letter Sent and File Uploaded Successfully.",
|
||||
"failedSendOrUpload": "Failed To Send Letter or Upload File.",
|
||||
"successassign": "Record Assigned Successfully.",
|
||||
"submittedWithAttachments": "Record and Attachments Submitted.",
|
||||
"createFailed": "Failed To Create Record.",
|
||||
"refreshedSuccess": "Refreshed Successfully.",
|
||||
"refreshFailed": "Failed To Refresh.",
|
||||
"redirecting": "Redirecting...",
|
||||
"loginSuccess": "Login Successful.",
|
||||
"receivingDepartmentRequired": "At least one receiving department is required",
|
||||
"receivingUnitRequired": "At least one receiving unit is required",
|
||||
"recordTypeRequired": "Record type is required",
|
||||
"initiationTypeRequired": "Initiation type is required",
|
||||
"subjectRequired": "Subject is required",
|
||||
"preferredLanguageRequired": "Preferred language is required",
|
||||
"bodyRequired": "Body is required",
|
||||
"sincerelyTextRequired": "Sincerely text is required",
|
||||
"headerRequired": "Header is required",
|
||||
"footerRequired": "Footer is required",
|
||||
"accessDenied": "Access Denied",
|
||||
"accessDeniedmsg": "You Cannot Enter This Page Because You Have an Active Delegation. Please Remove The Delegation To Continue.",
|
||||
"accessDeniedmsg2": "You Cannot Enter This Page Because You Have an Active Delegation. You Cannot Delegate If You Are Already Delegated By Another Employee",
|
||||
"accessDeniedmsg3": "You cannot access Record Management Module because you are not registered to this organization or you are not assigned any position"
|
||||
},
|
||||
"landingPage": {
|
||||
"smartOffice": "EDR Smart Office",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"overview": "Overview",
|
||||
"features": "Features",
|
||||
"workflow": "Workflow",
|
||||
"manual": "Manual",
|
||||
"digitalTransformation": "Digital transformation for modern government operations.",
|
||||
"facebook": "Facebook",
|
||||
"twitter": "Twitter",
|
||||
"telegram": "Telegram",
|
||||
"tiktok": "TikTok",
|
||||
"contactUs": "Contact Us",
|
||||
"quickLinks": "Quick Links",
|
||||
"copyright": "Tria Plc - Copyright ©",
|
||||
"rightsReserved": "All Rights Reserved - Powered by Tria",
|
||||
"address": "Lingo Tower Infront of Sheger House, Bole, Addis Ababa, Ethiopia",
|
||||
"platformSystem": "Platform System",
|
||||
"initiativeDescription": "A digital transformation initiative for the Ethio-Djibouti Railway designed to modernize government office operations with cutting-edge tech.",
|
||||
"getStarted": "Get Started",
|
||||
"learnMore": "Learn More",
|
||||
"sops": "SOPS",
|
||||
"smartOfficePlatformSystem": "Smart Office Platform System",
|
||||
"revolutionDescription": "Revolutionizing government operations with AI-powered workflow automation and real-time analytics.",
|
||||
"liveDemo": "Live Demo Available",
|
||||
"outgoingRecords": "Outgoing Records",
|
||||
"outgoingRecordsDesc": "Create and send internal or external records with attachments and hierarchical approval workflows.",
|
||||
"incomingRecords": "Incoming Records",
|
||||
"incomingRecordsDesc": "View, accept, and respond to received records, with the ability to assign them to relevant departments.",
|
||||
"approvalWorkflows": "Approval Workflows",
|
||||
"approvalWorkflowsDesc": "Digital letter and signature support with hierarchical approval processes that match your office structure.",
|
||||
"interactiveDashboard": "Interactive Dashboard",
|
||||
"interactiveDashboardDesc": "Real-time tracking of incoming and outgoing records, with department-level summaries and pending approvals.",
|
||||
"organizedFolders": "Organized Folders",
|
||||
"organizedFoldersDesc": "Categorize records using a structured system with advanced search and filtering capabilities.",
|
||||
"securityAccessControl": "Security & Access Control",
|
||||
"securityAccessControlDesc": "Role-based access with complete audit trails for all document actions and modifications.",
|
||||
"powerfulFeaturesTitle": "Powerful Features",
|
||||
"powerfulFeaturesSubtitle": "Streamline Your Document Workflow",
|
||||
"powerfulFeaturesDesc": "Our comprehensive tools help you manage records efficiently while maintaining security and compliance.",
|
||||
"feature1": "Faster Approval Times",
|
||||
"feature2": "More Organized Records",
|
||||
"feature3": "Audit Compliance",
|
||||
"documentWorkflow": "Document Workflow",
|
||||
"recordCreation": "Record Creation",
|
||||
"recordCreationDesc": "User creates a new record with attachments and metadata",
|
||||
"teamLeaderReview": "Team Leader Review",
|
||||
"teamLeaderReviewDesc": "First level approval by immediate supervisor",
|
||||
"directorApproval": "Director Approval",
|
||||
"directorApprovalDesc": "Department head reviews and approves",
|
||||
"deputyHeadReview": "Deputy Head Review",
|
||||
"deputyHeadReviewDesc": "Final administrative approval before submission",
|
||||
"recordOfficerProcessing": "Record Officer Processing",
|
||||
"recordOfficerProcessingDesc": "Official registration and dispatch",
|
||||
"streamlinedProcess": "Streamlined Process",
|
||||
"recordManagementApprovalWorkflows": "Record Management Approval Workflows",
|
||||
"smartOfficeDescription": "Smart Office mirrors your organizational hierarchy with customizable digital workflows that ensure proper oversight and accountability.",
|
||||
"step": "STEP",
|
||||
"requestChanges": "Request Changes",
|
||||
"workflowAutomationBenefits": "Workflow Automation Benefits",
|
||||
"workflowAutomationDesc": "Our intelligent workflow system reduces processing time by 65% while ensuring compliance with your organizational policies.",
|
||||
"fasterApprovals": "65% Faster Approvals",
|
||||
"reduceDelays": "Reduce bureaucratic delays",
|
||||
"auditTrail": "100% Audit Trail",
|
||||
"completeRecord": "Complete record of all actions",
|
||||
"advancedFeatures": "Advanced Features",
|
||||
"slaMonitoring": "SLA Monitoring",
|
||||
"slaMonitoringDesc": "Automatic tracking of service level agreements with escalation alerts for delays.",
|
||||
"conditionalRouting": "Conditional Routing",
|
||||
"conditionalRoutingDesc": "Smart rules automatically route documents based on content, value, or department.",
|
||||
"temporaryApprovalDelegation": "Temporary approval delegation for vacations with full audit tracking.",
|
||||
"login": "Login to the System",
|
||||
"step1": "1. Click on the",
|
||||
"headerBtn": "button in the header",
|
||||
"step2": "2. Enter your username and password provided by your administrator",
|
||||
"step3": "3. Click the",
|
||||
"accessBtn": "button to access the system",
|
||||
"note": "Note",
|
||||
"forgotIntro": "If you forget your password, contact your system administrator for reset.",
|
||||
"dashboardTitle": "Dashboard Overview",
|
||||
"dashboardDesc": "The dashboard provides a comprehensive overview of your record activities:",
|
||||
"recordsCreated": "Records Created",
|
||||
"recordsTotal": "Total records you've initiated",
|
||||
"recordsReceived": "Records Received:",
|
||||
"incomingDocs": "Incoming documents from others",
|
||||
"breakdown": "Internal/External Breakdown:",
|
||||
"visuals": "Visual charts showing document types",
|
||||
"approvalStatus": "Approval Status",
|
||||
"approvalTypes": "Pending, approved, or rejected records",
|
||||
"quickActions": "Quick Actions",
|
||||
"createOrCheck": "Create new records or check pending approvals directly from the dashboard",
|
||||
"recentActivity": "Recent Activity",
|
||||
"trackUpdates": "Track the latest updates on your documents",
|
||||
"manageOut": "Managing Outgoing Records",
|
||||
"createNew": "Creating New Records",
|
||||
"goToTab": "Navigate to the",
|
||||
"clickBtn": "Click button",
|
||||
"fillFields": "Fill in all required fields (record type, receiver, subject, etc.)",
|
||||
"attach": "Attach supporting documents if needed",
|
||||
"saveOrSubmit": "Save as draft or submit for approval",
|
||||
"trackStatus": "Tracking Record Status",
|
||||
"notForwarded": "This item is not yet forwarded. This items can be edited and deleted",
|
||||
"awaiting": "This item is awaiting acceptance or approval.",
|
||||
"accepted": "This item has been accepted and is awaiting approval.",
|
||||
"approvedSent": "This item has been approved and sent to record officer to be dispatched.",
|
||||
"returned": "This item has been returned for adjustments or additional information. Please review the comments.",
|
||||
"rejected": "This item has been rejected.",
|
||||
"sent": "This item has been sent to the recipients.",
|
||||
"returnedByOfficer": "This item has been returned by the Record Officer.",
|
||||
"monitor": "Monitor your records through each approval stage in the workflow",
|
||||
"manageIncoming": "Managing Incoming Records",
|
||||
"incomingTypes": "Types of Incoming Records",
|
||||
"fromOthers": "From other organizations",
|
||||
"withinOrg": "Within your organization",
|
||||
"copied": "Copied for your information",
|
||||
"processIncoming": "Processing Incoming Records",
|
||||
"readDoc": "Open and read the full document",
|
||||
"acknowledge": "Acknowledge receipt of the document",
|
||||
"forward": "Forward to another team member if needed",
|
||||
"archive": "Archive",
|
||||
"fileRef": "File for future reference",
|
||||
"apprvalworkflow": "Approval Workflow",
|
||||
"process": "Approval Process",
|
||||
"internalFlow": "Internal Records Workflow:",
|
||||
"creator": "Creator",
|
||||
"leader": "Team Leader",
|
||||
"director": "Director",
|
||||
"officer": "Record Officer",
|
||||
"externalFlow": "External Records Workflow:",
|
||||
"actions": "Approval Actions",
|
||||
"delegation": "Delegation Management",
|
||||
"delegateInfo": "When you're unavailable, you can delegate your approval authority to another team member:",
|
||||
"delegateNav": "Navigate to the",
|
||||
"selectColleague": "Select a colleague from your organization structure",
|
||||
"setDates": "Set the start and end dates for the delegation period",
|
||||
"setPerms": "Specify which permissions to delegate",
|
||||
"saveDelegate": "Save the delegation settings",
|
||||
"autoExpire": "Delegations automatically expire on the end date you specify.",
|
||||
"collab": "Collaboration Workspace",
|
||||
"collabInfo": "The collaboration tab brings together records that require input from multiple team members:",
|
||||
"viewDocs": "View all collaborative documents in one place",
|
||||
"addComments": "Add your comments or annotations",
|
||||
"approve": "Approve with your digital signature when consensus is reached",
|
||||
"trackChanges": "Track changes and version history",
|
||||
"realTime": "Real-time Updates",
|
||||
"liveChanges": "See changes from other collaborators as they happen",
|
||||
"notifications": "Notification System",
|
||||
"alerts": "Get alerts when others contribute to the document",
|
||||
"signatureMgmt": "Teeter & Signature Management",
|
||||
"leadersOnly": "Team leaders and above can manage their digital teeter and signature:",
|
||||
"signatureTitle": "Teeter & Signature",
|
||||
"uploadTeeter": "Upload a clear image of your official teeter",
|
||||
"uploadSignature": "Create or upload your digital signature",
|
||||
"defaultSignature": "Set as default for all approvals",
|
||||
"updateDesignation": "Update whenever your official designation changes",
|
||||
"security": "Security Notice:",
|
||||
"encrypted": "Your teeter and signature are encrypted and only used for official approvals.",
|
||||
"recordModule": "Record Management Module",
|
||||
"guide": "Comprehensive guide for using the Smart Office Platform System",
|
||||
"back": "Back to Dashboard",
|
||||
"moduleGuide": "Module Guide",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"addTeeterAndSignature": "Add digital teeter and signature",
|
||||
"returnWithComments": "Return with comments",
|
||||
"requestModifications": "Request modifications",
|
||||
"screenshotOf": "Screenshot of",
|
||||
"fasterApproval": "Faster Approval",
|
||||
"auditReady": "Audit Ready",
|
||||
"organizedRecords": "Organized Records",
|
||||
"tab": "Tab",
|
||||
"click": "Click",
|
||||
"button": "Button"
|
||||
},
|
||||
"organization": {
|
||||
"organizations": "Organizations",
|
||||
"newOrganization": "New Organization",
|
||||
"organizationName": "Organization name",
|
||||
"createdOn": "Created on",
|
||||
"numberOfUsers": "Number of users",
|
||||
"assignedAdmin": "Assigned admin",
|
||||
"isGovernmentOrganization": "Is Government Organization",
|
||||
"createNew": "Create New",
|
||||
"edit": "Edit",
|
||||
"enterDetails": "Enter the details for the new organization. All fields marked with an asterisk (*) are required.",
|
||||
"nameAmharic": "Name (Amharic)",
|
||||
"nameEnglish": "Name (English)",
|
||||
"key": "Key",
|
||||
"organizationType": "Organization Type",
|
||||
"loadingOrganizationTypes": "Loading organization types...",
|
||||
"errorLoadingOrganizationTypes": "Error loading organization types",
|
||||
"noOrganizationTypesAvailable": "No organization types available",
|
||||
"parentOrganization": "Parent Organizational (optional)",
|
||||
"loading": "Loading...",
|
||||
"selectOrganization": "Select organization",
|
||||
"noOrganization": "No organization",
|
||||
"isOrganizationPublic": "Is Organization Public",
|
||||
"selectOrganizationType": "Select organization type",
|
||||
"deleteOrganization": "Delete Organization",
|
||||
"confirmDelete": "Are you sure you want to permanently delete?",
|
||||
"cannotUndo": "This action cannot be undone.",
|
||||
"deleting": "Deleting ...",
|
||||
"delete": "Delete",
|
||||
"viewOrganization": "View Organization",
|
||||
"editOrganization": "Edit Organization",
|
||||
"englishNameRequired": "English name is required",
|
||||
"amharicNameRequired": "Amharic name is required",
|
||||
"invalidEmail": "Invalid email address",
|
||||
"usernameMinLength": "Username must be at least 3 characters",
|
||||
"organizationRequired": "Organization is required",
|
||||
"invalidPhoneNumber": "Phone number must be a valid Ethiopian number",
|
||||
"registerAdminTitle": "Register Organization Admin",
|
||||
"registerAdminDescription": "Fill in the required fields to register",
|
||||
"enterEnglishName": "Enter English name",
|
||||
"enterAmharicName": "Enter Amharic name",
|
||||
"email": "Email",
|
||||
"emailExample": "email@example.com",
|
||||
"username": "Username",
|
||||
"enterUsername": "Enter a username",
|
||||
"phoneNumber": "Phone Number",
|
||||
"phoneNumberExample": "e.g. 0912345678 or +251912345678",
|
||||
"organization": "Organization",
|
||||
"registering": "Registering...",
|
||||
"registerAdmin": "Register Admin",
|
||||
"dashboard": "Dashboard",
|
||||
"organizationAdmins": "Organization Admins",
|
||||
"externalUsers": "External Users",
|
||||
"userManagement": "User Management",
|
||||
"content": "Content",
|
||||
"bulkUpload": "Bulk Upload",
|
||||
"positionTypes": "Position Types",
|
||||
"activityLog": "Activity Log",
|
||||
"setting": "Setting",
|
||||
"loadingAdmins": "Loading admins...",
|
||||
"errorLoadingAdmins": "Error loading admin data",
|
||||
"retry": "Retry",
|
||||
"assignAdmin": "Assign Admin",
|
||||
"addAdmin": "Add Admin",
|
||||
"adminAssignedSuccess": "Admin assigned successfully",
|
||||
"userAssignedSuccess": "User assigned successfully",
|
||||
"userAssignFailed": "User couldn't be assigned",
|
||||
"assignAdminToOrganization": "Assign Admin to Organization",
|
||||
"assignAdminInstructions": "Select an organization and a user to assign as an admin.",
|
||||
"users": "Users",
|
||||
"searchUsers": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"assigning": "Assigning...",
|
||||
"assignUser": "Assign User",
|
||||
"pendingExternalUsers": "Pending External Users",
|
||||
"activateUserPrompt": "Activate this user?",
|
||||
"activateUserDescription": "This will enable the user to access the platform",
|
||||
"activating": "Activating...",
|
||||
"yesActivate": "Yes, activate",
|
||||
"statistics": "Statistics",
|
||||
"totalOrganizations": "Total Organizations",
|
||||
"recentOrganizations": "Recent Organizations",
|
||||
"viewMore": "View more →",
|
||||
"recentActivities": "Recent Activities",
|
||||
"moreActivities": "More activities →",
|
||||
"noRecentActivities": "No recent activities found",
|
||||
"updatedOn": "Created On",
|
||||
"settings": "Settings",
|
||||
"Archive Users": "Archive Users"
|
||||
},
|
||||
"userIncomingretun": {
|
||||
"View Detail": "View Detail",
|
||||
"Open actions menu": "Open actions menu",
|
||||
"Actions": "Actions",
|
||||
"convertRecord": "Convert Record",
|
||||
"return": "Return",
|
||||
"returning": "Returning...",
|
||||
"returing": "Returning...",
|
||||
"conformReturn": "Confirm Return",
|
||||
"Please provide a reason for returning this record.": "Please provide a reason for returning this record.",
|
||||
"ReturnComment": "Return Comment",
|
||||
"EnterReturnReason": "Enter reason for returning",
|
||||
"CommentRequired": "Comment is required",
|
||||
"RecordReturnedSuccessfully": "Record returned successfully",
|
||||
"FailedToReturnRecord": "Failed to return record"
|
||||
},
|
||||
"okrDashboard": {
|
||||
"brandEyebrow": "Objective Intelligence",
|
||||
"title": "OKR Performance Dashboard",
|
||||
"description": "A focused overview of top performance, department momentum, and upcoming OKR analytics.",
|
||||
"planYear": "Plan Year",
|
||||
"notSelected": "Not selected",
|
||||
"topPerformerEmployee": "Top Performer Employee",
|
||||
"topPerformerDepartment": "Top Performer Department",
|
||||
"employeePlaceholderName": "Awaiting API data",
|
||||
"departmentPlaceholderName": "Awaiting API data",
|
||||
"employeeHelper": "The leading employee will appear here once the dashboard API is connected.",
|
||||
"departmentHelper": "The highest performing department will appear here once the dashboard API is connected.",
|
||||
"employeeScore": "Employee score",
|
||||
"departmentScore": "Department score",
|
||||
"placeholderOneTitle": "Strategic Signal",
|
||||
"placeholderOneHelper": "Reserved for the next OKR insight from the analytics API.",
|
||||
"placeholderTwoTitle": "Execution Health",
|
||||
"placeholderTwoHelper": "Reserved for rollout, risk, or completion insight.",
|
||||
"comingSoon": "Coming soon",
|
||||
"waitingForApi": "Waiting for API",
|
||||
"analyticsTitle": "OKR Analytics",
|
||||
"analyticsDescription": "Select a date range and chart type to preview the dashboard visualization.",
|
||||
"dateRange": "Date Range",
|
||||
"chartType": "Chart Type",
|
||||
"thisMonth": "This Month",
|
||||
"thisQuarter": "This Quarter",
|
||||
"thisYear": "This Year",
|
||||
"customRange": "Custom Range",
|
||||
"barChart": "Bar Chart",
|
||||
"lineChart": "Line Chart",
|
||||
"areaChart": "Area Chart",
|
||||
"chartTitle": "Performance Trend",
|
||||
"chartSubtitle": "Sample data is displayed until the API response is ready.",
|
||||
"sampleData": "Sample Data",
|
||||
"achievement": "Achievement",
|
||||
"target": "Target"
|
||||
}
|
||||
}
|
||||
7397
apps/edr-freight-web/backoffice/src/locales/am/translation.json
Normal file
7397
apps/edr-freight-web/backoffice/src/locales/am/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
7399
apps/edr-freight-web/backoffice/src/locales/en/translation.json
Normal file
7399
apps/edr-freight-web/backoffice/src/locales/en/translation.json
Normal file
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