This commit is contained in:
natib21
2026-07-10 11:25:59 +00:00
parent 2696ca7498
commit e6e44e773b
1146 changed files with 200266 additions and 90527 deletions

View File

@@ -1,378 +1,378 @@
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-blue-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-blue-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;
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-blue-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-blue-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;