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,129 +1,129 @@
import { ExternalUserDto } from "@/super-admin/dto/ExternalUsersDto";
import { format } from "date-fns";
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/shared/common/ui/badge";
import { UserStatusToggleConfirm } from "./UserStatusToggleConfirm";
import { t } from "i18next";
import { useNavigate } from "react-router-dom";
export const ExternalUsersColumnDefn = (
localizedName: (name?: { am: string; en: string }) => string,
navigate: ReturnType<typeof useNavigate>
): ColumnDef<ExternalUserDto>[] => {
return [
{
accessorKey: "name.en",
header: () => t("organization.username"),
cell: ({ row }) => (
<div className="font-medium">{localizedName(row.original.name)}</div>
),
},
{
accessorKey: "updatedAt",
header: () => t("organization.updatedOn"),
cell: ({ row }) => (
<div className="font-medium">
{format(new Date(row.original.updatedAt), "MMM dd, yyyy")}
</div>
),
},
{
accessorKey: "userType",
header: () => "User Type",
cell: ({ row }) => {
const userType = row.original.userType;
const displayText =
userType === "external_organization"
? "External Organization"
: "Employee";
return (
<div className="font-medium">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
userType === "external_organization"
? "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
}`}
>
{displayText}
</span>
</div>
);
},
},
{
id: "status",
header: () => t("dashboard.Status"),
cell: ({ row }) => {
const { status } = row.original;
const getStatusColor = (status: string) => {
switch (status) {
case "accepted":
return "bg-primary-100 text-primary-600 hover:bg-primary-100 dark:bg-primary-900/40 dark:text-primary-300 dark:hover:bg-primary-900/50";
case "rejected":
return "bg-red-100 text-red-600 hover:bg-red-100 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-900/50";
case "pending":
default:
return "bg-blue-100 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/50";
}
};
const getStatusText = (status: string) => {
switch (status) {
case "accepted":
return "Accepted";
case "rejected":
return "Rejected";
case "pending":
default:
return t("statusBar.Pending");
}
};
return (
<div>
<Badge
className={`${getStatusColor(
status
)} rounded-full px-6 py-1 font-medium`}
>
{getStatusText(status)}
</Badge>
</div>
);
},
},
{
id: "actions",
header: () => t("userRecord.Actions"),
cell: ({ row }) => {
const { id, status } = row.original;
const handleViewUploads = () => {
navigate(`/user-management/external_users/view/${id}`);
};
// const handleEdit = () => {
// navigate(`/user-management/external_users/edit/${id}`);
// };
return (
<div className="flex items-center gap-2">
<UserStatusToggleConfirm userId={id} userStatus={status} />
<button
onClick={handleViewUploads}
className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-sm font-medium text-gray-800 dark:text-gray-200"
>
{t("View")}
</button>
{/* <button
onClick={handleEdit}
className="px-2 py-1 bg-blue-100 rounded hover:bg-blue-200 text-sm font-medium"
>
{t("Edit")}
</button> */}
</div>
);
},
},
];
};
import { ExternalUserDto } from "@/super-admin/dto/ExternalUsersDto";
import { format } from "date-fns";
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/shared/common/ui/badge";
import { UserStatusToggleConfirm } from "./UserStatusToggleConfirm";
import { t } from "i18next";
import { useNavigate } from "react-router-dom";
export const ExternalUsersColumnDefn = (
localizedName: (name?: { am: string; en: string }) => string,
navigate: ReturnType<typeof useNavigate>
): ColumnDef<ExternalUserDto>[] => {
return [
{
accessorKey: "name.en",
header: () => t("organization.username"),
cell: ({ row }) => (
<div className="font-medium">{localizedName(row.original.name)}</div>
),
},
{
accessorKey: "updatedAt",
header: () => t("organization.updatedOn"),
cell: ({ row }) => (
<div className="font-medium">
{format(new Date(row.original.updatedAt), "MMM dd, yyyy")}
</div>
),
},
{
accessorKey: "userType",
header: () => "User Type",
cell: ({ row }) => {
const userType = row.original.userType;
const displayText =
userType === "external_organization"
? "External Organization"
: "Employee";
return (
<div className="font-medium">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
userType === "external_organization"
? "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
}`}
>
{displayText}
</span>
</div>
);
},
},
{
id: "status",
header: () => t("dashboard.Status"),
cell: ({ row }) => {
const { status } = row.original;
const getStatusColor = (status: string) => {
switch (status) {
case "accepted":
return "bg-primary-100 text-primary-600 hover:bg-primary-100 dark:bg-primary-900/40 dark:text-primary-300 dark:hover:bg-primary-900/50";
case "rejected":
return "bg-red-100 text-red-600 hover:bg-red-100 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-900/50";
case "pending":
default:
return "bg-blue-100 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/50";
}
};
const getStatusText = (status: string) => {
switch (status) {
case "accepted":
return "Accepted";
case "rejected":
return "Rejected";
case "pending":
default:
return t("statusBar.Pending");
}
};
return (
<div>
<Badge
className={`${getStatusColor(
status
)} rounded-full px-6 py-1 font-medium`}
>
{getStatusText(status)}
</Badge>
</div>
);
},
},
{
id: "actions",
header: () => t("userRecord.Actions"),
cell: ({ row }) => {
const { id, status } = row.original;
const handleViewUploads = () => {
navigate(`/user-management/external_users/view/${id}`);
};
// const handleEdit = () => {
// navigate(`/user-management/external_users/edit/${id}`);
// };
return (
<div className="flex items-center gap-2">
<UserStatusToggleConfirm userId={id} userStatus={status} />
<button
onClick={handleViewUploads}
className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-sm font-medium text-gray-800 dark:text-gray-200"
>
{t("View")}
</button>
{/* <button
onClick={handleEdit}
className="px-2 py-1 bg-blue-100 rounded hover:bg-blue-200 text-sm font-medium"
>
{t("Edit")}
</button> */}
</div>
);
},
},
];
};

