Files
edr-platform/apps/edr-freight-web/backoffice/src/super-admin/components/externalUsers/ViewDocuments.tsx
natib21 22cf8eabd1 fix ui
2026-07-14 07:59:17 +00:00

379 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;