mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
fix ui
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import NotificationList from "@/record-management/components/NotificationList";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { ChevronDown, User, Key, LogOut } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiBell, FiChevronDown } from "react-icons/fi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNotifications } from "@/shared/hooks/useNotification";
|
||||
import { cn } from "@/shared/common/ui/fileUploader/utils";
|
||||
import { useUser } from "@/shared/context/UserContext";
|
||||
import {
|
||||
UI_LANGUAGE_OPTIONS,
|
||||
getUiLanguageLabel,
|
||||
getUiLanguageShortLabel,
|
||||
resolveUiLanguage,
|
||||
} from "@/shared/i18n/uiLanguages";
|
||||
|
||||
export const ExternalPortal = ({
|
||||
mobileView = false,
|
||||
onItemClick,
|
||||
}: {
|
||||
mobileView?: boolean;
|
||||
onItemClick?: () => void;
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const userDetails = useUser();
|
||||
const { logout } = useAuthUser();
|
||||
const fullName = userDetails?.name?.en || t("header.user");
|
||||
const splittedName = fullName.trim().split(" ");
|
||||
const initials =
|
||||
splittedName.length === 1
|
||||
? splittedName[0][0]
|
||||
: `${splittedName[0][0]}${splittedName[1][0]}`;
|
||||
|
||||
const currentLanguage = resolveUiLanguage(i18n.language);
|
||||
const changeLanguage = (lng: string) => i18n.changeLanguage(lng);
|
||||
const handleLogout = () => {
|
||||
logout("/external-portal/signin");
|
||||
};
|
||||
|
||||
const { unseenCount } = useNotifications({
|
||||
take: 10,
|
||||
skip: 0,
|
||||
orderBy: "updatedAt:DESC",
|
||||
});
|
||||
const [openNotifications, setOpenNotifications] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpenNotifications(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Handle navigation with optional callback for mobile
|
||||
const handleNavigation = (path: string) => {
|
||||
navigate(path);
|
||||
if (onItemClick) onItemClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex items-center ${
|
||||
mobileView
|
||||
? "flex-col space-y-4 w-full"
|
||||
: "space-x-1 md:space-x-3 ml-auto"
|
||||
}`}>
|
||||
{/* Notifications */}
|
||||
|
||||
<div
|
||||
className={`relative ${mobileView ? "w-full" : ""}`}
|
||||
ref={dropdownRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={mobileView ? "default" : "icon"}
|
||||
className={cn(
|
||||
"relative text-gray-500 hover:bg-primary-50 hover:text-primary-600",
|
||||
mobileView
|
||||
? "w-full justify-start px-4 py-3 text-base"
|
||||
: "h-8 w-8 rounded-full md:h-9 md:w-9",
|
||||
openNotifications &&
|
||||
"bg-primary-50 text-primary-700 ring-1 ring-inset ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60",
|
||||
)}
|
||||
aria-label={t("header.notifications")}
|
||||
onClick={() => setOpenNotifications((prev) => !prev)}>
|
||||
<FiBell
|
||||
className={cn(
|
||||
"h-4 w-4 transition-colors md:h-5 md:w-5",
|
||||
mobileView && "mr-3",
|
||||
openNotifications &&
|
||||
"fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300",
|
||||
)}
|
||||
/>
|
||||
{mobileView && <span>{t("header.notifications")}</span>}
|
||||
{unseenCount > 0 && (
|
||||
<span
|
||||
className={`absolute ${
|
||||
mobileView ? "top-3 right-4" : "-top-1 -right-1"
|
||||
} bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full`}>
|
||||
{unseenCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{openNotifications && (
|
||||
<div
|
||||
className={
|
||||
mobileView
|
||||
? "fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl border bg-white shadow-2xl"
|
||||
: "absolute right-0 z-50 mt-2 w-[24rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border bg-white shadow-2xl"
|
||||
}>
|
||||
<NotificationList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Language Switcher */}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={mobileView ? "default" : "sm"}
|
||||
className={`${
|
||||
mobileView
|
||||
? "w-full justify-start px-4 py-3 text-base"
|
||||
: "gap-1 md:gap-1.5 text-xs md:text-sm h-8 px-2 md:px-3"
|
||||
} font-medium text-gray-700 hover:bg-primary-50 hover:text-primary-600`}
|
||||
onClick={(e) => e.preventDefault()}>
|
||||
<FiChevronDown
|
||||
className={`${
|
||||
mobileView ? "mr-3" : ""
|
||||
} h-3 w-3 md:h-4 md:w-4 opacity-50`}
|
||||
/>
|
||||
<span>{getUiLanguageLabel(currentLanguage, t)}</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
className={`w-40 bg-white shadow-xl rounded-lg p-1 z-50 ${
|
||||
mobileView ? "ml-4" : ""
|
||||
}`}
|
||||
align={mobileView ? "start" : "end"}
|
||||
sideOffset={5}>
|
||||
{UI_LANGUAGE_OPTIONS.map((lang) => (
|
||||
<DropdownMenu.Item
|
||||
key={lang.value}
|
||||
onSelect={() => {
|
||||
changeLanguage(lang.value);
|
||||
if (onItemClick) onItemClick();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between text-sm text-gray-700 hover:bg-primary-100 rounded-md px-3 py-2 cursor-pointer",
|
||||
currentLanguage === lang.value && "bg-primary-50",
|
||||
)}
|
||||
>
|
||||
<span>{getUiLanguageLabel(lang.value, t)}</span>
|
||||
{currentLanguage === lang.value && (
|
||||
<span className="text-xs">
|
||||
{getUiLanguageShortLabel(lang.value, t)}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/* User Menu */}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex items-center ${
|
||||
mobileView
|
||||
? "w-full justify-start px-4 py-3 text-base"
|
||||
: "h-8 md:h-10 px-1 md:px-2"
|
||||
} space-x-1 md:space-x-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900`}
|
||||
aria-label={t("header.userMenu")}>
|
||||
<div
|
||||
className={`flex items-center justify-center ${
|
||||
mobileView ? "w-10 h-10" : "w-7 h-7 md:w-8 md:h-8"
|
||||
} rounded-full bg-gray-200`}>
|
||||
<span
|
||||
className={`${
|
||||
mobileView ? "text-base" : "text-xs md:text-sm"
|
||||
} font-medium`}>
|
||||
{initials.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{!mobileView && (
|
||||
<div className="hidden sm:flex flex-col items-start">
|
||||
<span className="text-xs font-medium leading-none">
|
||||
{userDetails?.name?.en}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 leading-none mt-1">
|
||||
External Organization
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mobileView && (
|
||||
<div className="flex flex-col items-start ml-3">
|
||||
<span className="text-sm font-medium leading-none">
|
||||
{userDetails?.name?.en}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-none mt-1">
|
||||
External Organization
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 md:h-4 md:w-4 text-gray-500" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
className={`w-48 p-1 bg-white shadow-xl rounded-md ${
|
||||
mobileView ? "ml-4" : ""
|
||||
}`}
|
||||
align={mobileView ? "start" : "end"}>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center px-3 py-2.5 text-sm text-gray-700 hover:bg-primary-100 rounded-md cursor-pointer"
|
||||
onClick={() => handleNavigation("/profile")}>
|
||||
<User className="mr-2.5 h-4 w-4 text-gray-500" />
|
||||
<span>{t("header.viewProfile")}</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center px-3 py-2.5 text-sm text-gray-700 hover:bg-primary-100 rounded-md cursor-pointer"
|
||||
onClick={() =>
|
||||
handleNavigation("/record-management/change-password")
|
||||
}>
|
||||
<Key className="mr-2.5 h-4 w-4 text-gray-500" />
|
||||
<span>{t("header.changePassword")}</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-gray-200" />
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 rounded-md cursor-pointer"
|
||||
onClick={handleLogout}>
|
||||
<LogOut className="mr-2.5 h-4 w-4" />
|
||||
<span>{t("header.signOut")}</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
import Header from "../../../layout/components/Header";
|
||||
import React from "react";
|
||||
import { AlertCircle, Mail, PhoneCall } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface VerificationPendingProps {
|
||||
hasCompletedRegistration: boolean;
|
||||
contactNumber?: string;
|
||||
email?:string;
|
||||
|
||||
}
|
||||
|
||||
const VerificationPending: React.FC<VerificationPendingProps> = ({
|
||||
hasCompletedRegistration,
|
||||
contactNumber,
|
||||
email,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (hasCompletedRegistration) {
|
||||
return null; // ✅ Nothing to show if user has finished registration
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-primary-50 px-6">
|
||||
<Header />
|
||||
|
||||
<div className="max-w-lg w-full bg-white rounded-2xl shadow-lg p-8 border border-primary-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<AlertCircle className="text-primary-600 w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold text-primary-700">
|
||||
{t("verification.pending")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">
|
||||
{t("verification.message")}{" "}
|
||||
<span className="font-medium text-primary-700">
|
||||
<Mail/>{email} or <PhoneCall/>{contactNumber} </span>.
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationPending;
|
||||
@@ -0,0 +1,5 @@
|
||||
import UserTypeSelection from "./UserTypeSelection";
|
||||
|
||||
export default function ExternalAuthPage() {
|
||||
return <UserTypeSelection />;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useRegisterExternalPortalUser } from "@/external-portal/hooks/useRegisterExternalPortalUser";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { getDefaultFaydaRedirectUri } from "@/shared/utils/faydaOidc";
|
||||
import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ExternalPortalCallback() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const { isFayidaRegistering, registerExternalFayidaUser } =
|
||||
useRegisterExternalPortalUser();
|
||||
const hasRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRun.current) return;
|
||||
hasRun.current = true;
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
if (!code) {
|
||||
toast.error("Missing authorization code");
|
||||
setError("Missing authorization code");
|
||||
setLoading(false);
|
||||
navigate("/external-portal/signin", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const loginWithFayda = async () => {
|
||||
try {
|
||||
const res = await registerExternalFayidaUser({
|
||||
code,
|
||||
redirectUri: getDefaultFaydaRedirectUri(),
|
||||
});
|
||||
|
||||
if (!res || !res.response?.data) {
|
||||
throw new Error("Invalid response from Fayda login");
|
||||
}
|
||||
|
||||
const data = res.response.data;
|
||||
const userId = data?.userId ?? data?.user?.id ?? data?.user?.userId;
|
||||
const token = data?.token;
|
||||
const refreshToken = data?.refreshToken;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("Failed to get user ID from server.");
|
||||
}
|
||||
|
||||
await persistFaydaRegistrationAuth({
|
||||
...data,
|
||||
token,
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
navigate(`/verify-otp?userId=${userId}&isExternalOrg=false`, {
|
||||
replace: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error("LoginWithFayda error:", err);
|
||||
handleError(err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("registration.auth.faydaSigninFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loginWithFayda();
|
||||
}, [searchParams, navigate, handleError, registerExternalFayidaUser, t]);
|
||||
|
||||
if (loading || isFayidaRegistering) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-white">
|
||||
<Loader2 className="h-10 w-10 text-primary-600 animate-spin mb-4" />
|
||||
<p className="text-primary-700 text-lg font-medium">
|
||||
{t("registration.auth.faydaProcessing")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-red-50">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate("/external-portal/signin", { replace: true })
|
||||
}>
|
||||
{t("registration.auth.backToAuth")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
FileText,
|
||||
ImageIcon,
|
||||
FileIcon,
|
||||
VideoIcon,
|
||||
DownloadIcon,
|
||||
XIcon,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { useToast } from "@/shared/common/ui/use-toast";
|
||||
|
||||
type FileType = "image" | "pdf" | "video" | "other";
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: File | string; // Can accept File object or URL string
|
||||
type?: FileType; // Optional type hint
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
showDownload?: boolean;
|
||||
}
|
||||
|
||||
export const FilePreview = ({
|
||||
file,
|
||||
type,
|
||||
className,
|
||||
onRemove,
|
||||
showDownload = true,
|
||||
}: FilePreviewProps) => {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [detectedType, setDetectedType] = useState<FileType>("other");
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const determineFileType = (): FileType => {
|
||||
if (type) return type;
|
||||
|
||||
if (typeof file === "string") {
|
||||
const extension = file.split(".").pop()?.toLowerCase();
|
||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension || "")) {
|
||||
return "image";
|
||||
}
|
||||
if (extension === "pdf") return "pdf";
|
||||
if (["mp4", "webm", "ogg"].includes(extension || "")) return "video";
|
||||
return "other";
|
||||
}
|
||||
|
||||
if (file.type.startsWith("image/")) return "image";
|
||||
if (file.type === "application/pdf") return "pdf";
|
||||
if (file.type.startsWith("video/")) return "video";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const generatePreview = async () => {
|
||||
setIsLoading(true);
|
||||
setDetectedType(determineFileType());
|
||||
|
||||
try {
|
||||
if (typeof file === "string") {
|
||||
setPreviewUrl(file);
|
||||
} else {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating preview:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Could not generate file preview",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
generatePreview();
|
||||
|
||||
return () => {
|
||||
if (previewUrl && typeof file !== "string") {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [file, type]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!previewUrl) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = previewUrl;
|
||||
link.download = typeof file === "string" ? "download" : file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (detectedType) {
|
||||
case "image":
|
||||
return (
|
||||
<img
|
||||
src={previewUrl || ""}
|
||||
alt="Preview"
|
||||
className="object-contain w-full h-full"
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4">
|
||||
<FileText className="h-16 w-16 text-red-500 dark:text-red-400" />
|
||||
<span className="mt-2 text-sm font-medium truncate text-foreground">
|
||||
{typeof file === "string" ? "PDF Document" : file.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
className="w-full h-full"
|
||||
onLoadedData={() => setIsLoading(false)}>
|
||||
<source src={previewUrl || ""} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4">
|
||||
<FileIcon className="h-16 w-16 text-gray-400 dark:text-gray-500" />
|
||||
<span className="mt-2 text-sm font-medium truncate text-foreground">
|
||||
{typeof file === "string" ? "File" : file.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-800 dark:border-gray-700 w-full h-40",
|
||||
className
|
||||
)}>
|
||||
{renderPreview()}
|
||||
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
{onRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-background/80 hover:bg-background"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showDownload && previewUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-background/80 hover:bg-background"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload();
|
||||
}}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute inset-0 w-full h-full opacity-0 hover:opacity-100 hover:bg-background/20 dark:hover:bg-background/40"
|
||||
onClick={() => setIsDialogOpen(true)}>
|
||||
<span className="sr-only">View fullscreen</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-[90vw] max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{typeof file === "string" ? "File Preview" : file.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative h-[70vh]">
|
||||
{detectedType === "image" && (
|
||||
<img
|
||||
src={previewUrl || ""}
|
||||
alt="Fullscreen preview"
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
)}
|
||||
{detectedType === "pdf" && (
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full"
|
||||
title="PDF Preview"
|
||||
/>
|
||||
)}
|
||||
{detectedType === "video" && (
|
||||
<video controls autoPlay className="w-full h-full">
|
||||
<source src={previewUrl || ""} type="video/mp4" />
|
||||
</video>
|
||||
)}
|
||||
{detectedType === "other" && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<FileIcon className="h-16 w-16 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
{showDownload && (
|
||||
<Button onClick={handleDownload} variant="outline">
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setIsDialogOpen(false)}>Close</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import ReactCrop, {
|
||||
centerCrop,
|
||||
makeAspectCrop,
|
||||
Crop,
|
||||
PixelCrop,
|
||||
convertToPixelCrop,
|
||||
} from "react-image-crop";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { RotateCw, RotateCcw, Rotate3D } from "lucide-react";
|
||||
import { canvasPreview } from "./canvasPreview";
|
||||
import { useDebounceEffect } from "./useDebounceEffect";
|
||||
import "react-image-crop/dist/ReactCrop.css";
|
||||
|
||||
interface ImageCropperProps {
|
||||
file: File;
|
||||
onCropComplete: (croppedFile: File) => void;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
export function ImageCropper({
|
||||
file,
|
||||
onCropComplete,
|
||||
aspectRatio = 3 / 4,
|
||||
}: ImageCropperProps) {
|
||||
const [imgSrc, setImgSrc] = useState("");
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [crop, setCrop] = useState<Crop>();
|
||||
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
|
||||
const [scale, setScale] = useState(1);
|
||||
const [rotate, setRotate] = useState(0);
|
||||
|
||||
const rotateClockwise = useCallback(() => {
|
||||
setRotate((prev) => (prev + 90) % 360);
|
||||
}, []);
|
||||
|
||||
const rotateCounterClockwise = useCallback(() => {
|
||||
setRotate((prev) => (prev - 90) % 360);
|
||||
}, []);
|
||||
|
||||
const rotateSmall = useCallback((degrees: number) => {
|
||||
setRotate((prev) => (prev + degrees) % 360);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
setImgSrc(reader.result?.toString() || "");
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}, [file]);
|
||||
|
||||
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const { width, height } = e.currentTarget;
|
||||
setCrop(centerAspectCrop(width, height, aspectRatio));
|
||||
}
|
||||
|
||||
function centerAspectCrop(
|
||||
mediaWidth: number,
|
||||
mediaHeight: number,
|
||||
aspect: number
|
||||
) {
|
||||
return centerCrop(
|
||||
makeAspectCrop(
|
||||
{
|
||||
unit: "%",
|
||||
width: 90,
|
||||
},
|
||||
aspect,
|
||||
mediaWidth,
|
||||
mediaHeight
|
||||
),
|
||||
mediaWidth,
|
||||
mediaHeight
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCropComplete() {
|
||||
const image = imgRef.current;
|
||||
const previewCanvas = previewCanvasRef.current;
|
||||
if (!image || !previewCanvas || !completedCrop) {
|
||||
throw new Error("Crop canvas does not exist");
|
||||
}
|
||||
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
|
||||
const offscreen = new OffscreenCanvas(
|
||||
completedCrop.width * scaleX,
|
||||
completedCrop.height * scaleY
|
||||
);
|
||||
const ctx = offscreen.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("No 2d context");
|
||||
}
|
||||
|
||||
ctx.drawImage(
|
||||
previewCanvas,
|
||||
0,
|
||||
0,
|
||||
previewCanvas.width,
|
||||
previewCanvas.height,
|
||||
0,
|
||||
0,
|
||||
offscreen.width,
|
||||
offscreen.height
|
||||
);
|
||||
|
||||
const blob = await offscreen.convertToBlob({
|
||||
type: file.type || "image/png",
|
||||
});
|
||||
|
||||
const croppedFile = new File([blob], file.name, {
|
||||
type: blob.type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
onCropComplete(croppedFile);
|
||||
}
|
||||
|
||||
useDebounceEffect(
|
||||
async () => {
|
||||
if (
|
||||
completedCrop?.width &&
|
||||
completedCrop?.height &&
|
||||
imgRef.current &&
|
||||
previewCanvasRef.current
|
||||
) {
|
||||
canvasPreview(
|
||||
imgRef.current,
|
||||
previewCanvasRef.current,
|
||||
completedCrop,
|
||||
scale,
|
||||
rotate
|
||||
);
|
||||
}
|
||||
},
|
||||
100,
|
||||
[completedCrop, scale, rotate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{!!imgSrc && (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
onComplete={(c) => setCompletedCrop(c)}
|
||||
aspect={aspectRatio}
|
||||
minHeight={100}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt="Crop me"
|
||||
src={imgSrc}
|
||||
style={{ transform: `rotate(${rotate}deg)` }}
|
||||
onLoad={onImageLoad}
|
||||
className="max-h-[400px] object-contain"
|
||||
/>
|
||||
</ReactCrop>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="rotate-input">Rotate:</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={rotateCounterClockwise}
|
||||
title="Rotate counter-clockwise">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
id="rotate-input"
|
||||
type="number"
|
||||
value={rotate}
|
||||
min="-180"
|
||||
max="180"
|
||||
onChange={(e) => setRotate(Number(e.target.value))}
|
||||
className="w-16"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={rotateClockwise}
|
||||
title="Rotate clockwise">
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => rotateSmall(15)}
|
||||
title="Rotate 15° clockwise">
|
||||
<Rotate3D className="h-4 w-4 mr-1" /> 15°
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => rotateSmall(-15)}
|
||||
title="Rotate 15° counter-clockwise">
|
||||
<Rotate3D className="h-4 w-4 mr-1 transform scale-x-[-1]" /> 15°
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCropComplete}
|
||||
disabled={!completedCrop}
|
||||
className="self-start">
|
||||
Apply Crop
|
||||
</Button>
|
||||
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
style={{
|
||||
display: "none",
|
||||
border: "1px solid black",
|
||||
objectFit: "contain",
|
||||
width: completedCrop?.width,
|
||||
height: completedCrop?.height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
// RegistrationStepper.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Mail, User, Phone, Text, Building } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Stepper } from "./Stepper";
|
||||
import VerifyOtp from "../verifyOTP";
|
||||
import UploadDocumentsStep from "./UploadDocumentsStep";
|
||||
import { useRegisterExternalPortalUser } from "@/external-portal/hooks/useRegisterExternalPortalUser";
|
||||
import { submitApplication } from "@/external-portal/services/portalOutgoingService";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
|
||||
import { FilterEnum } from "@/shared/services/organizationsService";
|
||||
import { DocumentReq } from "./UploadDocumentsStep";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDocumentUploads } from "@/external-portal/hooks/useDocumentUploads";
|
||||
import { DocumentPayloadDto } from "@/shared/dto/External-Portal/External-PortalDto";
|
||||
import { useDeleteExternalUser } from "@/super-admin/hooks/useExternalUsers";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
type FilesRecord = Record<string, File | string | null>;
|
||||
|
||||
// registration schema
|
||||
const registrationSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
phoneNumber: z.string().min(10, "Phone number must be at least 10 digits"),
|
||||
name: z.object({
|
||||
en: z.string().min(2, "English name must be at least 2 characters"),
|
||||
am: z.string().min(2, "Amharic name must be at least 2 characters"),
|
||||
}),
|
||||
userType: z.enum(["individual", "external_organization"]),
|
||||
});
|
||||
|
||||
type RegistrationFormValues = z.infer<typeof registrationSchema>;
|
||||
|
||||
const RegistrationStepper: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [formData, setFormData] = useState<RegistrationFormValues | null>(null);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<FilesRecord>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [step1Completed, setStep1Completed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { registerExternalPortalUser, isRegistering } =
|
||||
useRegisterExternalPortalUser();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const { uploadDocuments } = useDocumentUploads();
|
||||
|
||||
const form = useForm<RegistrationFormValues>({
|
||||
resolver: zodResolver(registrationSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
username: "",
|
||||
phoneNumber: "",
|
||||
userType: "external_organization",
|
||||
name: { en: "", am: "" },
|
||||
},
|
||||
});
|
||||
|
||||
const userType = form.watch("userType");
|
||||
const mappedType =
|
||||
userType === "external_organization" ? "organization" : userType;
|
||||
const filterData: FilterEnum | undefined =
|
||||
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
|
||||
? (mappedType as FilterEnum)
|
||||
: undefined;
|
||||
|
||||
// fetch requirement docs to know which are required
|
||||
const { requirementDoc } = useDocumentRequirement(filterData);
|
||||
const requiredDocs = useMemo(
|
||||
() =>
|
||||
requirementDoc?.items?.filter((d: DocumentReq) => !d.isOptional) ?? [],
|
||||
[requirementDoc]
|
||||
);
|
||||
|
||||
const attachedCount = useMemo(
|
||||
() => Object.values(uploadedFiles).filter((f) => f !== null).length,
|
||||
[uploadedFiles]
|
||||
);
|
||||
|
||||
//validation
|
||||
const isStep1Complete = useMemo(() => {
|
||||
if (!form.formState.isValid) return false;
|
||||
|
||||
// Check if all required documents are uploaded
|
||||
const hasAllRequiredDocs = requiredDocs.every(
|
||||
(doc) => uploadedFiles[doc.id] instanceof File
|
||||
);
|
||||
|
||||
return hasAllRequiredDocs && form.formState.isValid;
|
||||
}, [form.formState.isValid, requiredDocs, uploadedFiles]);
|
||||
useEffect(() => {
|
||||
setStep1Completed(isStep1Complete);
|
||||
}, [isStep1Complete]);
|
||||
useEffect(() => {
|
||||
setStep1Completed(isStep1Complete);
|
||||
}, [isStep1Complete]);
|
||||
|
||||
const resetRegistrationFlow = () => {
|
||||
setCurrentStep(0);
|
||||
setFormData(null);
|
||||
setUploadedFiles({});
|
||||
setSubmitting(false);
|
||||
setStep1Completed(false);
|
||||
form.reset({
|
||||
email: "",
|
||||
username: "",
|
||||
phoneNumber: "",
|
||||
userType: "external_organization",
|
||||
name: { en: "", am: "" },
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resetRegistrationFlow();
|
||||
|
||||
const handlePageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) {
|
||||
resetRegistrationFlow();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pageshow", handlePageShow);
|
||||
return () => window.removeEventListener("pageshow", handlePageShow);
|
||||
}, []);
|
||||
const onRegistrationSubmit = async (data: RegistrationFormValues) => {
|
||||
// 1️⃣ Validate documents
|
||||
const missingDocs: string[] = requiredDocs
|
||||
.filter((doc) => !uploadedFiles[doc.id])
|
||||
.map((doc) => `${doc.title?.en || "Document"} is required.`);
|
||||
|
||||
// 2️⃣ Collect form errors from react-hook-form
|
||||
const formErrorMessages = Object.values(form.formState.errors)
|
||||
.map((err: any) => err?.message)
|
||||
.filter(Boolean);
|
||||
|
||||
// 3️⃣ Combine all errors
|
||||
const allErrors = [...formErrorMessages, ...missingDocs];
|
||||
|
||||
// 4️⃣ Stop submission and show toast if errors exist
|
||||
if (allErrors.length > 0) {
|
||||
allErrors.forEach((msg) => toast.error(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 5️⃣ Create external portal user
|
||||
const response = await registerExternalPortalUser(data);
|
||||
if (!response?.success) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: response?.response?.data?.message,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 6️⃣ Save tokens
|
||||
const token = response.response?.data?.token;
|
||||
const refreshToken = response.response?.data?.refreshToken;
|
||||
Cookies.set("auth-token", token || "");
|
||||
Cookies.set("refresh-token", refreshToken || "");
|
||||
|
||||
// 7️⃣ Upload documents
|
||||
await uploadAllFiles();
|
||||
await submitApplication();
|
||||
toast.success(t("registration.verifyOtp.messages.registrationComplete"));
|
||||
setFormData(data);
|
||||
setStep1Completed(true);
|
||||
setCurrentStep(1); // move to OTP verification
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes("Duplicate entry")) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: t("registration.verifyOtp.errors.alreadyRegistered"),
|
||||
});
|
||||
} else {
|
||||
handleError(err);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAllFiles = async (): Promise<void> => {
|
||||
const filesToUpload = Object.entries(uploadedFiles).filter(
|
||||
([, file]) => file instanceof File
|
||||
);
|
||||
|
||||
if (filesToUpload.length === 0) {
|
||||
throw new Error(
|
||||
"No documents uploaded. Registration requires documents."
|
||||
);
|
||||
}
|
||||
|
||||
const uploadPromises = filesToUpload.map(async ([docId, file]) => {
|
||||
if (file instanceof File) {
|
||||
const payload: DocumentPayloadDto = {
|
||||
documentId: docId,
|
||||
type: filterData || FilterEnum.EXTERNAL,
|
||||
fileInfo: {
|
||||
size: file.size,
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
originalname: file.name,
|
||||
},
|
||||
};
|
||||
|
||||
return uploadDocuments(payload, file).catch((err) => {
|
||||
throw new Error(
|
||||
`Failed to upload ${file.name} for document ${docId}: ${err}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
};
|
||||
|
||||
const onVerifyComplete = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
toast.success("Application submitted successfully.");
|
||||
const refreshToken = Cookies.get("refresh-token");
|
||||
if (!refreshToken) {
|
||||
throw new Error("No refresh token found");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submit error", err);
|
||||
toast.error("Failed to submit application.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const handleStepChange = (step: number) => {
|
||||
if (step === 1 && !step1Completed) {
|
||||
toast.error(
|
||||
"Please complete the registration form and upload all required documents first"
|
||||
);
|
||||
return;
|
||||
}
|
||||
setCurrentStep(step);
|
||||
};
|
||||
const steps = [
|
||||
{
|
||||
title: t("registration.steps.registerUpload.title"),
|
||||
description: t("registration.steps.registerUpload.description"),
|
||||
content: (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onRegistrationSubmit)}
|
||||
className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left: form */}
|
||||
<div className="space-y-4">
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{userType === "external_organization"
|
||||
? t("registration.form.organizationEmail")
|
||||
: t("registration.form.individualEmail")}
|
||||
<span className="text-red-500" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...field}
|
||||
className="pl-10"
|
||||
placeholder={
|
||||
userType === "external_organization"
|
||||
? t(
|
||||
"registration.form.organizationEmailPlaceholder"
|
||||
)
|
||||
: t(
|
||||
"registration.form.individualEmailPlaceholder"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{userType === "external_organization"
|
||||
? t("registration.form.organizationUsername")
|
||||
: t("registration.form.individualUsername")}
|
||||
<span className="text-red-500" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...field}
|
||||
className="pl-10"
|
||||
placeholder={
|
||||
userType === "external_organization"
|
||||
? t(
|
||||
"registration.form.organizationUsernamePlaceholder"
|
||||
)
|
||||
: t(
|
||||
"registration.form.individualUsernamePlaceholder"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phoneNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{userType === "external_organization"
|
||||
? t("registration.form.organizationPhone")
|
||||
: t("registration.form.individualPhone")}
|
||||
<span className="text-red-500" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...field}
|
||||
className="pl-10"
|
||||
placeholder={
|
||||
userType === "external_organization"
|
||||
? t(
|
||||
"registration.form.organizationPhonePlaceholder"
|
||||
)
|
||||
: t(
|
||||
"registration.form.individualPhonePlaceholder"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name.en"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{userType === "external_organization"
|
||||
? t("registration.form.organizationNameEn")
|
||||
: t("registration.form.individualNameEn")}
|
||||
<span className="text-red-500" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...field}
|
||||
className="pl-10"
|
||||
placeholder={
|
||||
userType === "external_organization"
|
||||
? t(
|
||||
"registration.form.organizationNameEnPlaceholder"
|
||||
)
|
||||
: t(
|
||||
"registration.form.individualNameEnPlaceholder"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name.am"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{userType === "external_organization"
|
||||
? t("registration.form.organizationNameAm")
|
||||
: t("registration.form.individualNameAm")}
|
||||
<span className="text-red-500" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={
|
||||
userType === "external_organization"
|
||||
? t(
|
||||
"registration.form.organizationNameAmPlaceholder"
|
||||
)
|
||||
: t(
|
||||
"registration.form.individualNameAmPlaceholder"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root?.message && (
|
||||
<div className="text-sm text-destructive mt-2">
|
||||
{String(form.formState.errors.root.message)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Uploads */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("registration.documents.requiredDocuments")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("registration.documents.requirementsUpdate")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{attachedCount} {t("registration.documents.attached")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 border rounded-md max-h-[60vh] overflow-auto">
|
||||
<UploadDocumentsStep
|
||||
registrationType={userType}
|
||||
uploadedFiles={uploadedFiles}
|
||||
setUploadedFiles={setUploadedFiles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("registration.documents.tip")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button - Span full width */}
|
||||
<div className="md:col-span-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isRegistering || submitting}
|
||||
className="w-full bg-primary-600">
|
||||
{isRegistering || submitting
|
||||
? t("registration.form.processing")
|
||||
: userType === "external_organization"
|
||||
? t("registration.form.organizationSubmitButton")
|
||||
: t("registration.form.individualSubmitButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("registration.steps.verifyOtp.title"),
|
||||
description: t("registration.steps.verifyOtp.description"),
|
||||
content: (
|
||||
<VerifyOtp
|
||||
email={formData?.email}
|
||||
phone={formData?.phoneNumber}
|
||||
onComplete={onVerifyComplete}
|
||||
isExternalOrg={true}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-5xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Building className="h-8 w-8 mr-2" />
|
||||
<CardTitle className="text-2xl font-bold">Registration</CardTitle>
|
||||
</div>
|
||||
<Stepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
setCurrentStep={handleStepChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>{steps[currentStep].content}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistrationStepper;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
interface StepperProps {
|
||||
steps: {
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
currentStep: number;
|
||||
setCurrentStep: (step: number) => void;
|
||||
disabled?:boolean;
|
||||
}
|
||||
|
||||
export const Stepper = ({
|
||||
steps,
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
disabled = false
|
||||
}: StepperProps) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex flex-col items-center flex-1",
|
||||
index < steps.length - 1 && "relative"
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
!disabled &&
|
||||
(index < currentStep || index === currentStep)
|
||||
) {
|
||||
setCurrentStep(index);
|
||||
}}}
|
||||
disabled={disabled || index > currentStep}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors duration-300",
|
||||
currentStep >= index
|
||||
? "bg-primary-500 border-primary-600 text-white" // Active step (green)
|
||||
: "bg-white border-gray-300 text-gray-500" // Inactive step
|
||||
)}>
|
||||
{index + 1}
|
||||
</button>
|
||||
<div className="mt-2 text-center">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
currentStep >= index
|
||||
? "text-primary-600" // Active title (darker green)
|
||||
: "text-gray-500" // Inactive title
|
||||
)}>
|
||||
{step.title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
currentStep >= index ? "text-primary-500" : "text-gray-400"
|
||||
)}>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-5 left-1/2 w-full h-0.5 -z-10",
|
||||
currentStep > index ? "bg-primary-400" : "bg-gray-200"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { useToast } from "@/shared/common/ui/use-toast";
|
||||
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
|
||||
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { getMyUploadById } from "@/external-portal/services/portalOutgoingService";
|
||||
import { useDocumentUploads } from "@/external-portal/hooks/useDocumentUploads";
|
||||
import { FilterEnum } from "@/shared/services/organizationsService";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import { Check, Trash } from "lucide-react";
|
||||
import { UploadFileModal } from "./UploadFileModal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUser } from "@/shared/context/UserContext";
|
||||
|
||||
export interface LocalizedText {
|
||||
am?: string;
|
||||
en?: string;
|
||||
}
|
||||
|
||||
export interface DocumentReq {
|
||||
id: string;
|
||||
title: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
key?: string;
|
||||
type?: string;
|
||||
order?: number;
|
||||
isActive?: boolean;
|
||||
isOptional?: boolean;
|
||||
}
|
||||
|
||||
type FilesRecord = Record<string, File | string | null>;
|
||||
|
||||
interface UploadDocumentsStepProps {
|
||||
registrationType?: "individual" | "external_organization" | string;
|
||||
uploadedFiles: FilesRecord;
|
||||
setUploadedFiles: React.Dispatch<React.SetStateAction<FilesRecord>>;
|
||||
}
|
||||
|
||||
|
||||
export const UploadDocumentsStep: React.FC<UploadDocumentsStepProps> = ({
|
||||
registrationType,
|
||||
uploadedFiles,
|
||||
setUploadedFiles,
|
||||
}) => {
|
||||
const userDetails = useUser();
|
||||
const {t} = useTranslation()
|
||||
// For other registration types, map to FilterEnum
|
||||
const mappedType =
|
||||
registrationType === "external_organization"
|
||||
? "organization"
|
||||
: (registrationType as string) || userDetails?.userType;
|
||||
|
||||
const filterData: FilterEnum | undefined =
|
||||
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
|
||||
? (mappedType as FilterEnum)
|
||||
: undefined;
|
||||
|
||||
const { requirementDoc } = useDocumentRequirement(filterData);
|
||||
const { data: myUploads } = useMyUploads();
|
||||
|
||||
const latestUploadIds = useMemo(() => {
|
||||
if (!requirementDoc?.items?.length) return [];
|
||||
return requirementDoc.items.map((doc: DocumentReq) => {
|
||||
const uploadsForDoc = myUploads?.filter(
|
||||
(u: any) => u.documentId === doc.id
|
||||
);
|
||||
if (uploadsForDoc?.length) {
|
||||
const latest = uploadsForDoc.reduce((prev: any, cur: any) =>
|
||||
new Date(prev.createdAt) > new Date(cur.createdAt) ? prev : cur
|
||||
);
|
||||
return {
|
||||
docId: doc.id,
|
||||
uploadId: latest.id,
|
||||
fileInfo: latest.fileInfo,
|
||||
};
|
||||
}
|
||||
return { docId: doc.id, uploadId: null, fileInfo: null };
|
||||
});
|
||||
}, [requirementDoc?.items, myUploads]);
|
||||
|
||||
const presignedQueries = useQueries({
|
||||
queries:
|
||||
latestUploadIds?.map(({ uploadId }: any) => ({
|
||||
queryKey: ["myUpload", uploadId],
|
||||
queryFn: () =>
|
||||
uploadId ? getMyUploadById(uploadId) : Promise.resolve(null),
|
||||
enabled: !!uploadId,
|
||||
})) || [],
|
||||
});
|
||||
|
||||
const prefills = useMemo(() => {
|
||||
const map: FilesRecord = {};
|
||||
if (!latestUploadIds?.length) return map;
|
||||
latestUploadIds.forEach(({ docId, fileInfo }, idx) => {
|
||||
const q = presignedQueries[idx];
|
||||
const presigned = q?.data?.presigned;
|
||||
map[docId] = presigned || fileInfo?.fileName || null;
|
||||
});
|
||||
return map;
|
||||
}, [latestUploadIds, presignedQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
Object.keys(uploadedFiles).length === 0 &&
|
||||
Object.keys(prefills).length > 0
|
||||
) {
|
||||
setUploadedFiles((prev) => ({ ...prefills, ...prev }));
|
||||
}
|
||||
}, [prefills, uploadedFiles, setUploadedFiles]);
|
||||
|
||||
const [modalOpen, setModalOpen] = React.useState(false);
|
||||
const [selectedDoc, setSelectedDoc] = React.useState<DocumentReq | null>(null);
|
||||
|
||||
// When user clicks "Upload" button
|
||||
const handleOpenModal = (doc: DocumentReq) => {
|
||||
setSelectedDoc(doc);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// When file is selected in modal
|
||||
const handleFileSelect = (file: File | null) => {
|
||||
if (selectedDoc) {
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[selectedDoc.id]: file,
|
||||
}));
|
||||
}
|
||||
setModalOpen(false);
|
||||
setSelectedDoc(null);
|
||||
};
|
||||
|
||||
|
||||
const handleRemove = (docId: string) => {
|
||||
setUploadedFiles((prev: FilesRecord) => ({
|
||||
...prev,
|
||||
[docId]: null,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
const [faydaValue, setFaydaValue] = useState<string>("");
|
||||
|
||||
// If registration type is individual, show Fayda input
|
||||
if (registrationType === "individual") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="font-medium">
|
||||
{t("registration.documents.fayda")}
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={faydaValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setFaydaValue(e.target.value)
|
||||
}
|
||||
placeholder={t("registration.documents.faydaMessage")}
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{requirementDoc?.items?.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">
|
||||
{t("registration.documents.noRequirements")}
|
||||
</div>
|
||||
) : (
|
||||
requirementDoc.items.map((doc) => {
|
||||
const current = uploadedFiles[doc.id];
|
||||
const isNewFile = current instanceof File;
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center gap-2 p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{doc.title?.en || doc.id}</div>
|
||||
{doc.description?.en && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{doc.description.en}
|
||||
</div>
|
||||
)}
|
||||
{current && (
|
||||
<div className="text-xs mt-1 flex items-center gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
<span
|
||||
className={
|
||||
isNewFile ? "text-blue-600" : "text-primary-600"
|
||||
}>
|
||||
{isNewFile
|
||||
? `Ready to upload: ${current.name}`
|
||||
: `Previously uploaded: ${current}`}
|
||||
</span>
|
||||
{isNewFile && (
|
||||
<span className="text-orange-500 ml-2">
|
||||
({t("registration.documents.willUploadOnSubmit")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!doc.isOptional && !current && (
|
||||
<div className="text-xs text-red-600 mt-1">
|
||||
{t("registration.documents.required")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{current && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(doc.id)}
|
||||
className="h-8 w-8 text-destructive">
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={current ? "outline" : "default"}
|
||||
className={current ? "" : "bg-primary-600"}
|
||||
onClick={() => handleOpenModal(doc)}>
|
||||
{current ? "Change" : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{modalOpen && selectedDoc && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<UploadFileModal
|
||||
value={uploadedFiles[selectedDoc.id] || null}
|
||||
multiple={false}
|
||||
accept={[
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".pdf",
|
||||
".dwg",
|
||||
".dxf",
|
||||
".dwt",
|
||||
".bak",
|
||||
".sv$",
|
||||
".dws",
|
||||
".mxd",
|
||||
".aprx",
|
||||
".rar",
|
||||
".zip",
|
||||
]}
|
||||
onChange={(file) => {
|
||||
handleFileSelect(
|
||||
file instanceof File
|
||||
? file
|
||||
: Array.isArray(file)
|
||||
? file[0]
|
||||
: null
|
||||
);
|
||||
}}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
setSelectedDoc(null);
|
||||
}}
|
||||
fileUploadFields={{
|
||||
requiredDocumentId: selectedDoc.id,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default UploadDocumentsStep;
|
||||
@@ -0,0 +1,514 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/common/ui/tooltip";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
|
||||
import {
|
||||
CloudUpload,
|
||||
Trash,
|
||||
FileType,
|
||||
X,
|
||||
Info,
|
||||
FileUp,
|
||||
Plus,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
import { FilePreview } from "./FilePreview";
|
||||
import { useToast } from "@/shared/common/ui/use-toast";
|
||||
import { MAX_FILE_SIZE_MB } from "@/shared/utils/max-file-size";
|
||||
import {
|
||||
getAllowedExtensionsFromAccept,
|
||||
getAllowedMimeTypesFromAccept,
|
||||
getFinalAllowedFileTypes,
|
||||
} from "@/shared/utils/get-final-allowed-file-types";
|
||||
import { ClientSideValidator } from "@/shared/services/validation/ClientSideValidator";
|
||||
import { FileUploadValidator } from "@/shared/services/validation/FileUploadValidator";
|
||||
|
||||
interface UploadFileModalProps {
|
||||
onChange: (
|
||||
file: File | File[] | null,
|
||||
status?: string,
|
||||
requiredDocumentId?: string
|
||||
) => void;
|
||||
value: File | File[] | null | string;
|
||||
fileUploadFields?: any;
|
||||
accept?: string[] | null;
|
||||
multiple?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const UploadFileModal = memo(
|
||||
({
|
||||
onChange,
|
||||
value,
|
||||
fileUploadFields,
|
||||
accept,
|
||||
multiple,
|
||||
onClose,
|
||||
}: UploadFileModalProps) => {
|
||||
const [file, setFile] = useState<File | File[] | null>(null);
|
||||
const [croppingFile, setCroppingFile] = useState<File | null>(null);
|
||||
const [croppedFile, setCroppedFile] = useState<File | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [fileErrorMessages, setFileErrorMessages] = useState<any[]>([]);
|
||||
|
||||
const openRef = useRef<HTMLInputElement>(null);
|
||||
const validatorRef = useRef(new ClientSideValidator());
|
||||
const { toast } = useToast();
|
||||
|
||||
const needsCropping = (file: File) => {
|
||||
const photoConfig = fileUploadFields?.photoConfiguration;
|
||||
const isPhotoConfigEnabled =
|
||||
photoConfig === true || photoConfig === "true";
|
||||
return (
|
||||
isPhotoConfigEnabled &&
|
||||
file.type.startsWith("image/") &&
|
||||
!["image/gif", "image/svg+xml"].includes(file.type)
|
||||
);
|
||||
};
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (newFile: File | File[] | null) => {
|
||||
setErrorMessage(null);
|
||||
setFileErrorMessages([]);
|
||||
|
||||
if (newFile) {
|
||||
const allowedMimeTypes = accept
|
||||
? getAllowedMimeTypesFromAccept(accept)
|
||||
: undefined;
|
||||
const allowedExtensions = accept
|
||||
? getAllowedExtensionsFromAccept(accept)
|
||||
: undefined;
|
||||
|
||||
if (Array.isArray(newFile)) {
|
||||
const validationErrors: any[] = [];
|
||||
|
||||
for (let index = 0; index < newFile.length; index++) {
|
||||
const item = newFile[index];
|
||||
|
||||
// Comprehensive file validation
|
||||
const basicValidation = FileUploadValidator.validateFile(item, {
|
||||
maxSizeMB: MAX_FILE_SIZE_MB,
|
||||
allowedMimeTypes:
|
||||
item.type && allowedMimeTypes?.length
|
||||
? allowedMimeTypes
|
||||
: undefined,
|
||||
allowedExtensions:
|
||||
allowedExtensions?.length ? allowedExtensions : undefined,
|
||||
});
|
||||
|
||||
if (!basicValidation.isValid) {
|
||||
validationErrors.push({
|
||||
fileIndex: index,
|
||||
error: basicValidation.error || "File validation failed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Client-side magic byte validation
|
||||
if (accept) {
|
||||
const clientValidation = await validatorRef.current.validateUpload(
|
||||
item,
|
||||
item.type,
|
||||
allowedMimeTypes || []
|
||||
);
|
||||
|
||||
if (!clientValidation.isValid) {
|
||||
validationErrors.push({
|
||||
fileIndex: index,
|
||||
error: clientValidation.error || "File validation failed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setFileErrorMessages(validationErrors);
|
||||
setErrorMessage(`File validation error`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Comprehensive file validation
|
||||
const basicValidation = FileUploadValidator.validateFile(newFile, {
|
||||
maxSizeMB: MAX_FILE_SIZE_MB,
|
||||
allowedMimeTypes:
|
||||
newFile.type && allowedMimeTypes?.length
|
||||
? allowedMimeTypes
|
||||
: undefined,
|
||||
allowedExtensions:
|
||||
allowedExtensions?.length ? allowedExtensions : undefined,
|
||||
});
|
||||
|
||||
if (!basicValidation.isValid) {
|
||||
setErrorMessage(basicValidation.error || "File validation failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (accept) {
|
||||
// Client-side magic byte validation
|
||||
const clientValidation = await validatorRef.current.validateUpload(
|
||||
newFile,
|
||||
newFile.type,
|
||||
allowedMimeTypes || []
|
||||
);
|
||||
|
||||
if (!clientValidation.isValid) {
|
||||
setErrorMessage(clientValidation.error || "File validation failed");
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (needsCropping(newFile)) {
|
||||
setCroppingFile(newFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the file and immediately call onChange to update parent
|
||||
const finalFile = multiple
|
||||
? [
|
||||
...(Array.isArray(file) ? file : []),
|
||||
...(Array.isArray(newFile) ? newFile : [newFile]).filter(Boolean),
|
||||
]
|
||||
: newFile;
|
||||
const cleanedFile = Array.isArray(finalFile)
|
||||
? finalFile.filter((f): f is File => f !== null) // removes nulls
|
||||
: finalFile;
|
||||
setFile(cleanedFile);
|
||||
|
||||
// Immediately notify parent about the file change
|
||||
if (cleanedFile) {
|
||||
onChange(
|
||||
cleanedFile,
|
||||
"selected",
|
||||
fileUploadFields?.requiredDocumentId
|
||||
);
|
||||
} else {
|
||||
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
|
||||
}
|
||||
},
|
||||
[fileUploadFields?.photoConfiguration, accept, multiple, file, onChange]
|
||||
);
|
||||
const handleClose = useCallback(() => {
|
||||
setCroppingFile(null);
|
||||
setFile(null);
|
||||
setCroppedFile(null);
|
||||
setErrorMessage(null);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
|
||||
|
||||
const handleCropComplete = useCallback(
|
||||
(croppedFile: File) => {
|
||||
setCroppingFile(null);
|
||||
setCroppedFile(croppedFile);
|
||||
setFile(croppedFile);
|
||||
onChange(croppedFile, "selected", fileUploadFields?.requiredDocumentId);
|
||||
},
|
||||
[onChange, fileUploadFields?.requiredDocumentId]
|
||||
);
|
||||
|
||||
const handleCancelCrop = useCallback(() => {
|
||||
setCroppingFile(null);
|
||||
setFile(null);
|
||||
setCroppedFile(null);
|
||||
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
|
||||
}, [onChange, fileUploadFields?.requiredDocumentId]);
|
||||
|
||||
const handleRemoveFile = useCallback(
|
||||
(index?: number) => {
|
||||
if (multiple && index !== undefined) {
|
||||
const updatedFiles = Array.isArray(file)
|
||||
? file.filter((_, i) => i !== index)
|
||||
: null;
|
||||
setFile(updatedFiles);
|
||||
setFileErrorMessages(
|
||||
fileErrorMessages.filter((x) => x?.fileIndex !== index)
|
||||
);
|
||||
|
||||
if (updatedFiles && updatedFiles.length > 0) {
|
||||
onChange(
|
||||
updatedFiles,
|
||||
"selected",
|
||||
fileUploadFields?.requiredDocumentId
|
||||
);
|
||||
} else {
|
||||
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
|
||||
}
|
||||
} else {
|
||||
setFile(null);
|
||||
setCroppedFile(null);
|
||||
setErrorMessage(null);
|
||||
onChange(null, "removed", fileUploadFields?.requiredDocumentId);
|
||||
}
|
||||
},
|
||||
[
|
||||
file,
|
||||
multiple,
|
||||
fileErrorMessages,
|
||||
onChange,
|
||||
fileUploadFields?.requiredDocumentId,
|
||||
]
|
||||
);
|
||||
|
||||
const isMultipleFileAttached = useMemo(
|
||||
() => file && Array.isArray(file),
|
||||
[file]
|
||||
);
|
||||
|
||||
const isSomeFileAttached = useMemo(() => {
|
||||
if (isMultipleFileAttached) {
|
||||
return Array.isArray(file) ? file.length > 0 : false;
|
||||
}
|
||||
return !!file;
|
||||
}, [file, isMultipleFileAttached]);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md relative">
|
||||
<CardHeader>
|
||||
<CardTitle>Upload File{multiple && "s"}</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-4 top-4 h-6 w-6"
|
||||
onClick={handleClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{croppingFile ? (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">Crop Image</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCancelCrop}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Note</AlertTitle>
|
||||
<AlertDescription>
|
||||
This image will be cropped to a 3:4 aspect ratio. Please
|
||||
adjust the crop area accordingly.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="p-4 border rounded-lg bg-muted">
|
||||
<ImageCropper
|
||||
file={croppingFile}
|
||||
onCropComplete={handleCropComplete}
|
||||
aspectRatio={3 / 4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={isSomeFileAttached ? "hidden" : "block"}>
|
||||
<div className="flex justify-center">
|
||||
<CloudUpload className="h-12 w-12" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-center mt-4">
|
||||
Upload File{multiple && "s"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isSomeFileAttached && (
|
||||
<div className="relative flex flex-col items-center gap-4 p-4 border rounded-md">
|
||||
<div
|
||||
className={
|
||||
isMultipleFileAttached
|
||||
? "max-w-[30rem] max-h-[20rem] overflow-auto"
|
||||
: "h-40 w-40"
|
||||
}>
|
||||
{file instanceof File &&
|
||||
file?.type === "application/pdf" ? (
|
||||
<div className="flex flex-col items-center justify-center pt-16">
|
||||
<FileType size={60} className="h-16 w-16" />
|
||||
</div>
|
||||
) : isMultipleFileAttached ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.isArray(file) &&
|
||||
Array.from(file)?.map((f, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
{f?.type === "application/pdf" ? (
|
||||
<div className="flex flex-col items-center justify-center pt-16">
|
||||
<FileType size={60} className="h-16 w-16" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`border rounded-md ${
|
||||
Array.from(file)?.length == 1
|
||||
? "h-60"
|
||||
: "h-48"
|
||||
} overflow-auto`}>
|
||||
<FilePreview file={f} type="image" />
|
||||
</div>
|
||||
)}
|
||||
<p className="w-36 truncate">{f?.name}</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveFile(i)}>
|
||||
<Trash className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove</TooltipContent>
|
||||
</Tooltip>
|
||||
{fileErrorMessages?.find(
|
||||
(item) => item.fileIndex === i
|
||||
)?.error && (
|
||||
<Alert variant="destructive">
|
||||
<X className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{
|
||||
fileErrorMessages?.find(
|
||||
(item) => item.fileIndex === i
|
||||
)?.error
|
||||
}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full overflow-auto">
|
||||
<FilePreview
|
||||
file={croppedFile || (file as File)}
|
||||
type="image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMultipleFileAttached &&
|
||||
(croppedFile || file) instanceof File && (
|
||||
<p className="w-36 truncate">
|
||||
{croppedFile
|
||||
? croppedFile.name
|
||||
: file instanceof File
|
||||
? file.name
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
{!isMultipleFileAttached && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8"
|
||||
onClick={() => handleRemoveFile()}>
|
||||
<Trash className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* File Selected Indicator */}
|
||||
<div className="flex items-center gap-2 text-primary-600">
|
||||
<Check className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">File selected</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
You can attach{" "}
|
||||
{accept
|
||||
?.map((item) => `${item} files (max ${MAX_FILE_SIZE_MB}MB)`)
|
||||
?.join(", ")}
|
||||
</p>
|
||||
{multiple && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm text-primary">
|
||||
You can attach multiple files
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Input
|
||||
type="file"
|
||||
accept={
|
||||
accept
|
||||
? getFinalAllowedFileTypes(accept)
|
||||
: "image/png,image/jpeg,application/pdf"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
if (multiple) {
|
||||
handleChange(Array.from(files));
|
||||
} else {
|
||||
handleChange(files[0]);
|
||||
}
|
||||
}}
|
||||
multiple={multiple}
|
||||
className="hidden"
|
||||
ref={openRef}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openRef.current?.click()}
|
||||
className="w-full">
|
||||
{isSomeFileAttached ? (
|
||||
multiple ? (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add more files
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Change file
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Select file{multiple ? "s" : ""}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* REMOVED: Continue button - file selection is now immediate */}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<X className="h-4 w-4" />
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UploadFileModal.displayName = "UploadFileModal";
|
||||
@@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
Building2,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Card } from "@/shared/common/ui/card";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "react-router-dom";
|
||||
import VerifyOtp from "../verifyOTP";
|
||||
import { useTradeLicenseVerification } from "@/external-portal/hooks/useTradeLicenseVerification";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import {
|
||||
startExternalPortalFaydaAuth,
|
||||
} from "@/shared/utils/faydaOidc";
|
||||
import {
|
||||
EtradeBusinessLicenseOption,
|
||||
fetchRegistrationByTin,
|
||||
mapEtradeBusinessLicenses,
|
||||
resolveEtradeLanguage,
|
||||
TinRegistrationNotFoundError,
|
||||
} from "@/complaints/services/etradeTinService";
|
||||
import {
|
||||
isValidLicenseNo,
|
||||
normalizeLicenseNo,
|
||||
normalizeTin,
|
||||
} from "@/external-portal/utils/etradeValidation";
|
||||
import {
|
||||
clearEtradeLicenseNo,
|
||||
storeEtradeLicenseNo,
|
||||
} from "@/external-portal/utils/etradeAuthStorage";
|
||||
|
||||
type OrgStep = "trade_license" | null;
|
||||
type TradeLicenseStep = "tin" | "select_license";
|
||||
|
||||
export default function UserTypeSelection() {
|
||||
const [orgStep, setOrgStep] = useState<OrgStep>(null);
|
||||
const [tradeLicenseStep, setTradeLicenseStep] =
|
||||
useState<TradeLicenseStep>("tin");
|
||||
const [tin, setTin] = useState("");
|
||||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [licenseOptions, setLicenseOptions] = useState<
|
||||
EtradeBusinessLicenseOption[]
|
||||
>([]);
|
||||
const [selectedLicenseNumber, setSelectedLicenseNumber] = useState("");
|
||||
const [tradeLicenseNumber, setTradeLicenseNumber] = useState("");
|
||||
const [tinError, setTinError] = useState<string | null>(null);
|
||||
const [isVerifyingTin, setIsVerifyingTin] = useState(false);
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showVerifyOtp, setShowVerifyOtp] = useState(false);
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const { registerTradeLicense, isRegistering } = useTradeLicenseVerification();
|
||||
|
||||
const resetTradeLicenseFlow = () => {
|
||||
setTradeLicenseStep("tin");
|
||||
setTin("");
|
||||
setOrganizationName("");
|
||||
setLicenseOptions([]);
|
||||
setSelectedLicenseNumber("");
|
||||
setTradeLicenseNumber("");
|
||||
setTinError(null);
|
||||
setShowVerifyOtp(false);
|
||||
clearEtradeLicenseNo();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setOrgStep(null);
|
||||
resetTradeLicenseFlow();
|
||||
}, []);
|
||||
|
||||
const onVerifyComplete = () => {
|
||||
toast.success(t("registration.etrade.registrationSuccess"));
|
||||
};
|
||||
|
||||
const registerWithLicense = async (licenseNumber: string) => {
|
||||
const normalizedLicense = normalizeLicenseNo(licenseNumber);
|
||||
if (!isValidLicenseNo(normalizedLicense)) return;
|
||||
|
||||
setTradeLicenseNumber(normalizedLicense);
|
||||
storeEtradeLicenseNo(normalizedLicense);
|
||||
await registerTradeLicense({
|
||||
tin: normalizeTin(tin),
|
||||
licenseNo: normalizedLicense,
|
||||
});
|
||||
setShowVerifyOtp(true);
|
||||
};
|
||||
|
||||
const startETradeAuth = () => {
|
||||
resetTradeLicenseFlow();
|
||||
setOrgStep("trade_license");
|
||||
};
|
||||
|
||||
const handleTinVerify = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setTinError(null);
|
||||
|
||||
const normalizedTin = tin.trim();
|
||||
if (!normalizedTin) {
|
||||
setTinError(t("complaint.tin.tinRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^\d{10}$/.test(normalizedTin)) {
|
||||
setTinError(t("complaint.tin.tinInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsVerifyingTin(true);
|
||||
const registration = await fetchRegistrationByTin(
|
||||
normalizedTin,
|
||||
resolveEtradeLanguage(i18n.language),
|
||||
);
|
||||
const businesses = mapEtradeBusinessLicenses(
|
||||
registration,
|
||||
resolveEtradeLanguage(i18n.language),
|
||||
);
|
||||
|
||||
if (businesses.length === 0) {
|
||||
setTinError(t("registration.etrade.noLicensesFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const companyName = String(
|
||||
registration.BusinessName ??
|
||||
registration.businessName ??
|
||||
registration.BusinessNameAmh ??
|
||||
registration.businessNameAmh ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
setOrganizationName(companyName);
|
||||
setLicenseOptions(businesses);
|
||||
setSelectedLicenseNumber(businesses[0].licenseNumber);
|
||||
setTradeLicenseStep("select_license");
|
||||
} catch (error) {
|
||||
if (error instanceof TinRegistrationNotFoundError) {
|
||||
setTinError(t("complaint.tin.notFound"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === "ETRADE_REFERER_REJECTED"
|
||||
) {
|
||||
setTinError(t("complaint.tin.proxyError"));
|
||||
return;
|
||||
}
|
||||
handleError(error);
|
||||
} finally {
|
||||
setIsVerifyingTin(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLicenseSelectionSubmit = async () => {
|
||||
if (!selectedLicenseNumber) return;
|
||||
|
||||
try {
|
||||
await registerWithLicense(selectedLicenseNumber);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const authOptions = [
|
||||
{
|
||||
provider: "fayda" as const,
|
||||
title: t("registration.auth.continueWithFayda"),
|
||||
description: t("registration.auth.faydaDescription"),
|
||||
icon: UserRound,
|
||||
onClick: () => startExternalPortalFaydaAuth(),
|
||||
},
|
||||
{
|
||||
provider: "etrade" as const,
|
||||
title: t("registration.auth.continueWithEtrade"),
|
||||
description: t("registration.auth.etradeDescription"),
|
||||
icon: Building2,
|
||||
onClick: () => startETradeAuth(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-primary-50 p-4">
|
||||
<div className="w-full max-w-3xl">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center gap-2 mb-4">
|
||||
<Building2 className="size-8 text-primary-600" />
|
||||
<h1 className="text-3xl font-bold text-primary-900">
|
||||
{t("registration.auth.portalTitle")}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-primary-700 text-lg">
|
||||
{t("registration.auth.selectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!orgStep && (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-8">
|
||||
{authOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Card
|
||||
key={option.provider}
|
||||
className="p-6 cursor-pointer border-2 border-primary-200 bg-white transition-all hover:border-primary-400"
|
||||
onClick={option.onClick}>
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="p-4 rounded-full bg-primary-100 text-primary-600">
|
||||
<Icon className="size-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2 text-primary-900">
|
||||
{option.title}
|
||||
</h2>
|
||||
<p className="text-primary-700 text-sm">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm font-medium text-primary-700 hover:text-primary-900 hover:underline">
|
||||
{t("registration.auth.employeeLogin")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Link
|
||||
to="/external-portal/signup/manual"
|
||||
className="text-sm font-medium text-primary-700 hover:text-primary-900 hover:underline">
|
||||
{t("registration.auth.manualOrgSignup")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{orgStep === "trade_license" && (
|
||||
<div className="mb-6 flex items-center justify-center gap-2 text-sm">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 ${
|
||||
tradeLicenseStep === "tin" && !showVerifyOtp
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-primary-100 text-primary-700"
|
||||
}`}>
|
||||
1. {t("registration.etrade.stepTin")}
|
||||
</span>
|
||||
<span className="text-primary-400">→</span>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 ${
|
||||
tradeLicenseStep === "select_license" && !showVerifyOtp
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-primary-100 text-primary-700"
|
||||
}`}>
|
||||
2. {t("registration.etrade.stepLicense")}
|
||||
</span>
|
||||
<span className="text-primary-400">→</span>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 ${
|
||||
showVerifyOtp
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-primary-100 text-primary-700"
|
||||
}`}>
|
||||
3. {t("registration.etrade.stepOtp")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgStep === "trade_license" &&
|
||||
(!showVerifyOtp ? (
|
||||
tradeLicenseStep === "tin" ? (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold text-primary-900">
|
||||
{t("registration.auth.continueWithEtrade")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-primary-700">
|
||||
{t("registration.etrade.enterTinDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{tinError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
|
||||
<p className="text-sm text-red-700">{tinError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleTinVerify} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="etrade-tin">
|
||||
{t("complaint.tin.tinLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="etrade-tin"
|
||||
inputMode="numeric"
|
||||
pattern="\d*"
|
||||
maxLength={10}
|
||||
value={tin}
|
||||
onChange={(event) =>
|
||||
setTin(
|
||||
event.target.value.replace(/\D/g, "").slice(0, 10),
|
||||
)
|
||||
}
|
||||
placeholder={t("complaint.tin.tinPlaceholder")}
|
||||
className="border-primary-300 focus:ring-primary-500 font-mono text-lg tracking-wide"
|
||||
autoComplete="off"
|
||||
disabled={isVerifyingTin}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-primary-600">
|
||||
{t("complaint.tin.tinHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOrgStep(null)}
|
||||
className="border-primary-400 text-primary-700 hover:bg-primary-100"
|
||||
disabled={isVerifyingTin}>
|
||||
{t("registration.etrade.back")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isVerifyingTin || tin.trim().length !== 10}
|
||||
className="bg-primary-600 hover:bg-primary-700 text-white">
|
||||
{isVerifyingTin ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("complaint.tin.verifying")}
|
||||
</span>
|
||||
) : (
|
||||
t("complaint.tin.verify")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold text-primary-900">
|
||||
{t("registration.etrade.selectLicenseTitle")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-primary-700">
|
||||
{t("registration.etrade.selectLicenseDescription")}
|
||||
</p>
|
||||
{organizationName && (
|
||||
<p className="mt-2 text-sm font-medium text-primary-900">
|
||||
{organizationName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[24rem] space-y-3 overflow-y-auto pr-1">
|
||||
{licenseOptions.map((option) => {
|
||||
const isSelected =
|
||||
selectedLicenseNumber === option.licenseNumber;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={option.mainGuid}
|
||||
className={`cursor-pointer border-2 p-4 transition-all ${
|
||||
isSelected
|
||||
? "border-primary-500 bg-primary-50 ring-2 ring-primary-200"
|
||||
: "border-primary-200 hover:border-primary-300 bg-white"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setSelectedLicenseNumber(option.licenseNumber)
|
||||
}>
|
||||
<div className="space-y-2 text-left">
|
||||
<p className="font-semibold text-primary-900">
|
||||
{option.tradeName}
|
||||
</p>
|
||||
<p className="text-sm text-primary-700">
|
||||
<span className="font-medium">
|
||||
{t("registration.etrade.licenseNumber")}:
|
||||
</span>{" "}
|
||||
<span className="font-mono">
|
||||
{option.licenseNumber}
|
||||
</span>
|
||||
</p>
|
||||
{option.activities.length > 0 && (
|
||||
<p className="text-sm text-primary-600">
|
||||
<span className="font-medium">
|
||||
{t("registration.etrade.activity")}:
|
||||
</span>{" "}
|
||||
{option.activities.join("; ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTradeLicenseStep("tin");
|
||||
setSelectedLicenseNumber("");
|
||||
setLicenseOptions([]);
|
||||
}}
|
||||
className="border-primary-400 text-primary-700 hover:bg-primary-100"
|
||||
disabled={isRegistering}>
|
||||
{t("registration.etrade.back")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLicenseSelectionSubmit}
|
||||
disabled={!selectedLicenseNumber || isRegistering}
|
||||
className="bg-primary-600 hover:bg-primary-700 text-white">
|
||||
{isRegistering ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("registration.etrade.loading")}
|
||||
</span>
|
||||
) : (
|
||||
t("registration.etrade.continue")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<VerifyOtp
|
||||
licenseNo={tradeLicenseNumber}
|
||||
onComplete={onVerifyComplete}
|
||||
isEtradeVerification={true}
|
||||
isExternalOrg={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { PixelCrop } from "react-image-crop";
|
||||
|
||||
export async function canvasPreview(
|
||||
image: HTMLImageElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
crop: PixelCrop,
|
||||
scale = 1,
|
||||
rotate = 0
|
||||
) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("No 2d context");
|
||||
}
|
||||
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
|
||||
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
|
||||
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
const cropX = crop.x * scaleX;
|
||||
const cropY = crop.y * scaleY;
|
||||
const cropWidth = crop.width * scaleX;
|
||||
const cropHeight = crop.height * scaleY;
|
||||
|
||||
const rotateRads = rotate * (Math.PI / 180);
|
||||
const centerX = image.naturalWidth / 2;
|
||||
const centerY = image.naturalHeight / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(-cropX, -cropY);
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(rotateRads);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
ctx.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState } from "react";
|
||||
import Header from "@/layout/components/Header";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useToast } from "@/shared/common/ui/use-toast";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
|
||||
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
getMyUploadById,
|
||||
LatestUploadInfo,
|
||||
submitApplication,
|
||||
} from "@/external-portal/services/portalOutgoingService";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import { FilterEnum } from "@/shared/services/organizationsService";
|
||||
|
||||
import { UploadFileModal } from "./UploadFileModal";
|
||||
import { useUser } from "@/shared/context/UserContext";
|
||||
|
||||
interface LocalizedText {
|
||||
am: string;
|
||||
en: string;
|
||||
}
|
||||
type Upload = {
|
||||
uploadId: string;
|
||||
// add other fields if needed
|
||||
};
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
title: LocalizedText;
|
||||
description: LocalizedText;
|
||||
key: string;
|
||||
type: string;
|
||||
order: number;
|
||||
isActive: boolean;
|
||||
isOptional: boolean;
|
||||
}
|
||||
|
||||
const UploadSteps = () => {
|
||||
const { toast } = useToast();
|
||||
const localized = useLocalizedName();
|
||||
|
||||
const userDetails = useUser();
|
||||
const filterDataStr = userDetails?.userType;
|
||||
const mappedType =
|
||||
filterDataStr === "external_organization" ? "organization" : filterDataStr;
|
||||
|
||||
const filterData: FilterEnum | undefined =
|
||||
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
|
||||
? (mappedType as FilterEnum)
|
||||
: undefined;
|
||||
|
||||
// Fetch required docs
|
||||
const { requirementDoc } = useDocumentRequirement(filterData);
|
||||
const { data: myUploads } = useMyUploads();
|
||||
|
||||
// Step 1: Collect latest upload IDs for each document
|
||||
const latestUploadIds = (requirementDoc.items ?? []).map((doc) => {
|
||||
const uploadsForDoc = (myUploads ?? []).filter(
|
||||
(u) => u.documentId === doc.id
|
||||
);
|
||||
|
||||
if (uploadsForDoc.length) {
|
||||
const latestUpload = uploadsForDoc.reduce((prev, current) =>
|
||||
new Date(prev.createdAt) > new Date(current.createdAt) ? prev : current
|
||||
);
|
||||
|
||||
return {
|
||||
docId: doc.id,
|
||||
uploadId: latestUpload.id,
|
||||
fileInfo: latestUpload.fileInfo,
|
||||
};
|
||||
}
|
||||
|
||||
return { docId: doc.id, uploadId: null, fileInfo: null };
|
||||
});
|
||||
// Step 2: Query presigned URLs for latest uploads
|
||||
const queriesToRun = (latestUploadIds as LatestUploadInfo[]).filter(
|
||||
({ uploadId }) => !!uploadId
|
||||
);
|
||||
const uploadQueries = useQueries({
|
||||
queries: queriesToRun.map(({ uploadId }) => ({
|
||||
queryKey: ["myUpload", uploadId],
|
||||
queryFn: () => getMyUploadById(uploadId!),
|
||||
})),
|
||||
});
|
||||
|
||||
// Step 3: Build files record
|
||||
const filesRecord: Record<string, File | string | null> =
|
||||
latestUploadIds.reduce((acc, { docId, fileInfo }, idx) => {
|
||||
const queryIndex = queriesToRun.findIndex(
|
||||
(q) => q.uploadId === latestUploadIds[idx].uploadId
|
||||
);
|
||||
const uploadData =
|
||||
queryIndex >= 0 ? uploadQueries[queryIndex]?.data : undefined;
|
||||
acc[docId] = uploadData?.presigned || fileInfo?.fileName || null;
|
||||
return acc;
|
||||
}, {} as Record<string, File | string | null>);
|
||||
|
||||
// State for modal and files
|
||||
const [uploadedFiles, setUploadedFiles] = useState(filesRecord);
|
||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const handleOpenModal = (doc: Document) => {
|
||||
setSelectedDoc(doc);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUploadComplete = (file: File | string | null) => {
|
||||
if (selectedDoc) {
|
||||
const newFiles = {
|
||||
...uploadedFiles,
|
||||
[selectedDoc.id]: file,
|
||||
};
|
||||
setUploadedFiles(newFiles);
|
||||
setSelectedDoc(null);
|
||||
setModalOpen(false);
|
||||
|
||||
if (file) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description:
|
||||
"File uploaded successfully. You will be notified of your status via SMS shortly.",
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const response = await submitApplication();
|
||||
if (!response) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "User ID is missing. Cannot submit application.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description:
|
||||
"You have submitted your application and will be notified of the status. Thank you!",
|
||||
variant: "default",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to submit application.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Header />
|
||||
|
||||
<div className="p-4 bg-white shadow rounded-lg w-full">
|
||||
<h2 className="text-lg font-semibold text-center">Upload Documents</h2>
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
Please upload the required documents for verification.
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-5xl mx-auto mt-4 space-y-4">
|
||||
{requirementDoc?.items.length === 0 && (
|
||||
<div>No Document Requirements available.</div>
|
||||
)}
|
||||
|
||||
{requirementDoc?.items.map((doc: Document) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between p-4 border rounded-md">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{localized(doc.title)}</span>
|
||||
{doc.description && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{localized(doc.description)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{uploadedFiles[doc.id] && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const file = uploadedFiles[doc.id];
|
||||
if (!file) return;
|
||||
|
||||
if (typeof file === "string") {
|
||||
window.open(file, "_blank");
|
||||
} else if (file instanceof File) {
|
||||
const url = URL.createObjectURL(file);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
}}>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary-600"
|
||||
onClick={() => handleOpenModal(doc)}>
|
||||
{uploadedFiles[doc.id] ? "Replace" : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary-600 w-full"
|
||||
disabled={
|
||||
Object.values(uploadedFiles).filter((file) => file !== null)
|
||||
.length === 0
|
||||
}
|
||||
onClick={handleSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Overlay */}
|
||||
{modalOpen && selectedDoc && (
|
||||
<UploadFileModal
|
||||
value={uploadedFiles[selectedDoc.id] || null}
|
||||
multiple={false}
|
||||
accept={[
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".pdf",
|
||||
".dwg",
|
||||
".dxf",
|
||||
".dwt",
|
||||
".bak",
|
||||
".sv$",
|
||||
".dws",
|
||||
".mxd",
|
||||
".aprx",
|
||||
".rar",
|
||||
".zip",
|
||||
]}
|
||||
onChange={(file) => {
|
||||
if (file === null) handleUploadComplete(null);
|
||||
else if (file instanceof File) handleUploadComplete(file);
|
||||
else if (Array.isArray(file) && file[0])
|
||||
handleUploadComplete(file[0]);
|
||||
}}
|
||||
fileUploadFields={{
|
||||
requiredDocumentId: selectedDoc.id,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadSteps;
|
||||
@@ -0,0 +1,26 @@
|
||||
// components/useDebounceEffect.ts
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useDebounceEffect(
|
||||
fn: () => void,
|
||||
waitTime: number,
|
||||
deps?: any[]
|
||||
) {
|
||||
const timeoutRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
fn();
|
||||
}, waitTime);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Mail, User, Phone, Text, Building } from "lucide-react";
|
||||
import { useToast } from "@/shared/common/ui/use-toast";
|
||||
|
||||
import { userSchema, UserFormValues } from "./outgoing/formSchema";
|
||||
import { useRegisterExternalPortalUser } from "../hooks/useRegisterExternalPortalUser";
|
||||
|
||||
import { useTenantConfig } from "@/layout/components/TenantConfig";
|
||||
import { useDocumentRequirement } from "@/shared/hooks/useOrganizationReport";
|
||||
import { useMyUploads } from "@/external-portal/hooks/useMyUpload";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
getMyUploadById,
|
||||
submitApplication,
|
||||
} from "@/external-portal/services/portalOutgoingService";
|
||||
import { FilterEnum } from "@/shared/services/organizationsService";
|
||||
import { UploadFileModal } from "./Registration/UploadFileModal";
|
||||
|
||||
|
||||
|
||||
type LocalizedText = { am: string; en: string };
|
||||
interface Document {
|
||||
id: string;
|
||||
title: LocalizedText;
|
||||
description: LocalizedText;
|
||||
key: string;
|
||||
type: string;
|
||||
order: number;
|
||||
isActive: boolean;
|
||||
isOptional: boolean;
|
||||
}
|
||||
|
||||
export default function SignupWithUploads() {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
const { registerExternalPortalUser, isRegistering } =
|
||||
useRegisterExternalPortalUser();
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
username: "",
|
||||
phoneNumber: "",
|
||||
userType: "external_organization",
|
||||
name: { am: "", en: "" },
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
// watch userType so we can load document requirements for that type
|
||||
const watchedUserType = form.watch("userType") || "external_organization";
|
||||
|
||||
// Map userType to FilterEnum values (same logic you used elsewhere)
|
||||
const mappedType =
|
||||
watchedUserType === "external_organization"
|
||||
? "organization"
|
||||
: watchedUserType;
|
||||
|
||||
const filterData: FilterEnum | undefined =
|
||||
mappedType && Object.values(FilterEnum).includes(mappedType as FilterEnum)
|
||||
? (mappedType as FilterEnum)
|
||||
: undefined;
|
||||
|
||||
// fetch requirement doc for the watched user type
|
||||
const { requirementDoc } = useDocumentRequirement(filterData);
|
||||
|
||||
// fetch user's previous uploads (if any) and prefill the upload list
|
||||
const { data: myUploads } = useMyUploads();
|
||||
|
||||
// build latest upload ids per requirement doc (like your UploadSteps logic)
|
||||
const latestUploadIds = (requirementDoc.items ?? []).map((doc) => {
|
||||
const uploadsForDoc = (myUploads ?? []).filter(
|
||||
(u) => u.documentId === doc.id
|
||||
);
|
||||
|
||||
if (uploadsForDoc.length) {
|
||||
const latestUpload = uploadsForDoc.reduce((prev, current) =>
|
||||
new Date(prev.createdAt) > new Date(current.createdAt) ? prev : current
|
||||
);
|
||||
|
||||
return {
|
||||
docId: doc.id,
|
||||
uploadId: latestUpload.id,
|
||||
fileInfo: latestUpload.fileInfo,
|
||||
};
|
||||
}
|
||||
|
||||
return { docId: doc.id, uploadId: null, fileInfo: null };
|
||||
});
|
||||
|
||||
// query presigned urls for each latest upload
|
||||
const uploadQueries = useQueries({
|
||||
queries:
|
||||
latestUploadIds.map(({ uploadId }:any) => ({
|
||||
queryKey: ["myUpload", uploadId],
|
||||
queryFn: () => getMyUploadById(uploadId as string),
|
||||
enabled: !!uploadId,
|
||||
})) || [],
|
||||
});
|
||||
|
||||
// build filesRecord map: docId -> presignedURL | fileName | null
|
||||
const filesRecord = latestUploadIds.reduce(
|
||||
(acc: Record<string, File | string | null>, { docId, fileInfo }: any, idx:any) => {
|
||||
const uploadData = uploadQueries[idx]?.data as
|
||||
| { data?: { presigned?: string } }
|
||||
| undefined;
|
||||
const presigned = uploadData?.data?.presigned;
|
||||
acc[docId] = presigned || fileInfo?.fileName || null;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, File | string | null>
|
||||
);
|
||||
|
||||
// local state to track uploaded files while user interacts on this page
|
||||
const [uploadedFiles, setUploadedFiles] =
|
||||
useState<Record<string, File | string | null>>(filesRecord);
|
||||
|
||||
// modal state for UploadFileModal
|
||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const openUploadModal = (doc: Document) => {
|
||||
setSelectedDoc(doc);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUploadComplete = (file: File | null | string) => {
|
||||
if (!selectedDoc) return;
|
||||
const newFiles = { ...uploadedFiles, [selectedDoc.id]: file };
|
||||
setUploadedFiles(newFiles);
|
||||
setSelectedDoc(null);
|
||||
setModalOpen(false);
|
||||
|
||||
if (file) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description:
|
||||
"File uploaded successfully. You will be notified of your status via SMS shortly.",
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Signup submit: registers the user then triggers submitApplication (if desired)
|
||||
const onSubmit = async (values: UserFormValues) => {
|
||||
try {
|
||||
// 1) Register user (existing hook)
|
||||
await registerExternalPortalUser(values);
|
||||
|
||||
// 2) After registration, optionally submit an application.
|
||||
// Your uploadDocuments hook (used in UploadFileModal) already uploads
|
||||
// files during the modal upload action. submitApplication() finalizes/sends the application.
|
||||
try {
|
||||
await submitApplication();
|
||||
} catch (err) {
|
||||
// non-blocking: show toast but continue to navigate to verify OTP
|
||||
console.error("submitApplication error", err);
|
||||
toast({
|
||||
title: "Warning",
|
||||
description:
|
||||
"Account created but application submission failed. You can retry later.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
// 3) Navigate to verify-otp with query params (same behavior you had)
|
||||
const params = new URLSearchParams();
|
||||
if (values.email) params.set("email", values.email.trim());
|
||||
if (values.phoneNumber) {
|
||||
const normalizedPhone = values.phoneNumber.trim().replace(/^0/, "+251");
|
||||
params.set("phone", normalizedPhone);
|
||||
}
|
||||
navigate(`/external-portal/verify-otp?${params.toString()}`);
|
||||
} catch (error) {
|
||||
console.error("Signup error:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to create account. Please check your details.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// helper localizer — re-use your localized name hook if needed (not provided here).
|
||||
const localized = (t: LocalizedText) => t?.en || t?.am || "";
|
||||
|
||||
// count uploaded files
|
||||
const attachedCount = useMemo(
|
||||
() => Object.values(uploadedFiles).filter((f) => f !== null).length,
|
||||
[uploadedFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-6xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col md:flex-row h-full max-h-[900px]">
|
||||
{/* Left: Signup form */}
|
||||
<div className="md:w-1/2 w-full p-8 md:p-12 flex flex-col gap-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<img
|
||||
src={tenantConfig.logo || "/assets/smart-office-logo.svg"}
|
||||
alt={tenantConfig.appName}
|
||||
className="h-10 object-contain"
|
||||
/>
|
||||
<span className="ml-2 text-xl font-semibold text-gray-800">
|
||||
{tenantConfig.appName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-1">Create Account</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Create your account and upload required documents on the right.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="mt-4 flex-1 flex flex-col">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...form.register("email")}
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
className="pl-10 h-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...form.register("username")}
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
className="pl-10 h-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...form.register("phoneNumber")}
|
||||
type="tel"
|
||||
placeholder="Phone Number"
|
||||
className="pl-10 h-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...form.register("name.en")}
|
||||
type="text"
|
||||
placeholder="Name (English)"
|
||||
className="pl-10 h-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
{...form.register("name.am")}
|
||||
type="text"
|
||||
placeholder="Name (Amharic)"
|
||||
className="pl-10 h-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* userType control — you may already have this as a select / radio; using plain input for brevity */}
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Account type</label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<label
|
||||
className={`px-3 py-2 rounded-md border ${
|
||||
form.getValues("userType") === "external_organization"
|
||||
? "bg-primary-50 border-primary"
|
||||
: ""
|
||||
}`}>
|
||||
<input
|
||||
type="radio"
|
||||
{...form.register("userType")}
|
||||
value="external_organization"
|
||||
className="mr-2"
|
||||
/>
|
||||
Organization
|
||||
</label>
|
||||
<label
|
||||
className={`px-3 py-2 rounded-md border ${
|
||||
form.getValues("userType") === "individual"
|
||||
? "bg-primary-50 border-primary"
|
||||
: ""
|
||||
}`}>
|
||||
<input
|
||||
type="radio"
|
||||
{...form.register("userType")}
|
||||
value="individual"
|
||||
className="mr-2"
|
||||
/>
|
||||
Individual
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-primary hover:bg-primary-700 text-white"
|
||||
disabled={isRegistering}>
|
||||
{isRegistering
|
||||
? "Creating Account..."
|
||||
: "Create account & submit"}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-3">
|
||||
Already have an account?{" "}
|
||||
<a href="/login" className="text-primary">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Vertical divider */}
|
||||
<div className="hidden md:block w-px bg-gray-200" />
|
||||
|
||||
{/* Right: Upload document area (changes by userType) */}
|
||||
<div className="md:w-1/2 w-full p-6 md:p-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
Upload Required Documents
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Required documents for:{" "}
|
||||
<span className="font-medium">{mappedType}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{attachedCount} attached
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-[62vh] overflow-auto">
|
||||
{requirementDoc?.items?.length === 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
No document requirements for this account type.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirementDoc?.items?.map((doc: Document) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between p-3 border rounded-md">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{localized(doc.title)}</span>
|
||||
{doc.description && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{localized(doc.description)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{uploadedFiles[doc.id] && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const file = uploadedFiles[doc.id];
|
||||
if (!file) return;
|
||||
if (typeof file === "string") {
|
||||
window.open(file, "_blank");
|
||||
} else if (file instanceof File) {
|
||||
const url = URL.createObjectURL(file);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
}}>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary-600"
|
||||
onClick={() => openUploadModal(doc)}>
|
||||
{uploadedFiles[doc.id] ? "Replace" : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Tip: You can upload files now or complete your account and upload
|
||||
later.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload modal */}
|
||||
{modalOpen && selectedDoc && (
|
||||
<UploadFileModal
|
||||
value={uploadedFiles[selectedDoc.id] || null}
|
||||
multiple={false}
|
||||
accept={[
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".pdf",
|
||||
".dwg",
|
||||
".dxf",
|
||||
".dwt",
|
||||
".bak",
|
||||
".sv$",
|
||||
".dws",
|
||||
".mxd",
|
||||
".aprx",
|
||||
".rar",
|
||||
".zip",
|
||||
]}
|
||||
onChange={(file) => {
|
||||
if (file === null) handleUploadComplete(null);
|
||||
else if (file instanceof File) handleUploadComplete(file);
|
||||
else if (Array.isArray(file) && file[0])
|
||||
handleUploadComplete(file[0]);
|
||||
}}
|
||||
fileUploadFields={{
|
||||
requiredDocumentId: selectedDoc.id,
|
||||
// optionally: photoConfiguration etc.
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchRecordById,
|
||||
regeneratePdf,
|
||||
} from "@/record-management/services/api/userRecordService";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileList from "@/record-management/common/fileListComponent";
|
||||
import {
|
||||
ChevronLeft,
|
||||
RefreshCw,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Download,
|
||||
X,
|
||||
MoveLeft,
|
||||
} from "lucide-react";
|
||||
import { attachmentResponse } from "../hooks/useCreateExternalLetterRecord";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
interface ViewExternalRecordProps {
|
||||
itemId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
const Box = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 hover:border-primary-200 dark:hover:border-primary-600 transition-colors">
|
||||
<label className="block text-base font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
||||
{title}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const ViewExternalRecord: React.FC<ViewExternalRecordProps> = ({
|
||||
itemId,
|
||||
onBack,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const [isLoadingPdf, setIsLoadingPdf] = useState(true);
|
||||
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
const { data: attachment, isLoading, error } = attachmentResponse(itemId);
|
||||
const { data: record, error: recordFetchError } = useQuery({
|
||||
queryKey: ["record", itemId],
|
||||
queryFn: () => fetchRecordById(itemId!),
|
||||
enabled: !!itemId,
|
||||
});
|
||||
|
||||
// Surface main-record fetch failures via the centralized error handler.
|
||||
// attachment failure stays silent — the existing inline UI already covers it.
|
||||
const prevRecordErrorRef = useRef<unknown>(null);
|
||||
useEffect(() => {
|
||||
if (recordFetchError && recordFetchError !== prevRecordErrorRef.current) {
|
||||
prevRecordErrorRef.current = recordFetchError;
|
||||
handleError(recordFetchError);
|
||||
}
|
||||
}, [recordFetchError, handleError]);
|
||||
const attachmentUrl = useMemo(() => {
|
||||
if (!attachment) return null;
|
||||
|
||||
// Handle ArrayBuffer case
|
||||
if (attachment instanceof ArrayBuffer) {
|
||||
const blob = new Blob([attachment], { type: "application/pdf" });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
// Handle presigned URL case
|
||||
if (typeof attachment === "object" && "presigned" in attachment) {
|
||||
return attachment.presigned;
|
||||
}
|
||||
|
||||
// Handle direct URL case
|
||||
if (typeof attachment === "string") {
|
||||
return attachment.startsWith("http") ? attachment : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [attachment]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clean up blob URLs
|
||||
if (attachmentUrl && attachmentUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(attachmentUrl);
|
||||
}
|
||||
};
|
||||
}, [attachmentUrl]);
|
||||
|
||||
const { mutate: regeneratePdfMutation, isPending: isRegenerating } =
|
||||
useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!itemId) throw new Error("Record ID is missing");
|
||||
await regeneratePdf(itemId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("msg.pdfRegenerated"));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["attachmentResponse", itemId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!attachmentUrl) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = attachmentUrl;
|
||||
link.download = `document-${itemId}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const openFullScreen = () => {
|
||||
setIsFullScreen(true);
|
||||
};
|
||||
|
||||
const closeFullScreen = () => {
|
||||
setIsFullScreen(false);
|
||||
};
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setIsLoadingPdf(false);
|
||||
};
|
||||
|
||||
// Handle escape key to exit fullscreen
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && isFullScreen) {
|
||||
closeFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [isFullScreen]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">{t("loading")}...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="bg-red-100 rounded-full p-3 w-12 h-12 flex items-center justify-center mx-auto mb-4">
|
||||
<X className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-red-800 mb-2">
|
||||
{t("error.failedToLoadPdf")}
|
||||
</h3>
|
||||
<p className="text-red-600 mb-4">{error.message}</p>
|
||||
<div className="flex">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
size="sm" // or use className for custom sizing
|
||||
className="bg-gray-600 text-sm px-1 py-1 w-[100px] hover:bg-gray-700"
|
||||
>
|
||||
<MoveLeft className="w-4 h-4" />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Normal View */}
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
size="sm" // or use className for custom sizing
|
||||
className="bg-gray-600 text-sm px-1 py-1 w-[100px] hover:bg-gray-700"
|
||||
>
|
||||
<MoveLeft className="w-4 h-4" />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => regeneratePdfMutation()}
|
||||
disabled={isRegenerating || !attachmentUrl}
|
||||
className="flex items-center px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${
|
||||
isRegenerating ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
{t("viewDetail.regeneratePdf")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!attachmentUrl}
|
||||
className="flex items-center px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t("userRecord.Download")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openFullScreen}
|
||||
disabled={!attachmentUrl}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg shadow-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
<Maximize className="h-4 w-4 mr-2" />
|
||||
{t("viewDetail.fullScreen")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden">
|
||||
{attachmentUrl ? (
|
||||
<div className="relative">
|
||||
{isLoadingPdf && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400 flex items-center gap-2">
|
||||
<span className="animate-spin rounded-full h-4 w-4 border-t-2 border-primary-600 dark:border-primary-400"></span>
|
||||
{t("viewDetail.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={attachmentUrl}
|
||||
title="PDF Preview"
|
||||
width="100%"
|
||||
height="800px"
|
||||
style={{ border: "none" }}
|
||||
onLoad={handleIframeLoad}
|
||||
className={
|
||||
isLoadingPdf
|
||||
? "opacity-0"
|
||||
: "opacity-100 transition-opacity duration-300"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-96 bg-gray-50">
|
||||
<div className="bg-gray-100 rounded-full p-4 w-16 h-16 flex items-center justify-center mb-4">
|
||||
<X className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{t("noPdfAvailable")}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-center max-w-md">
|
||||
{t("noPdfAvailableDescription") ||
|
||||
"The PDF document is not available. You can try regenerating it using the button above."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Full Screen Overlay */}
|
||||
{isFullScreen && attachmentUrl && (
|
||||
<div className="fixed inset-0 bg-black z-50 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-900 text-white px-6 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">Document Preview</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center px-3 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors duration-200"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={closeFullScreen}
|
||||
className="flex items-center px-3 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors duration-200"
|
||||
title="Exit Fullscreen"
|
||||
>
|
||||
<Minimize className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF Container */}
|
||||
<div className="flex-1 bg-gray-800">
|
||||
<iframe
|
||||
src={attachmentUrl}
|
||||
title="PDF Preview - Full Screen"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: "none" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-900 text-white px-6 py-3 text-sm text-center">
|
||||
<p>Press ESC to exit fullscreen mode</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Box title={t("viewDetail.attachments")}>
|
||||
<FileList
|
||||
attachments={record?.content[0]?.recordAttachments ?? []}
|
||||
showDelete={false}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewExternalRecord;
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Card, CardHeader, CardContent } from "@/shared/common/ui/card";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
|
||||
import VerifyOtp from "./verifyOTP";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useUser } from "@/shared/context/UserContext";
|
||||
|
||||
export const LandingPageLayout = () => {
|
||||
const userDetails = useUser();
|
||||
const doesUserHaveSetPassword = userDetails?.hasFinishedRegistration;
|
||||
const shouldBlock = doesUserHaveSetPassword === false;
|
||||
|
||||
const shellClasses =
|
||||
"min-h-screen flex flex-col w-full " +
|
||||
(shouldBlock ? "blur-sm pointer-events-none select-none" : "");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Shell (blurred & non-interactive when blocked) */}
|
||||
<div className={shellClasses} aria-hidden={shouldBlock}>
|
||||
{/* Header Section */}
|
||||
<header className="text-center py-10 border-b border-gray-200 bg-primary-500 ">
|
||||
<div className="container">
|
||||
<h1 className="text-4xl font-bold text-primary-foreground mb-4">
|
||||
Welcome to SmartOffice
|
||||
</h1>
|
||||
<p className="text-xl text-primary-foreground/90 max-w-2xl mx-auto mb-8">
|
||||
Submit, track, and manage letters to organizations seamlessly. Our
|
||||
system ensures transparency and traceability at every step.
|
||||
</p>
|
||||
{!userDetails && (
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button asChild variant="secondary">
|
||||
<Link to="/external-portal/signin">Sign In</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/external-portal/signup">Sign Up</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-grow py-12 px-4">
|
||||
<section className="container max-w-6xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center text-foreground mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground text-center max-w-2xl mx-auto mb-12">
|
||||
From registration to letter submission and tracking, we've made
|
||||
the process simple.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-semibold text-primary">
|
||||
1. Sign Up
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Register your organization or as an individual. Get a
|
||||
confirmation via text message.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-semibold text-primary">
|
||||
2. Log In
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Access your dashboard and start interacting with the system
|
||||
securely.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-md transition-shadow hover:cursor-pointer">
|
||||
<Link to="/external-portal/portal-outgoing/submit-letter">
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-semibold text-primary">
|
||||
3. Submit Letters
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Select your target organization and submit your letter
|
||||
directly through the portal.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-md transition-shadow hover:cursor-pointer">
|
||||
<Link to="/external-portal/portal-outgoing">
|
||||
<CardHeader>
|
||||
<h3 className="text-xl font-semibold text-primary">
|
||||
4. Track Activities
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
See your submitted letters and view the activity logs
|
||||
associated with each.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-6 border-t border-gray-200 text-center text-muted-foreground">
|
||||
<div className="container">
|
||||
<p>© 2025 SmartOffice. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{shouldBlock && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center text-center p-6 pointer-events-auto">
|
||||
<div className="bg-white bg-opacity-90 backdrop-blur-md rounded-xl shadow-xl p-8 max-w-md w-full border border-gray-200">
|
||||
<Clock className="w-12 h-12 text-yellow-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">
|
||||
Access Restricted
|
||||
</h2>
|
||||
<p className="text-gray-800 text-lg">
|
||||
Your registration is pending. Please wait for approval from the
|
||||
SmartOffice administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPageLayout;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createLetterSchema = z.object({
|
||||
fileInfo: z.object({
|
||||
fileName: z.string(),
|
||||
contentType: z.string(),
|
||||
size: z.number(),
|
||||
originalname: z.string(),
|
||||
}),
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
letterNumber: z.string().min(1, "Letter number is required"),
|
||||
preferredLanguage: z.enum(["am", "en"], {
|
||||
required_error: "Preferred language is required",
|
||||
}),
|
||||
});
|
||||
|
||||
// 👇 If you want TypeScript types derived from the Zod schema:
|
||||
export type CreateLetterFormValues = z.infer<typeof createLetterSchema>;
|
||||
|
||||
export const createComplaintLetterSchema = z.object({
|
||||
fileInfo: z.object({
|
||||
fileName: z.string(),
|
||||
contentType: z.string(),
|
||||
size: z.number(),
|
||||
originalname: z.string(),
|
||||
}),
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
description: z.string().min(1, "Description is required"),
|
||||
recipient: z.string().optional(),
|
||||
letterNumber: z.string().optional(),
|
||||
preferredLanguage: z.enum(["am", "en"], {
|
||||
required_error: "Preferred language is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export type CreateComplaintLetterFormValues = z.infer<
|
||||
typeof createComplaintLetterSchema
|
||||
>;
|
||||
|
||||
export const userSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
username: z
|
||||
.string()
|
||||
.min(3, { message: "Username must be at least 3 characters" }),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.min(10, { message: "Phone number must be at least 10 digits" }),
|
||||
userType: z.string().min(1, { message: "User type is required" }),
|
||||
name: z.object({
|
||||
am: z.string().min(1, { message: "Amharic name is required" }),
|
||||
en: z.string().min(1, { message: "English name is required" }),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UserFormValues = z.infer<typeof userSchema>;
|
||||
|
||||
export const defaultCreateLetterValues: CreateLetterFormValues = {
|
||||
fileInfo: {
|
||||
fileName: "",
|
||||
contentType: "",
|
||||
size: 0,
|
||||
originalname: "",
|
||||
},
|
||||
subject: "",
|
||||
letterNumber: "",
|
||||
preferredLanguage: "en",
|
||||
};
|
||||
|
||||
export const defaultCreateComplaintLetterValues: CreateComplaintLetterFormValues =
|
||||
{
|
||||
fileInfo: {
|
||||
fileName: "",
|
||||
contentType: "",
|
||||
size: 0,
|
||||
originalname: "",
|
||||
},
|
||||
subject: "",
|
||||
description: "",
|
||||
recipient: "",
|
||||
letterNumber: "",
|
||||
preferredLanguage: "en",
|
||||
};
|
||||
|
||||
export const defaultUserValues: UserFormValues = {
|
||||
email: "",
|
||||
username: "",
|
||||
phoneNumber: "",
|
||||
userType: "",
|
||||
name: {
|
||||
am: "",
|
||||
en: "",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { DataTable } from "@/record-management/common/DataTable";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { FilePlus2, Search } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { portalOutgoingColumn } from "./portalOutgoingColumn";
|
||||
import { useExternalPortal } from "@/external-portal/hooks/useCreateExternalLetterRecord";
|
||||
import Loader from "@/record-management/common/loader";
|
||||
import { useUser } from "@/shared/context/UserContext";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { hasComplaintVerification } from "@/complaints/utils/complaintVerificationStorage";
|
||||
import { COMPLAINT_SUBMIT_PATH } from "@/complaints/utils/complaintRoutes";
|
||||
type FilterType = "Reference" | "From" | "Subject" | "letterNumber";
|
||||
|
||||
const PortalOutgoing = () => {
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showRegistrationAlert, setShowRegistrationAlert] = useState(false);
|
||||
const userDetails = useUser();
|
||||
const localizedName = useLocalizedName();
|
||||
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const complaintSessionActive = hasComplaintVerification();
|
||||
const showComplaintPrompt =
|
||||
complaintSessionActive ||
|
||||
Boolean(
|
||||
(location.state as { fromComplaintVerification?: boolean } | null)
|
||||
?.fromComplaintVerification,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCompletedRegistration && !complaintSessionActive) {
|
||||
setShowRegistrationAlert(true);
|
||||
} else {
|
||||
setShowRegistrationAlert(false);
|
||||
}
|
||||
}, [complaintSessionActive, hasCompletedRegistration]);
|
||||
|
||||
const [filterType, setFilterType] = useState<FilterType>("Reference");
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const params: Record<string, any> = {
|
||||
skip: pagination.pageIndex * pagination.pageSize,
|
||||
take: pagination.pageSize,
|
||||
orderBy: "record.createdAt:DESC",
|
||||
};
|
||||
const {
|
||||
ExternalLetters,
|
||||
isFetchingLetters,
|
||||
ExternalCount,
|
||||
refetchExternalLetters,
|
||||
} = useExternalPortal(params);
|
||||
|
||||
const statusOptions = [{ value: "", label: t("statusBar.All") }];
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
let filtered = ExternalLetters;
|
||||
|
||||
if (statusFilter) {
|
||||
filtered = filtered.filter(
|
||||
(record: any) => record.status === statusFilter,
|
||||
);
|
||||
}
|
||||
|
||||
if (!searchQuery) return filtered;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
return filtered.filter((record: any) => {
|
||||
switch (filterType) {
|
||||
case "Reference":
|
||||
return record.letterNumber?.toLowerCase().includes(query);
|
||||
case "Subject":
|
||||
return record.content[0]?.subject.toLowerCase().includes(query);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}, [ExternalLetters, statusFilter, searchQuery, filterType]);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
const handleFilterChange = (value: FilterType) => {
|
||||
setFilterType(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-slate-50 py-6 md:py-8">
|
||||
<div className="mx-auto max-w-6xl px-4 md:px-6">
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
|
||||
{t("nav.myRecord")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{t("complaint.fayda.formSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Link to={COMPLAINT_SUBMIT_PATH}>
|
||||
<Button className="h-11 rounded-lg px-5 shadow-sm bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
<FilePlus2 className="mr-2 h-4 w-4" />
|
||||
{complaintSessionActive
|
||||
? t("complaint.submit")
|
||||
: t("userRecord.Add Record")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showComplaintPrompt ? (
|
||||
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
{t("complaint.choice.prompt")}{" "}
|
||||
<Link
|
||||
to={COMPLAINT_SUBMIT_PATH}
|
||||
className="font-semibold underline underline-offset-2">
|
||||
{t("complaint.submit")}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showRegistrationAlert ? (
|
||||
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{t("registration.registrationRequired")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold underline underline-offset-2"
|
||||
onClick={() => navigate("/external-portal/upload-documents")}>
|
||||
{t("registration.uploadDocuments")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:p-5">
|
||||
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statusOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={statusFilter === option.value ? "linkActive" : "link"}
|
||||
onClick={() => setStatusFilter(option.value)}
|
||||
className="rounded-md">
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row lg:w-auto">
|
||||
<Select value={filterType} onValueChange={handleFilterChange}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder={t("userRecord.Filter by")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Subject">
|
||||
{t("userRecord.Subject")}
|
||||
</SelectItem>
|
||||
<SelectItem value="Reference">
|
||||
{t("userRecord.Letter Number")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative w-full sm:min-w-[240px]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
type="text"
|
||||
placeholder={`${t("userRecord.Search")} ${filterType.toLowerCase()}`}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetchingLetters ? <Loader /> : null}
|
||||
|
||||
<DataTable
|
||||
tableName="External Records"
|
||||
columns={portalOutgoingColumn(
|
||||
localizedName as (name?: { am?: string; en?: string }) => string,
|
||||
)}
|
||||
data={filteredRecords}
|
||||
toolBarPosition="right"
|
||||
totalCount={ExternalCount}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
refresh={refetchExternalLetters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PortalOutgoing;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { IncomingRecordDto } from "@/record-management/dto/userRecords/userRecordsDto";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Download, Eye, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
import { LetterRecordDto } from "@/shared/dto/External-Portal/External-PortalDto";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { renderStatus } from "@/record-management/utils/renderDetails";
|
||||
|
||||
export const portalOutgoingColumn = (
|
||||
localizedName: (name?: { am?: string; en?: string }) => string
|
||||
): ColumnDef<LetterRecordDto>[] => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { t, i18n } = useTranslation();
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const navigate = useNavigate();
|
||||
const handleViewRecord = (recordId: string) => {
|
||||
navigate(`/external-portal/view/${recordId}`);
|
||||
};
|
||||
return [
|
||||
{
|
||||
accessorKey: "referenceNumber",
|
||||
header: t("userRecord.Letter Number"),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row?.original?.letterNumber}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t("userRecord.Subject"),
|
||||
cell: ({ row }) => {
|
||||
const subject = row.original.content[0]?.subject || "-";
|
||||
return <div className="text-sm text-gray-700">{subject}</div>;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "Receiver",
|
||||
header: t("userRecord.ReceivingUnit"),
|
||||
cell: ({ row }) => {
|
||||
const receivingUnit =
|
||||
localizedName(row.original.receivingUnits?.[0]?.unit?.name) || "-";
|
||||
return <div className="text-sm text-gray-700">{receivingUnit}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("userRecord.Status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row?.original?.statusKey;
|
||||
return renderStatus(status, status);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "Date",
|
||||
header: t("userRecord.Date Received"),
|
||||
cell: ({ row }) => {
|
||||
const lang = i18n.language;
|
||||
const dispatchedDate = row.original.createdAt;
|
||||
const amharicDate = row.original.amharicCreatedAt;
|
||||
if (lang.startsWith("am")) {
|
||||
return amharicDate ?? "-";
|
||||
} else {
|
||||
const date = new Date(dispatchedDate).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}
|
||||
);
|
||||
|
||||
return date ?? "-";
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: t("userIncoming.Actions"),
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleViewRecord(record.id)}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{t("userRecord.View")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,601 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { ReusableFileUploader } from "@/shared/common/ui/fileUploader/reusableFileUploader";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createLetterSchema,
|
||||
createComplaintLetterSchema,
|
||||
defaultCreateLetterValues,
|
||||
defaultCreateComplaintLetterValues,
|
||||
CreateLetterFormValues,
|
||||
} from "./formSchema";
|
||||
import { useExternalPortal } from "@/external-portal/hooks/useCreateExternalLetterRecord";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { recordFileService } from "@/record-management/services/api/recordFileService";
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { motion } from "framer-motion";
|
||||
import { useUser } from "@/shared/context/UserContext";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { Textarea } from "@/shared/common/ui/textarea";
|
||||
import { ComplaintVerifiedInfoPanel } from "@/complaints/components/ComplaintVerifiedInfoPanel";
|
||||
import {
|
||||
clearComplaintVerification,
|
||||
getComplaintVerification,
|
||||
} from "@/complaints/utils/complaintVerificationStorage";
|
||||
import { COMPLAINT_RECORDS_PATH } from "@/complaints/utils/complaintRoutes";
|
||||
import {
|
||||
unitConfigurationService,
|
||||
type CanReceiveComplaintResponse,
|
||||
} from "@/shared/services/unitConfigurationService";
|
||||
import {
|
||||
getExternalPortalReceivingUnitName,
|
||||
resolveExternalPortalReceivingUnitIds,
|
||||
} from "@/external-portal/config/receivingOrganization";
|
||||
import {
|
||||
PortalFieldLabel,
|
||||
PortalFormHeader,
|
||||
PortalFormSection,
|
||||
PortalReadOnlyField,
|
||||
} from "../shared/PortalFormPrimitives";
|
||||
import { ArrowLeft, Building2, Send } from "lucide-react";
|
||||
|
||||
interface AddIncomingRecordPortalFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const SubmitExternalRecordForm = ({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: AddIncomingRecordPortalFormProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const complaintSession = getComplaintVerification();
|
||||
const isComplaintMode = complaintSession !== null;
|
||||
const hasAuthToken = Boolean(Cookies.get("auth-token"));
|
||||
|
||||
const [canReceiveComplaintConfig, setCanReceiveComplaintConfig] =
|
||||
useState<CanReceiveComplaintResponse | null>(null);
|
||||
const [isLoadingCanReceiveComplaintConfig, setIsLoadingCanReceiveComplaintConfig] =
|
||||
useState(false);
|
||||
|
||||
type FormValues = CreateLetterFormValues & {
|
||||
description?: string;
|
||||
recipient?: string;
|
||||
};
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(
|
||||
(isComplaintMode
|
||||
? createComplaintLetterSchema
|
||||
: createLetterSchema) as typeof createLetterSchema,
|
||||
) as Resolver<FormValues>,
|
||||
defaultValues: (isComplaintMode
|
||||
? defaultCreateComplaintLetterValues
|
||||
: defaultCreateLetterValues) as FormValues,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const [showRegistrationAlert, setShowRegistrationAlert] = useState(false);
|
||||
const userDetails = useUser();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
|
||||
const { createExternalPortalLetter, isSending } = useExternalPortal();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [mainFileIndex, setMainFileIndex] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const handleCancel = onCancel ?? (() => navigate(COMPLAINT_RECORDS_PATH));
|
||||
const complaintReceivingUnitId = resolveExternalPortalReceivingUnitIds()[0];
|
||||
const receivingOrganizationName = getExternalPortalReceivingUnitName(
|
||||
i18n.language,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCompletedRegistration && !isComplaintMode) {
|
||||
setShowRegistrationAlert(true);
|
||||
} else {
|
||||
setShowRegistrationAlert(false);
|
||||
}
|
||||
}, [hasCompletedRegistration, isComplaintMode]);
|
||||
|
||||
// Complaint receiving unit is the configured target unit (not the submitter's unit).
|
||||
useEffect(() => {
|
||||
if (!isComplaintMode) return;
|
||||
if (!hasAuthToken) return;
|
||||
if (!complaintReceivingUnitId) return;
|
||||
|
||||
let isCancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
setIsLoadingCanReceiveComplaintConfig(true);
|
||||
const res = await unitConfigurationService.getCanReceiveComplaint(
|
||||
complaintReceivingUnitId,
|
||||
);
|
||||
|
||||
if (isCancelled) return;
|
||||
setCanReceiveComplaintConfig(res.data);
|
||||
} catch (err) {
|
||||
if (isCancelled) return;
|
||||
setCanReceiveComplaintConfig(null);
|
||||
toast.error(
|
||||
t(
|
||||
"complaint.receiveCheckFailed",
|
||||
"Could not verify complaint receiving for this unit. Please try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoadingCanReceiveComplaintConfig(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [complaintReceivingUnitId, hasAuthToken, isComplaintMode, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isComplaintMode) return;
|
||||
if (!hasAuthToken) return;
|
||||
if (!canReceiveComplaintConfig) return;
|
||||
|
||||
if (canReceiveComplaintConfig.canReceiveComplaint === false) {
|
||||
toast.error(
|
||||
t(
|
||||
"complaint.receiveDisabled",
|
||||
"Complaint receiving is disabled for this unit.",
|
||||
),
|
||||
);
|
||||
navigate(COMPLAINT_RECORDS_PATH);
|
||||
}
|
||||
}, [
|
||||
canReceiveComplaintConfig,
|
||||
hasAuthToken,
|
||||
isComplaintMode,
|
||||
navigate,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length === 1) {
|
||||
setMainFileIndex(0);
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
const buildComplaintSubject = (
|
||||
subject: string,
|
||||
recipient?: string,
|
||||
description?: string,
|
||||
) => {
|
||||
let composed = subject.trim();
|
||||
if (recipient?.trim()) {
|
||||
composed = `[To: ${recipient.trim()}] ${composed}`;
|
||||
}
|
||||
if (description?.trim()) {
|
||||
composed = `${composed}\n\n${description.trim()}`;
|
||||
}
|
||||
return composed;
|
||||
};
|
||||
|
||||
const isAllowedMainFile = (file: File) => {
|
||||
if (!isComplaintMode) {
|
||||
return file.type === "application/pdf";
|
||||
}
|
||||
return (
|
||||
file.type === "application/pdf" ||
|
||||
file.type === "image/jpeg" ||
|
||||
file.type === "image/png"
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (files.length === 0) {
|
||||
toast.error(t("userRecord.Please select file"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainFileIndex === null) {
|
||||
toast.error(t("Please select the main file for the record"));
|
||||
return;
|
||||
}
|
||||
|
||||
const mainFile = files[mainFileIndex];
|
||||
const fileInfo = {
|
||||
fileName: mainFile.name,
|
||||
contentType: mainFile.type,
|
||||
size: mainFile.size,
|
||||
originalname: mainFile.name,
|
||||
};
|
||||
|
||||
const unitIds = isComplaintMode
|
||||
? [complaintReceivingUnitId]
|
||||
: resolveExternalPortalReceivingUnitIds();
|
||||
|
||||
if (isComplaintMode) {
|
||||
if (isLoadingCanReceiveComplaintConfig) {
|
||||
toast.error(
|
||||
t(
|
||||
"complaint.receiveCheckInProgress",
|
||||
"Still verifying complaint receiving for this unit. Please wait.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canReceiveComplaintConfig) {
|
||||
toast.error(
|
||||
t(
|
||||
"complaint.receiveCheckFailed",
|
||||
"Could not verify complaint receiving for this unit. Please try again.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReceiveComplaintConfig.canReceiveComplaint === false) {
|
||||
toast.error(
|
||||
t(
|
||||
"complaint.receiveDisabled",
|
||||
"Complaint receiving is disabled for this unit.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!complaintReceivingUnitId && isComplaintMode) {
|
||||
toast.error(
|
||||
t(
|
||||
"complaint.receiveCheckFailed",
|
||||
"Could not verify complaint receiving for this unit. Please try again.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedMainFile(mainFile)) {
|
||||
toast.error(
|
||||
isComplaintMode
|
||||
? t("complaint.fayda.uploadHint")
|
||||
: t("userRecord.The main file must be a PDF document"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const letterNumber =
|
||||
values.letterNumber?.trim() ||
|
||||
(isComplaintMode ? `CMP-${Date.now()}` : "");
|
||||
|
||||
if (!letterNumber) {
|
||||
toast.error(t("userRecord.Letter Number"));
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = isComplaintMode
|
||||
? buildComplaintSubject(
|
||||
values.subject,
|
||||
values.recipient,
|
||||
values.description,
|
||||
)
|
||||
: values.subject;
|
||||
|
||||
try {
|
||||
const recordResult = await createExternalPortalLetter(
|
||||
{
|
||||
...values,
|
||||
subject,
|
||||
fileInfo,
|
||||
unitIds,
|
||||
letterNumber,
|
||||
},
|
||||
mainFile,
|
||||
);
|
||||
const contentId = recordResult.contentId;
|
||||
|
||||
if (!contentId) {
|
||||
throw new Error("Missing contentId from created record");
|
||||
}
|
||||
|
||||
form.reset();
|
||||
setFiles([]);
|
||||
setMainFileIndex(null);
|
||||
|
||||
const attachments = files.filter((_, idx) => idx !== mainFileIndex);
|
||||
if (attachments.length > 0) {
|
||||
try {
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const uploadMetaResult = await recordFileService.uploadMeta(
|
||||
attachment,
|
||||
attachment.name,
|
||||
contentId,
|
||||
);
|
||||
|
||||
await recordFileService.uploadFile(
|
||||
attachment,
|
||||
uploadMetaResult.presigned,
|
||||
);
|
||||
await recordFileService.updateStatus(
|
||||
uploadMetaResult.id,
|
||||
contentId,
|
||||
true,
|
||||
);
|
||||
}),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
isComplaintMode
|
||||
? t("complaint.success")
|
||||
: t("externalPortal.letterSentSuccess"),
|
||||
);
|
||||
if (isComplaintMode) {
|
||||
clearComplaintVerification();
|
||||
}
|
||||
onSuccess?.();
|
||||
navigate(COMPLAINT_RECORDS_PATH);
|
||||
} catch (error) {
|
||||
await handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToDocuments = () => {
|
||||
navigate("/external-portal/upload-documents");
|
||||
setShowRegistrationAlert(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showRegistrationAlert && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
You haven't completed the verification process. Please upload
|
||||
required documents to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleGoToDocuments}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500">
|
||||
Upload Documents
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
aria-labelledby="form-title">
|
||||
{isComplaintMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(COMPLAINT_RECORDS_PATH)}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-slate-600 transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("complaint.fayda.backToMyRecords")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<PortalFormHeader
|
||||
title={
|
||||
isComplaintMode
|
||||
? t("complaint.formTitle")
|
||||
: t("userIncoming.uploadIncomingRecord")
|
||||
}
|
||||
subtitle={
|
||||
isComplaintMode ? t("complaint.fayda.formSubtitle") : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{complaintSession ? (
|
||||
<ComplaintVerifiedInfoPanel session={complaintSession} />
|
||||
) : null}
|
||||
|
||||
<PortalFormSection className="w-full min-w-0 max-w-full space-y-5 overflow-visible">
|
||||
{!isComplaintMode ? (
|
||||
<PortalReadOnlyField
|
||||
label={t("userIncoming.Receiving Organization")}
|
||||
value={receivingOrganizationName}
|
||||
icon={Building2}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel asChild>
|
||||
<PortalFieldLabel required>
|
||||
{t("userIncoming.Subject")}
|
||||
</PortalFieldLabel>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("userIncoming.Subject")}
|
||||
className="h-11 rounded-lg border-slate-200 focus-visible:ring-primary/20"
|
||||
{...field}
|
||||
disabled={isSending}
|
||||
aria-required="true"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isComplaintMode ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel asChild>
|
||||
<PortalFieldLabel required>
|
||||
{t("complaint.fayda.description")}
|
||||
</PortalFieldLabel>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"complaint.fayda.descriptionPlaceholder",
|
||||
)}
|
||||
className="min-h-[160px] resize-none rounded-lg border-slate-200 focus-visible:ring-primary/20"
|
||||
{...field}
|
||||
disabled={isSending}
|
||||
aria-required="true"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isComplaintMode ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="letterNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel asChild>
|
||||
<PortalFieldLabel required>
|
||||
{t("userRecord.Letter Number")}
|
||||
</PortalFieldLabel>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("userRecord.Letter Number")}
|
||||
className="h-11 rounded-lg border-slate-200 focus-visible:ring-primary/20"
|
||||
{...field}
|
||||
disabled={isSending}
|
||||
aria-required="true"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<PortalFieldLabel required>
|
||||
{isComplaintMode
|
||||
? t("complaint.fayda.attachment")
|
||||
: t("userIncoming.Attachments")}
|
||||
</PortalFieldLabel>
|
||||
<div className="mt-2 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-2 transition-colors hover:border-primary/40">
|
||||
<ReusableFileUploader
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
isLoading={isSending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div>
|
||||
<PortalFieldLabel required>
|
||||
{t("Select the main file for the record")}
|
||||
</PortalFieldLabel>
|
||||
<RadioGroup
|
||||
className="mt-2"
|
||||
value={mainFileIndex?.toString() || ""}
|
||||
onValueChange={(value) => setMainFileIndex(Number(value))}
|
||||
aria-required="true">
|
||||
{files.map((file, index) => (
|
||||
<div key={file.name} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={index.toString()}
|
||||
id={`main-file-${index}`}
|
||||
/>
|
||||
<Label htmlFor={`main-file-${index}`}>{file.name}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
</PortalFormSection>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-slate-500">{t("common.requiredFields")}</p>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSending}
|
||||
className="h-11 min-w-[8rem] rounded-lg">
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!form.formState.isValid ||
|
||||
isSending ||
|
||||
files.length === 0 ||
|
||||
(isComplaintMode &&
|
||||
(isLoadingCanReceiveComplaintConfig ||
|
||||
canReceiveComplaintConfig?.canReceiveComplaint !== true))
|
||||
}
|
||||
className="h-11 min-w-[10rem] rounded-lg bg-primary hover:bg-primary/90">
|
||||
{isSending ? (
|
||||
t("userIncoming.Processing...")
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{isComplaintMode ? t("complaint.submit") : t("common.Submit")}
|
||||
{isComplaintMode ? <Send className="h-4 w-4" /> : null}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmitExternalRecordForm;
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/common/ui/fileUploader/utils";
|
||||
|
||||
export function PortalFormPage({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("min-h-screen bg-slate-50 py-8 md:py-10", className)}>
|
||||
<div className="mx-auto w-full max-w-3xl px-4 md:px-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalFormHeader({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8 text-center">
|
||||
<h1
|
||||
id="form-title"
|
||||
className="text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? (
|
||||
<p className="mt-2 text-sm text-slate-600 md:text-base">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalFormSection({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-xl border border-slate-200 bg-white p-5 shadow-sm md:p-6",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalFieldLabel({
|
||||
children,
|
||||
required,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{children}
|
||||
{required ? <span className="ml-0.5 text-red-600">*</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalReadOnlyField({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
icon?: LucideIcon;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<PortalFieldLabel>{label}</PortalFieldLabel>
|
||||
<div className="relative">
|
||||
{Icon ? (
|
||||
<Icon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-slate-200 bg-white py-2.5 text-sm text-slate-800",
|
||||
Icon ? "pl-10 pr-3" : "px-3",
|
||||
mono && "font-mono",
|
||||
)}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Lock, User, Mail, Phone, Globe, Building, Text } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { userSchema, UserFormValues } from "./outgoing/formSchema";
|
||||
import { useRegisterExternalPortalUser } from "../hooks/useRegisterExternalPortalUser";
|
||||
import { useTenantConfig } from "@/layout/components/TenantConfig";
|
||||
|
||||
const Signup = () => {
|
||||
const navigate = useNavigate();
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
const { registerExternalPortalUser, isRegistering } =
|
||||
useRegisterExternalPortalUser();
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
username: "",
|
||||
phoneNumber: "",
|
||||
userType: "external_organization",
|
||||
name: {
|
||||
am: "",
|
||||
en: "",
|
||||
},
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = async (values: UserFormValues) => {
|
||||
try {
|
||||
await registerExternalPortalUser(values);
|
||||
const params = new URLSearchParams();
|
||||
if (values.email) params.set("email", values.email.trim());
|
||||
if (values.phoneNumber) {
|
||||
const normalizedPhone = values.phoneNumber.trim().replace(/^0/, "+251");
|
||||
params.set("phone", normalizedPhone);
|
||||
}
|
||||
navigate(`/external-portal/verify-otp?${params.toString()}`);
|
||||
} catch (error) {
|
||||
console.error("Signup error:", error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-6xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col md:flex-row h-full max-h-[800px]">
|
||||
{/* Left Panel */}
|
||||
<div className="md:w-1/2 w-full p-8 md:p-12 flex flex-col">
|
||||
<div className="flex items-center mb-8">
|
||||
<img
|
||||
src={tenantConfig.logo || "/assets/smart-office-logo.svg"}
|
||||
alt={tenantConfig.appName}
|
||||
className="h-10 object-contain"
|
||||
/>
|
||||
<span className="ml-2 text-xl font-semibold text-gray-800">
|
||||
{tenantConfig.appName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Create Organization Account
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
Streamline your workflow with our paperless solution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6 flex-1 flex flex-col"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Organization Email"
|
||||
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
|
||||
{...form.register("email")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Organization Username"
|
||||
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
|
||||
{...form.register("username")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="Organization Phone Number"
|
||||
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
|
||||
{...form.register("phoneNumber")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Organization Name (English)"
|
||||
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
|
||||
{...form.register("name.en")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Text className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Organization Name (Amharic)"
|
||||
className="h-12 rounded-lg border-gray-300 pl-10 text-base focus-visible:ring-2 focus-visible:ring-primary"
|
||||
{...form.register("name.am")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-primary hover:bg-primary-700 text-white text-base font-medium rounded-lg transition-all duration-300 shadow-md hover:shadow-lg"
|
||||
disabled={isRegistering}
|
||||
>
|
||||
{isRegistering ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Creating Account...
|
||||
</span>
|
||||
) : (
|
||||
"Sign Up"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
Already have an account?{" "}
|
||||
<a
|
||||
href="/login"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
<div className="md:w-1/2 bg-gradient-to-br from-primary to-primary-300 text-white p-10 hidden md:flex flex-col">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold mb-4">
|
||||
Transform Your Office Experience
|
||||
</h2>
|
||||
<p className="text-lg opacity-90">
|
||||
Join thousands of organizations that have gone paperless with our
|
||||
smart solutions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-64 h-64 rounded-full bg-white/10 blur-xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20 shadow-xl">
|
||||
<div className="flex items-center mb-4">
|
||||
<Building className="w-8 h-8 mr-3" />
|
||||
<h3 className="text-xl font-semibold">
|
||||
Organization Dashboard
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-primary-400 mr-2"></div>
|
||||
<span>Real-time document management</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-400 mr-2"></div>
|
||||
<span>Secure cloud storage</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-purple-400 mr-2"></div>
|
||||
<span>Automated workflow</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400 mr-2"></div>
|
||||
<span>Multi-language support</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 relative">
|
||||
<img
|
||||
src="/assets/MainDashboard.png"
|
||||
alt="Dashboard Preview"
|
||||
className="w-full rounded-xl shadow-2xl border-4 border-white/20"
|
||||
/>
|
||||
<div className="absolute -bottom-4 -right-4 bg-white p-2 rounded-lg shadow-lg">
|
||||
<img
|
||||
src="/assets/StatsCard.png"
|
||||
alt="Stats Preview"
|
||||
className="w-24 h-24 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-6 text-center text-sm opacity-80">
|
||||
<p>Trusted by 500+ organizations worldwide</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Signup;
|
||||
@@ -0,0 +1,468 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ShieldCheck,
|
||||
ArrowLeft,
|
||||
RefreshCcw,
|
||||
EyeOff,
|
||||
Eye,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/common/ui/card";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { GenerateVerifcationCodePayload } from "@/shared/services/authService";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTradeLicenseVerification } from "../hooks/useTradeLicenseVerification";
|
||||
import { getEtradeLicenseNo } from "../utils/etradeAuthStorage";
|
||||
|
||||
interface VerifyOtpProps {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
onComplete?: () => void;
|
||||
isExternalOrg?: boolean;
|
||||
userId?: string;
|
||||
isEtradeVerification?: boolean;
|
||||
licenseNo?: string;
|
||||
}
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
const RESEND_COOLDOWN_SECONDS = 30;
|
||||
|
||||
const VerifyOtp: React.FC<VerifyOtpProps> = ({
|
||||
email,
|
||||
phone,
|
||||
onComplete,
|
||||
isExternalOrg,
|
||||
userId: propUserId,
|
||||
isEtradeVerification,
|
||||
licenseNo,
|
||||
}: VerifyOtpProps) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [otpValues, setOtpValues] = useState<string[]>(
|
||||
Array(OTP_LENGTH).fill("")
|
||||
);
|
||||
const userID = propUserId || searchParams.get("userId") || "";
|
||||
const resolvedLicenseNo =
|
||||
licenseNo?.trim() ||
|
||||
searchParams.get("licenseNo")?.trim() ||
|
||||
getEtradeLicenseNo() ||
|
||||
"";
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const [hasNavigated, setHasNavigated] = useState(false);
|
||||
const requersOtp =
|
||||
isExternalOrg || searchParams.get("isExternalOrg") === "true";
|
||||
const {
|
||||
setPassword,
|
||||
setFayidaPassword,
|
||||
resendOtpCode,
|
||||
setPasswordSuccess,
|
||||
setPasswordError,
|
||||
} = useAuthUser();
|
||||
const { verifyEtradeOtp, isVerifyingOtp } = useTradeLicenseVerification();
|
||||
|
||||
useEffect(() => {
|
||||
inputsRef.current[0]?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const t = setInterval(() => setCooldown((s) => (s > 0 ? s - 1 : 0)), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [cooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setPasswordSuccess && !setPasswordError?.message && !hasNavigated) {
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: t("registration.verifyOtp.messages.success"),
|
||||
});
|
||||
setHasNavigated(true);
|
||||
onComplete?.();
|
||||
navigate("/verification_page");
|
||||
}
|
||||
}, [setPasswordSuccess, setPasswordError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setPasswordError?.message) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: setPasswordError.message.includes("OTP")
|
||||
? setPasswordError.message
|
||||
: t("registration.verifyOtp.messages.passwordUpdateFailed"),
|
||||
});
|
||||
}
|
||||
}, [setPasswordError]);
|
||||
|
||||
const focusInput = (idx: number) => {
|
||||
inputsRef.current[idx]?.focus();
|
||||
inputsRef.current[idx]?.select?.();
|
||||
};
|
||||
|
||||
const handleOtpChange = (idx: number, value: string) => {
|
||||
const char = value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 1);
|
||||
if (!char) return;
|
||||
|
||||
const updated = [...otpValues];
|
||||
updated[idx] = char;
|
||||
setOtpValues(updated);
|
||||
|
||||
if (idx < OTP_LENGTH - 1) {
|
||||
setTimeout(() => focusInput(idx + 1), 10); // give DOM time to settle
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpKeyDown = (
|
||||
idx: number,
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === "Backspace") {
|
||||
if (otpValues[idx]) {
|
||||
setOtpValues((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = "";
|
||||
return next;
|
||||
});
|
||||
} else if (idx > 0) {
|
||||
focusInput(idx - 1);
|
||||
}
|
||||
} else if (e.key === "ArrowLeft" && idx > 0) {
|
||||
focusInput(idx - 1);
|
||||
} else if (e.key === "ArrowRight" && idx < OTP_LENGTH - 1) {
|
||||
focusInput(idx + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = e.clipboardData.getData("text").replace(/\D/g, "");
|
||||
if (!pasted) return;
|
||||
e.preventDefault();
|
||||
setOtpValues(
|
||||
Array(OTP_LENGTH)
|
||||
.fill("")
|
||||
.map((_, i) => pasted[i] ?? "")
|
||||
);
|
||||
const lastIndex = Math.min(pasted.length, OTP_LENGTH) - 1;
|
||||
focusInput(lastIndex >= 0 ? lastIndex : 0);
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email && !phone) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: t("registration.verifyOtp.messages.missingEmailPhone"),
|
||||
});
|
||||
toast.error(t("registration.verifyOtp.errors.missingUserId"));
|
||||
return;
|
||||
}
|
||||
setResendLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
email: email || "",
|
||||
phoneNumber: phone || "",
|
||||
} satisfies GenerateVerifcationCodePayload;
|
||||
await resendOtpCode(payload);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: t("registration.verifyOtp.messages.verificationCodeResent"),
|
||||
});
|
||||
setCooldown(RESEND_COOLDOWN_SECONDS);
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: t("registration.verifyOtp.messages.resendFailed"),
|
||||
});
|
||||
toast.error(t("registration.verifyOtp.errors.resendFailed"));
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError("");
|
||||
setMessage(null);
|
||||
|
||||
if (!isEtradeVerification) {
|
||||
if (!newPassword || !confirmPassword) {
|
||||
setError(t("registration.verifyOtp.messages.fillPasswords"));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError(t("registration.verifyOtp.messages.passwordLength"));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("registration.verifyOtp.messages.passwordMismatch"));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (requersOtp) {
|
||||
const code = otpValues.join("");
|
||||
|
||||
if (isEtradeVerification) {
|
||||
if (!resolvedLicenseNo) {
|
||||
setError(t("registration.etrade.licenseNoRequired"));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (code.length !== OTP_LENGTH || !/^[a-zA-Z0-9]{6}$/.test(code)) {
|
||||
setError(t("registration.verifyOtp.messages.invalidOtp"));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyEtradeOtp({
|
||||
licenseNo: resolvedLicenseNo,
|
||||
otp: code,
|
||||
});
|
||||
onComplete?.();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setError(t("registration.verifyOtp.messages.invalidOtp"));
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.length !== OTP_LENGTH || !/^[a-zA-Z0-9]{6}$/.test(code)) {
|
||||
setError(t("registration.verifyOtp.messages.invalidOtp"));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email && !phone) {
|
||||
setError(t("registration.verifyOtp.messages.missingEmailPhone"));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const passwordPayload: any = {
|
||||
email: email || "",
|
||||
verificationCode: code,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
};
|
||||
if (userID) {
|
||||
passwordPayload.userId = userID;
|
||||
}
|
||||
await setPassword(passwordPayload);
|
||||
} else {
|
||||
await setFayidaPassword({
|
||||
userId: userID,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
navigate("/login");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const pageFade = { initial: { opacity: 0 }, animate: { opacity: 1 } };
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
{...pageFade}
|
||||
className="min-h-screen w-full bg-gradient-to-b from-background to-muted/30 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md shadow-xl border-0">
|
||||
<CardHeader className="text-center">
|
||||
<img
|
||||
src="/assets/smart-office-logo.svg"
|
||||
alt="Smart Office Logo"
|
||||
className="h-10 mx-auto mb-2"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("registration.verifyOtp.title")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isEtradeVerification
|
||||
? t("registration.etrade.otpVerificationDescription")
|
||||
: t("registration.verifyOtp.description", {
|
||||
channel: email
|
||||
? t("registration.verifyOtp.channel.email")
|
||||
: t("registration.verifyOtp.channel.phone"),
|
||||
})}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{requersOtp && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{Array.from({ length: OTP_LENGTH }).map((_, idx) => (
|
||||
<Input
|
||||
key={idx}
|
||||
ref={(el) => {
|
||||
inputsRef.current[idx] = el;
|
||||
}}
|
||||
inputMode="text"
|
||||
pattern="[a-zA-Z0-9]{1}"
|
||||
maxLength={1}
|
||||
value={otpValues[idx]}
|
||||
onChange={(e) => handleOtpChange(idx, e.target.value)}
|
||||
onKeyDown={(e) => handleOtpKeyDown(idx, e)}
|
||||
onPaste={handleOtpPaste}
|
||||
className="w-10 h-12 text-center text-xl tracking-widest"
|
||||
aria-label={t("registration.verifyOtp.otpInput.ariaLabel", {
|
||||
index: idx + 1,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!isEtradeVerification && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("registration.verifyOtp.password.newPassword")}
|
||||
className="h-10 rounded-md border px-4 text-sm ps-10"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
||||
onClick={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"registration.verifyOtp.password.confirmPassword"
|
||||
)}
|
||||
className="h-10 rounded-md border px-4 text-sm ps-10"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || isVerifyingOtp}>
|
||||
{isSubmitting || isVerifyingOtp
|
||||
? t("registration.verifyOtp.buttons.verifying")
|
||||
: isEtradeVerification
|
||||
? t("registration.verifyOtp.buttons.verifyOtp")
|
||||
: t("registration.verifyOtp.buttons.completeRegistration")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{!isEtradeVerification && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void handleResend()}
|
||||
disabled={resendLoading || cooldown > 0}
|
||||
aria-label={
|
||||
resendLoading
|
||||
? t("registration.verifyOtp.buttons.resendLoading")
|
||||
: cooldown > 0
|
||||
? t("registration.verifyOtp.buttons.resendCooldown", {
|
||||
seconds: cooldown,
|
||||
})
|
||||
: t("registration.verifyOtp.buttons.resendCode")
|
||||
}>
|
||||
{resendLoading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
{t("registration.verifyOtp.buttons.resending")}
|
||||
</span>
|
||||
) : cooldown > 0 ? (
|
||||
t("registration.verifyOtp.buttons.resendIn", {
|
||||
seconds: cooldown,
|
||||
})
|
||||
) : (
|
||||
t("registration.verifyOtp.buttons.resendCode")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-h-12" aria-live="polite" aria-atomic="true">
|
||||
{message && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"p-3 rounded-lg flex items-start gap-3",
|
||||
message.type === "success"
|
||||
? "bg-primary-50 text-primary-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
)}>
|
||||
<ShieldCheck
|
||||
className={cn(
|
||||
"h-5 w-5 flex-shrink-0 mt-0.5",
|
||||
message.type === "success"
|
||||
? "text-primary-500"
|
||||
: "text-red-500"
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm font-medium">{message.text}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
to="/support/resend-sms"
|
||||
className="text-muted-foreground hover:text-foreground">
|
||||
{t("registration.verifyOtp.support.needHelp")}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyOtp;
|
||||
Reference in New Issue
Block a user