View File

@@ -1,142 +1,142 @@
import { useMemo, useState } from "react";
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/common/ui/card";
import {
useAllExternalUsers,
userTypeEnum,
} from "@/super-admin/hooks/useExternalUsers";
import { ExternalUsersColumnDefn } from "./ExternalUsersColumnDefn";
import { useLocalizedName } from "@/shared/common/localizedName";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
import { Label } from "@/shared/common/ui/label";
import { useNavigate } from "react-router-dom";
import {
CombinedFilterBar,
CombinedFilterField,
} from "@/shared/components/filters/CombinedFilterBar";
import { useCombinedFilters } from "@/shared/hooks/useCombinedFilters";
import { FilterParams } from "@/shared/utils/filterParams";
export default function PendingExternalUsers() {
const localizedName = useLocalizedName();
const [pageIndex, setPageIndex] = useState(0);
const pageSize = 10;
const { t } = useTranslation();
const navigate = useNavigate();
const filterStore = useCombinedFilters<FilterParams>();
const [selectedUserType, setSelectedUserType] = useState<userTypeEnum>(
userTypeEnum.externalUsers,
);
const filterFields = useMemo<CombinedFilterField[]>(
() => [
{ key: "name", label: t("profile.name", "Name"), type: "text" },
{ key: "email", label: t("profile.emailmsg", "Email"), type: "text" },
{
key: "username",
label: t("profile.username", "Username"),
type: "text",
},
{
key: "phoneNumber",
label: t("profile.phoneNumber", "Phone Number"),
type: "text",
},
],
[t],
);
const { data, isLoading } = useAllExternalUsers({
take: pageSize,
skip: pageIndex * pageSize,
orderBy: "updatedAt:Desc",
userType: selectedUserType,
...filterStore.filters,
});
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
const handleFilterChange = () => {
setPageIndex(0);
};
if (isLoading) {
return (
<div className="flex justify-center p-6">
<Loader2 className="animate-spin w-6 h-6 text-gray-500 dark:text-gray-400" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="flex flex-col gap-4 px-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-xl font-semibold">All Users</CardTitle>
<RadioGroup
value={selectedUserType}
onValueChange={(val: userTypeEnum) => {
setSelectedUserType(val);
setPageIndex(0);
}}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={userTypeEnum.externalUsers}
id="allExternal"
/>
<Label htmlFor="allExternal">All External Users</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={userTypeEnum.external} id="external" />
<Label htmlFor="external">External Organization</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={userTypeEnum.individual} id="individual" />
<Label htmlFor="individual">Individual</Label>
</div>
</RadioGroup>
</CardHeader>
<CardContent className="space-y-4 px-0">
<CombinedFilterBar
fields={filterFields}
filters={filterStore.filters}
setFilter={filterStore.setFilter}
setFilters={filterStore.setFilters}
removeFilter={filterStore.removeFilter}
removeFilters={filterStore.removeFilters}
onFilterChange={handleFilterChange}
className="w-full min-w-0"
/>
<AdvancedTable
columns={ExternalUsersColumnDefn(localizedName, navigate)}
data={data?.items || []}
tableName="ExternalUsers"
toolBarPosition="right"
itemCount={data?.count || 0}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
hideToolbarFilter
disableClientFiltering
/>
</CardContent>
</Card>
</div>
);
}
import { useMemo, useState } from "react";
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/common/ui/card";
import {
useAllExternalUsers,
userTypeEnum,
} from "@/super-admin/hooks/useExternalUsers";
import { ExternalUsersColumnDefn } from "./ExternalUsersColumnDefn";
import { useLocalizedName } from "@/shared/common/localizedName";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
import { Label } from "@/shared/common/ui/label";
import { useNavigate } from "react-router-dom";
import {
CombinedFilterBar,
CombinedFilterField,
} from "@/shared/components/filters/CombinedFilterBar";
import { useCombinedFilters } from "@/shared/hooks/useCombinedFilters";
import { FilterParams } from "@/shared/utils/filterParams";
export default function PendingExternalUsers() {
const localizedName = useLocalizedName();
const [pageIndex, setPageIndex] = useState(0);
const pageSize = 10;
const { t } = useTranslation();
const navigate = useNavigate();
const filterStore = useCombinedFilters<FilterParams>();
const [selectedUserType, setSelectedUserType] = useState<userTypeEnum>(
userTypeEnum.externalUsers,
);
const filterFields = useMemo<CombinedFilterField[]>(
() => [
{ key: "name", label: t("profile.name", "Name"), type: "text" },
{ key: "email", label: t("profile.emailmsg", "Email"), type: "text" },
{
key: "username",
label: t("profile.username", "Username"),
type: "text",
},
{
key: "phoneNumber",
label: t("profile.phoneNumber", "Phone Number"),
type: "text",
},
],
[t],
);
const { data, isLoading } = useAllExternalUsers({
take: pageSize,
skip: pageIndex * pageSize,
orderBy: "updatedAt:Desc",
userType: selectedUserType,
...filterStore.filters,
});
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
const handleFilterChange = () => {
setPageIndex(0);
};
if (isLoading) {
return (
<div className="flex justify-center p-6">
<Loader2 className="animate-spin w-6 h-6 text-gray-500 dark:text-gray-400" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="flex flex-col gap-4 px-0 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-xl font-semibold">All Users</CardTitle>
<RadioGroup
value={selectedUserType}
onValueChange={(val: userTypeEnum) => {
setSelectedUserType(val);
setPageIndex(0);
}}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={userTypeEnum.externalUsers}
id="allExternal"
/>
<Label htmlFor="allExternal">All External Users</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={userTypeEnum.external} id="external" />
<Label htmlFor="external">External Organization</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={userTypeEnum.individual} id="individual" />
<Label htmlFor="individual">Individual</Label>
</div>
</RadioGroup>
</CardHeader>
<CardContent className="space-y-4 px-0">
<CombinedFilterBar
fields={filterFields}
filters={filterStore.filters}
setFilter={filterStore.setFilter}
setFilters={filterStore.setFilters}
removeFilter={filterStore.removeFilter}
removeFilters={filterStore.removeFilters}
onFilterChange={handleFilterChange}
className="w-full min-w-0"
/>
<AdvancedTable
columns={ExternalUsersColumnDefn(localizedName, navigate)}
data={data?.items || []}
tableName="ExternalUsers"
toolBarPosition="right"
itemCount={data?.count || 0}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
hideToolbarFilter
disableClientFiltering
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,137 +1,137 @@
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Textarea } from "@/shared/common/ui/textarea";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
type Status = "pending" | "submitted" | "approved" | "adjusted" | "rejected";
interface ActionModalProps {
documentId: string;
onActionSubmit: (documentId: string, status: Status, remark: string) => void;
}
const ActionModal = ({ documentId, onActionSubmit }: ActionModalProps) => {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<Status>("approved");
const [remark, setRemark] = useState("");
const { t } = useTranslation();
const handleSubmit = () => {
onActionSubmit(documentId, status, remark);
// Translate status for toast message
const getTranslatedStatus = (status: Status): string => {
switch (status) {
case "approved":
return t("actionModal.actions.approve");
case "adjusted":
return t("actionModal.actions.adjustment");
case "rejected":
return t("actionModal.actions.reject");
case "pending":
return t("actionModal.actions.pending");
case "submitted":
return t("actionModal.actions.submitted");
default:
return status;
}
};
toast.success(
t("actionModal.toast.success", {
status: getTranslatedStatus(status),
})
);
setOpen(false);
setRemark("");
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
{t("actionModal.trigger")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("actionModal.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
<div>
<label className="block text-sm font-medium mb-2">
{t("actionModal.labels.selectAction")}
</label>
<Select
value={status}
onValueChange={(val) => setStatus(val as Status)}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("actionModal.actions.selectPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="approved">
{t("actionModal.actions.approve")}
</SelectItem>
<SelectItem value="adjusted">
{t("actionModal.actions.adjustment")}
</SelectItem>
<SelectItem value="rejected">
{t("actionModal.actions.reject")}
</SelectItem>
<SelectItem value="pending">
{t("actionModal.actions.pending")}
</SelectItem>
<SelectItem value="submitted">
{t("actionModal.actions.submitted")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-2">
{t("actionModal.labels.remark")}
</label>
<Textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder={t("actionModal.labels.remarkPlaceholder")}
className="w-full"
/>
</div>
</div>
<DialogFooter className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
{t("actionModal.buttons.cancel")}
</Button>
<Button onClick={handleSubmit}>
{t("actionModal.buttons.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ActionModal;
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Textarea } from "@/shared/common/ui/textarea";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
type Status = "pending" | "submitted" | "approved" | "adjusted" | "rejected";
interface ActionModalProps {
documentId: string;
onActionSubmit: (documentId: string, status: Status, remark: string) => void;
}
const ActionModal = ({ documentId, onActionSubmit }: ActionModalProps) => {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<Status>("approved");
const [remark, setRemark] = useState("");
const { t } = useTranslation();
const handleSubmit = () => {
onActionSubmit(documentId, status, remark);
// Translate status for toast message
const getTranslatedStatus = (status: Status): string => {
switch (status) {
case "approved":
return t("actionModal.actions.approve");
case "adjusted":
return t("actionModal.actions.adjustment");
case "rejected":
return t("actionModal.actions.reject");
case "pending":
return t("actionModal.actions.pending");
case "submitted":
return t("actionModal.actions.submitted");
default:
return status;
}
};
toast.success(
t("actionModal.toast.success", {
status: getTranslatedStatus(status),
})
);
setOpen(false);
setRemark("");
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
{t("actionModal.trigger")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("actionModal.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
<div>
<label className="block text-sm font-medium mb-2">
{t("actionModal.labels.selectAction")}
</label>
<Select
value={status}
onValueChange={(val) => setStatus(val as Status)}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("actionModal.actions.selectPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="approved">
{t("actionModal.actions.approve")}
</SelectItem>
<SelectItem value="adjusted">
{t("actionModal.actions.adjustment")}
</SelectItem>
<SelectItem value="rejected">
{t("actionModal.actions.reject")}
</SelectItem>
<SelectItem value="pending">
{t("actionModal.actions.pending")}
</SelectItem>
<SelectItem value="submitted">
{t("actionModal.actions.submitted")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-2">
{t("actionModal.labels.remark")}
</label>
<Textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder={t("actionModal.labels.remarkPlaceholder")}
className="w-full"
/>
</div>
</div>
<DialogFooter className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
{t("actionModal.buttons.cancel")}
</Button>
<Button onClick={handleSubmit}>
{t("actionModal.buttons.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ActionModal;

View File

@@ -1,332 +1,332 @@
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { motion } from "framer-motion";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { CardContent } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import {
ChevronLeft,
CircleUser,
Mail,
Phone,
UserPen,
RefreshCw,
Save,
} from "lucide-react";
import { useUsersByID } from "@/super-admin/hooks/useUsers";
import { updateUserProfile } from "@/user-management/services/api/employeePositionsService";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const UpdateProfile = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { data: userData, refetch, isLoading } = useUsersByID(id!);
const [userName, setUserName] = useState("");
const [email, setEmail] = useState("");
const [englishName, setEnglishName] = useState("");
const [amharicName, setAmharicName] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [error, setError] = useState("");
const { handleError } = useErrorHandler(t);
// Populate form fields when user data is fetched
useEffect(() => {
if (userData) {
const user = userData;
setUserName(user.username ?? "");
setEmail(user.email ?? "");
setEnglishName(user.name?.en ?? "");
setAmharicName(user.name?.am ?? "");
setPhoneNumber(user.phoneNumber ?? "");
}
}, [userData]);
const container = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.1 } },
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors: { [key: string]: string } = {};
if (!email.trim()) newErrors.email = t("profile.emailRequired");
if (!userName.trim()) newErrors.userName = t("profile.usernameRequired");
if (!phoneNumber.trim()) newErrors.phoneNumber = t("profile.phoneRequired");
if (!englishName.trim())
newErrors.englishName = t("profile.englishNameRequired");
if (!amharicName.trim())
newErrors.amharicName = t("profile.amharicNameRequired");
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
// Phone number validation
const phoneRegex = /^(\+2519\d{8}|09\d{8})$/;
if (!phoneRegex.test(phoneNumber)) {
setError(t("profile.invalidPhoneFormat"));
toast.error(t("profile.invalidPhoneFormat"), {
description: t("profile.validPhoneFormat"),
});
return;
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
setError(t("profile.invalidEmailFormat"));
toast.error(t("profile.invalidEmailFormat"), {
description: t("profile.validEmailFormat"),
});
return;
}
setLoading(true);
try {
await updateUserProfile(
{
username: userName,
email,
phoneNumber,
name: { en: englishName, am: amharicName },
},
id as string,
);
toast.success(t("profile.profileUpdateSuccess"), {
description: t("profile.profileUpdated"),
});
refetch(); // refresh data after update
} catch (err: any) {
const message = err?.message || t("profile.profileUpdateFailed");
setError(message);
handleError(message);
} finally {
setLoading(false);
}
};
if (isLoading) return <div>Loading...</div>;
const user = userData;
if (!user) return <div>User not found</div>;
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100/40 dark:from-gray-900 dark:to-gray-800">
<div className="container mx-auto px-4 py-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6">
<Button
onClick={() => navigate(-1)}
variant="ghost"
className="flex items-center gap-2 text-primary hover:text-primary-700 hover:bg-primary-300/20 dark:text-primary-300 dark:hover:text-primary-300 px-4 py-2 rounded-full transition-colors">
<ChevronLeft className="w-5 h-5" />
<span className="font-medium">{t("common.back")}</span>
</Button>
</motion.div>
{/* Profile Content */}
<div className="flex justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="w-full max-w-2xl bg-white dark:bg-gray-800 rounded-2xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-700">
{/* Profile Header */}
<div className="relative bg-gradient-to-r from-primary to-primary-300 dark:from-primary-700 dark:to-primary-300 h-32">
<div className="absolute -bottom-12 left-1/2 transform -translate-x-1/2">
<motion.div
whileHover={{ scale: 1.05 }}
className="relative h-24 w-24 rounded-full border-4 border-white dark:border-gray-800 bg-white dark:bg-gray-800 shadow-lg">
<div className="absolute inset-0 bg-gradient-to-br from-primary-300 to-primary dark:from-primary-300 dark:to-primary-700 rounded-full flex items-center justify-center">
<CircleUser className="w-12 h-12 text-white" />
</div>
</motion.div>
</div>
</div>
{/* Profile Info */}
<div className="pt-14 pb-6 px-6 text-center">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{user.name.en || "___"}
</h2>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-1">
{t("profile.updateProfile")}
</p>
<div className="mt-3">
<Badge
variant="outline"
className={`rounded-full px-3 py-1 text-xs font-medium ${
user.status === "accepted"
? "bg-primary-300/20 text-primary border-primary/30"
: "bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
}`}>
{t(`statusBar.${user.status || "___"}`)}
</Badge>
</div>
</div>
{/* Update Form */}
<div className="px-6 pb-8">
<motion.form
variants={container}
initial="hidden"
animate="show"
className="space-y-4"
onSubmit={handleSubmit}>
{/* Username */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.username")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={userName}
onChange={(e) => setUserName(e.target.value)}
/>
</div>
{errors.userName && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.userName}
</p>
)}
</div>
{/* English Name */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.englishName")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={englishName}
onChange={(e) => setEnglishName(e.target.value)}
/>
</div>
{errors.englishName && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.englishName}
</p>
)}
</div>
{/* Amharic Name */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.amharicName")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={amharicName}
onChange={(e) => setAmharicName(e.target.value)}
/>
</div>
{errors.amharicName && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.amharicName}
</p>
)}
</div>
{/* Email */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.emailmsg")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
type="email"
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
{errors.email && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.email}
</p>
)}
</div>
{/* Phone Number */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.phoneNumber")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Phone className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
type="tel"
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
/>
</div>
{errors.phoneNumber && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.phoneNumber}
</p>
)}
</div>
{/* Submit Button */}
<motion.div
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="pt-4">
<Button
type="submit"
className="w-full h-12 bg-gradient-to-r from-primary to-primary-300 hover:from-primary-700 hover:to-primary-300 text-white font-medium rounded-lg shadow-md transition-all duration-200"
disabled={loading}>
{loading ? (
<span className="flex items-center justify-center">
<RefreshCw className="animate-spin h-5 w-5 mr-2" />
{t("profile.updating")}...
</span>
) : (
<span className="flex items-center justify-center">
<Save className="w-4 h-4 mr-2" />
{t("profile.updateProfile")}
</span>
)}
</Button>
</motion.div>
</motion.form>
</div>
</motion.div>
</div>
</div>
</div>
);
};
export default UpdateProfile;
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { motion } from "framer-motion";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { CardContent } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import {
ChevronLeft,
CircleUser,
Mail,
Phone,
UserPen,
RefreshCw,
Save,
} from "lucide-react";
import { useUsersByID } from "@/super-admin/hooks/useUsers";
import { updateUserProfile } from "@/user-management/services/api/employeePositionsService";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const UpdateProfile = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { data: userData, refetch, isLoading } = useUsersByID(id!);
const [userName, setUserName] = useState("");
const [email, setEmail] = useState("");
const [englishName, setEnglishName] = useState("");
const [amharicName, setAmharicName] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [error, setError] = useState("");
const { handleError } = useErrorHandler(t);
// Populate form fields when user data is fetched
useEffect(() => {
if (userData) {
const user = userData;
setUserName(user.username ?? "");
setEmail(user.email ?? "");
setEnglishName(user.name?.en ?? "");
setAmharicName(user.name?.am ?? "");
setPhoneNumber(user.phoneNumber ?? "");
}
}, [userData]);
const container = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.1 } },
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors: { [key: string]: string } = {};
if (!email.trim()) newErrors.email = t("profile.emailRequired");
if (!userName.trim()) newErrors.userName = t("profile.usernameRequired");
if (!phoneNumber.trim()) newErrors.phoneNumber = t("profile.phoneRequired");
if (!englishName.trim())
newErrors.englishName = t("profile.englishNameRequired");
if (!amharicName.trim())
newErrors.amharicName = t("profile.amharicNameRequired");
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
// Phone number validation
const phoneRegex = /^(\+2519\d{8}|09\d{8})$/;
if (!phoneRegex.test(phoneNumber)) {
setError(t("profile.invalidPhoneFormat"));
toast.error(t("profile.invalidPhoneFormat"), {
description: t("profile.validPhoneFormat"),
});
return;
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
setError(t("profile.invalidEmailFormat"));
toast.error(t("profile.invalidEmailFormat"), {
description: t("profile.validEmailFormat"),
});
return;
}
setLoading(true);
try {
await updateUserProfile(
{
username: userName,
email,
phoneNumber,
name: { en: englishName, am: amharicName },
},
id as string,
);
toast.success(t("profile.profileUpdateSuccess"), {
description: t("profile.profileUpdated"),
});
refetch(); // refresh data after update
} catch (err: any) {
const message = err?.message || t("profile.profileUpdateFailed");
setError(message);
handleError(message);
} finally {
setLoading(false);
}
};
if (isLoading) return <div>Loading...</div>;
const user = userData;
if (!user) return <div>User not found</div>;
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100/40 dark:from-gray-900 dark:to-gray-800">
<div className="container mx-auto px-4 py-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6">
<Button
onClick={() => navigate(-1)}
variant="ghost"
className="flex items-center gap-2 text-primary hover:text-primary-700 hover:bg-primary-300/20 dark:text-primary-300 dark:hover:text-primary-300 px-4 py-2 rounded-full transition-colors">
<ChevronLeft className="w-5 h-5" />
<span className="font-medium">{t("common.back")}</span>
</Button>
</motion.div>
{/* Profile Content */}
<div className="flex justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="w-full max-w-2xl bg-white dark:bg-gray-800 rounded-2xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-700">
{/* Profile Header */}
<div className="relative bg-gradient-to-r from-primary to-primary-300 dark:from-primary-700 dark:to-primary-300 h-32">
<div className="absolute -bottom-12 left-1/2 transform -translate-x-1/2">
<motion.div
whileHover={{ scale: 1.05 }}
className="relative h-24 w-24 rounded-full border-4 border-white dark:border-gray-800 bg-white dark:bg-gray-800 shadow-lg">
<div className="absolute inset-0 bg-gradient-to-br from-primary-300 to-primary dark:from-primary-300 dark:to-primary-700 rounded-full flex items-center justify-center">
<CircleUser className="w-12 h-12 text-white" />
</div>
</motion.div>
</div>
</div>
{/* Profile Info */}
<div className="pt-14 pb-6 px-6 text-center">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{user.name.en || "___"}
</h2>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-1">
{t("profile.updateProfile")}
</p>
<div className="mt-3">
<Badge
variant="outline"
className={`rounded-full px-3 py-1 text-xs font-medium ${
user.status === "accepted"
? "bg-primary-300/20 text-primary border-primary/30"
: "bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
}`}>
{t(`statusBar.${user.status || "___"}`)}
</Badge>
</div>
</div>
{/* Update Form */}
<div className="px-6 pb-8">
<motion.form
variants={container}
initial="hidden"
animate="show"
className="space-y-4"
onSubmit={handleSubmit}>
{/* Username */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.username")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={userName}
onChange={(e) => setUserName(e.target.value)}
/>
</div>
{errors.userName && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.userName}
</p>
)}
</div>
{/* English Name */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.englishName")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={englishName}
onChange={(e) => setEnglishName(e.target.value)}
/>
</div>
{errors.englishName && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.englishName}
</p>
)}
</div>
{/* Amharic Name */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.amharicName")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={amharicName}
onChange={(e) => setAmharicName(e.target.value)}
/>
</div>
{errors.amharicName && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.amharicName}
</p>
)}
</div>
{/* Email */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.emailmsg")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
type="email"
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
{errors.email && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.email}
</p>
)}
</div>
{/* Phone Number */}
<div className="space-y-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
{t("profile.phoneNumber")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Phone className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<Input
type="tel"
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
/>
</div>
{errors.phoneNumber && (
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
{errors.phoneNumber}
</p>
)}
</div>
{/* Submit Button */}
<motion.div
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="pt-4">
<Button
type="submit"
className="w-full h-12 bg-gradient-to-r from-primary to-primary-300 hover:from-primary-700 hover:to-primary-300 text-white font-medium rounded-lg shadow-md transition-all duration-200"
disabled={loading}>
{loading ? (
<span className="flex items-center justify-center">
<RefreshCw className="animate-spin h-5 w-5 mr-2" />
{t("profile.updating")}...
</span>
) : (
<span className="flex items-center justify-center">
<Save className="w-4 h-4 mr-2" />
{t("profile.updateProfile")}
</span>
)}
</Button>
</motion.div>
</motion.form>
</div>
</motion.div>
</div>
</div>
</div>
);
};
export default UpdateProfile;

