This commit is contained in:
Marshal
2026-07-14 13:11:38 +00:00
1915 changed files with 241099 additions and 165123 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ const links = [
{
title: "User management",
description: "Employees, roles, and permissions",
href: "/dashboard/user-management",
href: "/user-management",
icon: Users,
},
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
},
]}
/>
);
}

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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