mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 10:40:58 +00:00
fix ui
This commit is contained in:
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user