View File

@@ -1,124 +1,124 @@
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useApproveExternalUser } from "@/super-admin/hooks/useExternalUsers";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { UserStatus } from "@/super-admin/dto/ExternalUsersDto";
interface Props {
userId: string;
userStatus: UserStatus;
}
export const UserStatusToggleConfirm = ({ userId, userStatus }: Props) => {
const approve = useApproveExternalUser();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const isLoading = approve.isPending;
const onConfirm = (nextStatus: UserStatus) => {
approve.mutate(
{ id: userId, status: nextStatus },
{
onSuccess: () => {
toast.success(
nextStatus === "accepted"
? "User successfully approved"
: "User successfully rejected"
);
},
onError: (error) => {
handleError(error);
},
}
);
};
return (
<div className="flex gap-2">
{/* Approve Dialog - Only show for pending users */}
{userStatus === "pending" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="bg-primary-600 text-white hover:bg-primary-700"
size="sm"
disabled={isLoading}>
{t("statusBar.Approve")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("organization.approveUserPrompt")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("organization.approveUserDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => onConfirm("accepted")}
disabled={isLoading}>
{isLoading
? t("organization.approving")
: t("organization.yesApprove")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Reject Dialog - Only show for pending users */}
{userStatus === "pending" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="bg-red-600 text-white hover:bg-red-700"
size="sm"
disabled={isLoading}>
{t("statusBar.Reject")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("organization.rejectUserPrompt")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("organization.rejectUserDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => onConfirm("rejected")}
disabled={isLoading}>
{isLoading
? t("organization.rejecting")
: t("organization.yesReject")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);
};
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useApproveExternalUser } from "@/super-admin/hooks/useExternalUsers";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { UserStatus } from "@/super-admin/dto/ExternalUsersDto";
interface Props {
userId: string;
userStatus: UserStatus;
}
export const UserStatusToggleConfirm = ({ userId, userStatus }: Props) => {
const approve = useApproveExternalUser();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const isLoading = approve.isPending;
const onConfirm = (nextStatus: UserStatus) => {
approve.mutate(
{ id: userId, status: nextStatus },
{
onSuccess: () => {
toast.success(
nextStatus === "accepted"
? "User successfully approved"
: "User successfully rejected"
);
},
onError: (error) => {
handleError(error);
},
}
);
};
return (
<div className="flex gap-2">
{/* Approve Dialog - Only show for pending users */}
{userStatus === "pending" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="bg-primary-600 text-white hover:bg-primary-700"
size="sm"
disabled={isLoading}>
{t("statusBar.Approve")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("organization.approveUserPrompt")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("organization.approveUserDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => onConfirm("accepted")}
disabled={isLoading}>
{isLoading
? t("organization.approving")
: t("organization.yesApprove")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Reject Dialog - Only show for pending users */}
{userStatus === "pending" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="bg-red-600 text-white hover:bg-red-700"
size="sm"
disabled={isLoading}>
{t("statusBar.Reject")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("organization.rejectUserPrompt")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("organization.rejectUserDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => onConfirm("rejected")}
disabled={isLoading}>
{isLoading
? t("organization.rejecting")
: t("organization.yesReject")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);
};

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;