mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
import { useQuery, useQueries } from "@tanstack/react-query";
|
||
import { getMyUploadedFiles } from "@/external-portal/services/portalOutgoingService";
|
||
import { getDocumentRequirementsById } from "@/shared/services/organizationsService";
|
||
import { getUserById } from "@/super-admin/services/api/userService";
|
||
import {
|
||
Card,
|
||
CardHeader,
|
||
CardTitle,
|
||
CardContent,
|
||
} from "@/shared/common/ui/card";
|
||
import { Button } from "@/shared/common/ui/button";
|
||
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||
import { Alert, AlertDescription } from "@/shared/common/ui/alert";
|
||
import { FileText, RefreshCw, User, Mail, Phone, Building } from "lucide-react";
|
||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||
import {
|
||
DocumentRequirementResponseDto,
|
||
DocumentResponseDto,
|
||
} from "@/shared/dto/External-Portal/External-PortalDto";
|
||
import ActionModal from "./ResponseActions";
|
||
import { toast } from "sonner";
|
||
import { useGiveResponse } from "@/shared/hooks/useOrganizationReport";
|
||
import { useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/shared/common/ui/tooltip";
|
||
|
||
type ViewDocumentProps = {
|
||
userId: string;
|
||
};
|
||
|
||
interface UploadsResponse {
|
||
items: DocumentResponseDto[];
|
||
counts: number;
|
||
}
|
||
|
||
interface UserInfo {
|
||
id: string;
|
||
name: {
|
||
am: string;
|
||
en: string;
|
||
};
|
||
email: string;
|
||
phoneNumber: string;
|
||
username: string;
|
||
userType: string;
|
||
status: string;
|
||
}
|
||
|
||
const ViewDocument: React.FC<ViewDocumentProps> = ({ userId }) => {
|
||
const localizedName = useLocalizedName();
|
||
const { t } = useTranslation();
|
||
const [status, setStatus] = useState<"APPROVED" | "REJECTED" | "PENDING">(
|
||
"APPROVED"
|
||
);
|
||
|
||
// Utility function to truncate email if too long
|
||
const truncateEmail = (email: string, maxLength: number = 25) => {
|
||
if (email.length <= maxLength) return email;
|
||
const [localPart, domain] = email.split("@");
|
||
if (localPart.length > maxLength - 10) {
|
||
return `${localPart.substring(0, maxLength - 10)}...@${domain}`;
|
||
}
|
||
return email;
|
||
};
|
||
|
||
const { mutate, isLoading: isGiveResponseLoading } = useGiveResponse();
|
||
|
||
// 1️⃣ Fetch user information
|
||
const {
|
||
data: userInfo,
|
||
isLoading: isUserLoading,
|
||
isError: isUserError,
|
||
} = useQuery<UserInfo, Error>({
|
||
queryKey: ["user-info", userId],
|
||
queryFn: async () => {
|
||
const res = await getUserById(userId);
|
||
return res.data;
|
||
},
|
||
});
|
||
|
||
// 2️⃣ Fetch uploaded documents
|
||
const {
|
||
data: uploadedDocuments,
|
||
isLoading,
|
||
isError,
|
||
refetch,
|
||
isRefetching,
|
||
} = useQuery<UploadsResponse, Error>({
|
||
queryKey: ["external-letters", userId],
|
||
queryFn: async () => {
|
||
const res = await getMyUploadedFiles(userId);
|
||
return {
|
||
items: res.data.items,
|
||
counts: res.data.counts,
|
||
};
|
||
},
|
||
});
|
||
|
||
// 3️⃣ Fetch requirements for each uploaded document
|
||
const documentRequirementsQueries = useQueries({
|
||
queries:
|
||
uploadedDocuments?.items.map((doc) => ({
|
||
queryKey: ["document-requirements", doc.documentId],
|
||
queryFn: async (): Promise<DocumentRequirementResponseDto> => {
|
||
const res = await getDocumentRequirementsById(doc.documentId);
|
||
return res.data;
|
||
},
|
||
enabled: !!uploadedDocuments && uploadedDocuments.items.length > 0,
|
||
})) ?? [],
|
||
});
|
||
|
||
// 4️⃣ Action submit handler
|
||
const handleActionSubmit = async (
|
||
documentId: string,
|
||
actionStatus:
|
||
| "pending"
|
||
| "submitted"
|
||
| "approved"
|
||
| "adjusted"
|
||
| "rejected",
|
||
remark?: string
|
||
) => {
|
||
try {
|
||
await mutate({ id: documentId, data: { status: actionStatus, remark } });
|
||
toast.success(
|
||
t("viewDocument.actions.success", { action: actionStatus })
|
||
);
|
||
} catch (err: any) {
|
||
toast.error(
|
||
t("viewDocument.actions.error", {
|
||
error: err.message || t("viewDocument.loading.failedToLoad"),
|
||
})
|
||
);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card className="w-full max-w-4xl mx-auto mt-8 shadow-lg border-gray-200 dark:border-gray-700">
|
||
<CardHeader className="border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||
<FileText className="w-6 h-6 text-primary-600" />
|
||
{t("viewDocument.title")}
|
||
</CardTitle>
|
||
<Button
|
||
onClick={() => refetch()}
|
||
variant="default"
|
||
className="bg-primary-600 hover:bg-primary-700 text-white"
|
||
disabled={isRefetching}
|
||
>
|
||
<RefreshCw
|
||
className={`mr-2 h-4 w-4 ${isRefetching ? "animate-spin" : ""}`}
|
||
/>
|
||
{t("viewDocument.refreshButton")}
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="p-6">
|
||
{/* User Information Section */}
|
||
{isUserLoading ? (
|
||
<div className="mb-6 space-y-4">
|
||
<Skeleton className="h-6 w-48" />
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<Skeleton className="h-16 w-full rounded-lg" />
|
||
<Skeleton className="h-16 w-full rounded-lg" />
|
||
<Skeleton className="h-16 w-full rounded-lg" />
|
||
</div>
|
||
</div>
|
||
) : isUserError ? (
|
||
<Alert variant="destructive" className="mb-6">
|
||
<AlertDescription>
|
||
{t("viewDocument.failedToLoadUserInfo")}
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : userInfo ? (
|
||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
|
||
<User className="w-5 h-5 text-primary-600" />
|
||
{t("viewDocument.userInformation")}
|
||
</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<Building className="w-5 h-5 text-primary-600" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||
{t("viewDocument.organizationName")}
|
||
</p>
|
||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||
{localizedName(userInfo.name)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<Mail className="w-5 h-5 text-primary-600" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||
{t("viewDocument.email")}
|
||
</p>
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate cursor-help">
|
||
{truncateEmail(userInfo.email)}
|
||
</p>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<p className="max-w-xs break-all">{userInfo.email}</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||
<Phone className="w-5 h-5 text-purple-600" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||
{t("viewDocument.phoneNumber")}
|
||
</p>
|
||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||
{userInfo.phoneNumber}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Documents Section */}
|
||
{isLoading ? (
|
||
<div className="space-y-4">
|
||
{[...Array(3)].map((_, i) => (
|
||
<Skeleton key={i} className="h-24 w-full rounded-lg" />
|
||
))}
|
||
</div>
|
||
) : isError ? (
|
||
<Alert variant="destructive">
|
||
<AlertDescription>
|
||
{t("viewDocument.loading.failedToLoad")}
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : (
|
||
<>
|
||
<p className="mb-6 text-lg font-medium text-gray-700 dark:text-gray-300">
|
||
{t("viewDocument.totalDocuments")}{" "}
|
||
<span className="font-semibold text-primary-600">
|
||
{uploadedDocuments?.counts ?? 0}
|
||
</span>
|
||
</p>
|
||
|
||
<ScrollArea className="h-[500px] border border-gray-200 dark:border-gray-700 rounded-lg">
|
||
<ul className="space-y-4 p-2">
|
||
{uploadedDocuments?.items.map((doc, index) => {
|
||
const eachDocument = documentRequirementsQueries[index]?.data;
|
||
const isEachLoading =
|
||
documentRequirementsQueries[index]?.isLoading;
|
||
const isEachError =
|
||
documentRequirementsQueries[index]?.isError;
|
||
const requirements = eachDocument ? [eachDocument] : [];
|
||
|
||
// Translate status
|
||
const getTranslatedStatus = (status: string) => {
|
||
switch (status) {
|
||
case "approved":
|
||
return t("viewDocument.document.status.approved");
|
||
case "rejected":
|
||
return t("viewDocument.document.status.rejected");
|
||
case "pending":
|
||
default:
|
||
return t("viewDocument.document.status.pending");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<li
|
||
key={doc.id}
|
||
className="flex flex-col p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50/50 dark:hover:bg-gray-800/60 transition-colors shadow-sm"
|
||
>
|
||
<div className="flex justify-between items-center mb-3">
|
||
<div className="space-y-1">
|
||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||
{doc.fileInfo.originalname}
|
||
</p>
|
||
<span
|
||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||
doc.status === "approved"
|
||
? "bg-primary-100 text-primary-800"
|
||
: doc.status === "rejected"
|
||
? "bg-red-100 text-red-800"
|
||
: "bg-yellow-100 text-yellow-800"
|
||
}`}
|
||
>
|
||
{getTranslatedStatus(doc.status)}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
className="bg-primary-600 hover:bg-primary-700 text-white"
|
||
asChild
|
||
>
|
||
<a
|
||
href={doc.presigned}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{t("viewDocument.document.viewDocument")}
|
||
</a>
|
||
</Button>
|
||
{doc.status !== "approved" && (
|
||
<ActionModal
|
||
documentId={doc.id}
|
||
onActionSubmit={handleActionSubmit}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Document Requirements Section */}
|
||
<div className="mt-2 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("viewDocument.document.requirements")}
|
||
</h4>
|
||
{isEachLoading ? (
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-3/4" />
|
||
</div>
|
||
) : isEachError ? (
|
||
<p className="text-sm text-red-500 dark:text-red-400">
|
||
{t("viewDocument.loading.failedToLoadRequirements")}
|
||
</p>
|
||
) : requirements.length ? (
|
||
<ul className="space-y-2 text-sm">
|
||
{requirements.map((req) => (
|
||
<li key={req.id} className="flex items-start">
|
||
<span
|
||
className={`inline-block w-2 h-2 rounded-full mt-1.5 mr-2 ${
|
||
req.isOptional
|
||
? "bg-primary-400"
|
||
: "bg-primary-400"
|
||
}`}
|
||
/>
|
||
<div>
|
||
<span className="font-medium text-gray-800 dark:text-gray-200">
|
||
{localizedName(req.title)}
|
||
</span>
|
||
<span className="text-gray-500 dark:text-gray-400 ml-1">
|
||
{req.isOptional
|
||
? t("viewDocument.document.optional")
|
||
: t("viewDocument.document.required")}
|
||
</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||
{t("viewDocument.document.noRequirements")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</ScrollArea>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default ViewDocument;
|