user management ui

This commit is contained in:
yaschalew
2026-07-10 10:41:48 +03:00
parent dcb2d98503
commit 28a20923ff
595 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import { configureStore } from "@reduxjs/toolkit";
import dashboardReducer from "../components/dashboard/dashboardSlice";
export const store = configureStore({
reducer: {
dashboard: dashboardReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,44 @@
import { Activity } from "@/super-admin/hooks/useDashboardData";
import { formatDistanceToNow } from "date-fns";
import { t } from "i18next";
interface ActivityTimelineProps {
activities: Activity[];
}
export const ActivityTimeline = ({ activities }: ActivityTimelineProps) => {
if (!activities || activities.length === 0) {
return (
<div className="text-sm text-muted-foreground text-center py-4">
{t("organization.noRecentActivities")}
</div>
);
}
return (
<div className="relative pl-6">
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-purple-500" />
<ul className="space-y-6">
{activities.map((activity) => (
<li key={activity.id} className="relative pl-4">
<div className="absolute left-0 top-1 w-3 h-3 bg-purple-500 rounded-full" />
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(activity.timestamp), {
addSuffix: true,
})}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{activity.description}
</div>
<div className="text-xs">
By{" "}
<span className="font-medium text-purple-600">
{activity.user}
</span>
</div>
</li>
))}
</ul>
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { useTenantConfig } from "@/layout/components/TenantConfig";
export const HeaderBar = () => {
const { config: tenantConfig } = useTenantConfig();
return (
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{tenantConfig.appName} Dashboard
</h1>
<button className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm">
+ Add Organization
</button>
</div>
);
};

View File

@@ -0,0 +1,72 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import { Badge } from "@/shared/common/ui/badge";
import { formatDistanceToNow } from "date-fns";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import { t } from "i18next";
interface OrgTableProps {
organizations: OrganizationDto[];
}
export const OrgTable = ({ organizations }: OrgTableProps) => {
if (!organizations || organizations.length === 0) {
return (
<div className="text-sm text-muted-foreground text-center py-4 border dark:border-gray-700 rounded-xl p-6 dark:bg-gray-800">
No organizations found
</div>
);
}
return (
<div className="rounded-xl overflow-hidden shadow-sm border bg-white dark:bg-gray-800 dark:border-gray-700">
<Table>
<TableHeader className="bg-white dark:bg-gray-800 text-muted-foreground">
<TableRow>
<TableHead className="px-6 py-3">{t("organization.organizationName")}</TableHead>
{/* <TableHead className="px-6 py-3">{t("organization.key")}</TableHead> */}
<TableHead className="px-6 py-3">{t("organization.createdOn")}</TableHead>
<TableHead className="px-6 py-3">{t("dashboard.Status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{organizations.map((org, index) => (
<TableRow
key={org.id}
className={index % 2 ? "bg-purple-50/20 dark:bg-gray-700/40" : "bg-white dark:bg-gray-800"}
>
<TableCell className="px-6 py-3 font-medium">
{org.name?.en || "N/A"}
</TableCell>
{/* <TableCell className="px-6 py-3">{org.key || "N/A"}</TableCell> */}
<TableCell className="px-6 py-3">
{org.createdAt
? formatDistanceToNow(new Date(org.createdAt), {
addSuffix: true,
})
: "N/A"}
</TableCell>
<TableCell className="px-6 py-3">
<Badge
variant={org.status === "Active" ? "default" : "outline"}
className={
org.status === "Active"
? "bg-primary-100 text-primary-800 hover:bg-primary-100 dark:bg-primary-900/50 dark:text-primary-300"
: "bg-gray-100 text-gray-800 hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300"
}
>
{org.status === "Active" ? t("statusBar.activate") : t("statusBar.deactivate")}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { Card, CardContent } from "@/shared/common/ui/card";
import { cn } from "@/super-admin/lib/utils";
import { LucideIcon, Building2, Clock, User } from "lucide-react";
interface StatCardProps {
title: string;
value: number;
indicator?: string;
color: string;
icon: "building" | "clock" | "user";
variant?: "primary" | "default";
onClick?: () => void;
}
const iconMap: Record<string, LucideIcon> = {
building: Building2,
clock: Clock,
user: User,
};
export const StatCard: React.FC<StatCardProps> = ({
title,
value,
indicator,
color,
icon,
variant = "default",
onClick,
}) => {
const Icon = iconMap[icon];
return (
<Card
onClick={onClick}
className={cn(
"p-4 rounded-xl shadow-sm transition-colors",
variant === "primary" ? "bg-purple-600 text-white" : "bg-white dark:bg-gray-800 dark:border-gray-700",
onClick && "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
)}
>
<CardContent className="p-0">
<div className="flex justify-between items-center mb-4">
<div className="text-sm font-medium opacity-80">{title}</div>
<Icon className="w-5 h-5 opacity-60" />
</div>
<div className="text-3xl font-bold leading-snug">{value}</div>
{indicator && (
<div className={cn("text-xs mt-1", color)}>{indicator}</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,26 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface DashboardState {
totalOrgs: number;
pendingRequests: number;
orgAdmins: number;
}
const initialState: DashboardState = {
totalOrgs: 0,
pendingRequests: 0,
orgAdmins: 0,
};
const dashboardSlice = createSlice({
name: "dashboard",
initialState,
reducers: {
setStats(state, action: PayloadAction<DashboardState>) {
return { ...state, ...action.payload };
},
},
});
export const { setStats } = dashboardSlice.actions;
export default dashboardSlice.reducer;

View File

@@ -0,0 +1,34 @@
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { getDashboardStats } from "@/super-admin/services/api/dashboardApi";
import { DashboardStatsDto } from "../types/dashboardStatsDto";
export const useDashboardStats = () => {
const {
data: stats,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["dashboardStats"],
queryFn: async (): Promise<DashboardStatsDto> => {
try {
return await getDashboardStats();
} catch (err) {
toast("Failed to fetch dashboard stats. Using mock data.");
return err instanceof Error
? Promise.reject(new Error(err.message))
: Promise.reject(new Error("Failed to fetch dashboard stats."));
// Fallback to mock data if API call fails
}
},
staleTime: 5 * 60 * 1000,
});
return {
stats,
isLoading,
isError,
refetch,
};
};

View File

@@ -0,0 +1,10 @@
export interface DashboardStatsDto {
totalOrgs: number;
pendingRequests: number;
orgAdmins: number;
percentChanges: {
orgs: number;
pending: number;
admins: number;
};
}

View File

@@ -0,0 +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>
);
},
},
];
};

View File

@@ -0,0 +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>
);
}

View File

@@ -0,0 +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;

View File

@@ -0,0 +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;

View File

@@ -0,0 +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>
);
};

View File

@@ -0,0 +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;

View File

@@ -0,0 +1,481 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Label } from "@/shared/common/ui/label";
import { ScrollArea } from "@/shared/common/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { useEmployees } from "@/user-management/hooks/useEmployees";
import { cn } from "@/super-admin/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../shared/common/ui/dialog";
import { useTranslation } from "react-i18next";
import {
RemoveOrAssignUnitAdminPayload,
assignUnitAdminRole,
} from "@/super-admin/services/api/userRoleService";
import { useQueryClient } from "@tanstack/react-query";
import { Input } from "@/shared/common/ui/input";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useUnit } from "@/user-management/hooks/useUnit";
export function AssignAdminDialog({
onSuccess,
onClose,
isOpen,
organizationId,
unitId,
}: {
onSuccess: () => void;
isOpen: boolean;
onClose: () => void;
organizationId?: string;
unitId?: string;
}) {
const queryClient = useQueryClient();
const [selectedOrg, setSelectedOrg] = useState<string>(organizationId ?? "");
const [selectedUnit, setSelectedUnit] = useState<string>(unitId ?? "");
const [selectedAssignmentUnit, setSelectedAssignmentUnit] =
useState<string>("");
const [selectedUser, setSelectedUser] = useState<string>("");
const [isAssigning, setIsAssigning] = useState<boolean>(false);
const [search, setSearch] = useState("");
const { t } = useTranslation();
const [page, setPage] = useState(0);
const take = 3000; // items per page
const localizedName = useLocalizedName();
const hasPreselectedUnit = !!organizationId && !!unitId;
const organizationIdForAssignment = organizationId || selectedOrg;
const unitIdForAssignment =
unitId || selectedUnit || selectedAssignmentUnit;
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
"Org",
{
take: 300,
}
);
const { data: unitsResponse, isLoading: isLoadingUnits } = useUnit().getList(
organizationIdForAssignment,
{ take: 300, skip: 0 },
!hasPreselectedUnit
);
const {
employeesResponseByOrg,
isLoadingEmployeesByOrg: isLoadingEmployees,
refetchEmployeesByOrg,
} = useEmployees({
organizationId: organizationIdForAssignment || undefined,
unitId: unitIdForAssignment || undefined,
params: {
take,
skip: page * take,
},
});
// Refetch when unit selection changes
useEffect(() => {
if (unitIdForAssignment) {
refetchEmployeesByOrg();
}
}, [unitIdForAssignment, refetchEmployeesByOrg]);
useEffect(() => {
if (selectedOrg && !hasPreselectedUnit) {
setSelectedUnit("");
setSelectedAssignmentUnit("");
setSelectedUser("");
setPage(0);
}
}, [hasPreselectedUnit, selectedOrg]);
useEffect(() => {
if (selectedUnit && !hasPreselectedUnit) {
setSelectedAssignmentUnit("");
setSelectedUser("");
setPage(0);
}
}, [hasPreselectedUnit, selectedUnit]);
useEffect(() => {
if (!isOpen) return;
setSelectedOrg(organizationId ?? "");
setSelectedUnit(unitId ?? "");
setSelectedAssignmentUnit("");
setSelectedUser("");
setPage(0);
setSearch("");
}, [isOpen, organizationId, unitId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (
!selectedUser ||
!organizationIdForAssignment ||
!unitIdForAssignment
) {
toast.error("All fields are required");
return;
}
const user = employeesResponseByOrg?.items.find(
(e) => e.user.id === selectedUser
);
if (!user) return toast.error("User not found");
setIsAssigning(true);
const payload: RemoveOrAssignUnitAdminPayload = {
unitId: unitIdForAssignment,
userId: user.user.id,
};
assignUnitAdminRole(payload)
.then(() => {
queryClient.invalidateQueries({
queryKey: ["organizationAdmins"],
});
queryClient.invalidateQueries({
queryKey: ["unitAdmins", unitIdForAssignment],
});
toast.success(t("organization.userAssignedSuccess"));
setIsAssigning(false);
onClose();
onSuccess();
})
.catch((error) => {
toast.error(t("organization.userAssignFailed"), {
description: error?.response?.data?.message,
});
setIsAssigning(false);
});
};
const resetForm = () => {
setSelectedOrg(organizationId ?? "");
setSelectedUnit(unitId ?? "");
setSelectedAssignmentUnit("");
setSelectedUser("");
setPage(0);
setSearch("");
};
const filteredEmployees = useMemo(() => {
if (!employeesResponseByOrg?.items) return [];
return employeesResponseByOrg.items.filter((emp) => {
const name = emp.user.name?.en?.toLowerCase() || "";
const email = emp.user.email?.toLowerCase() || "";
const query = search.toLowerCase();
return name.includes(query) || email.includes(query);
});
}, [employeesResponseByOrg, search]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={hasPreselectedUnit ? "sm:max-w-[550px]" : "sm:max-w-[900px]"}
>
<DialogHeader>
<DialogTitle>
{hasPreselectedUnit
? t("contentManagement.manageUnitAdmin", "Manage Unit Admin")
: t("organization.assignAdminToOrganization")}
</DialogTitle>
<DialogDescription>
{hasPreselectedUnit
? t(
"organization.assignUnitAdminInstructions",
"Select a user to assign as an administrator for this unit."
)
: t("organization.assignAdminInstructions")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div
className={
hasPreselectedUnit
? "grid grid-cols-1 gap-4"
: "grid grid-cols-3 gap-4"
}
>
{/* Step 1: Organizations */}
{!hasPreselectedUnit && <div className="space-y-2">
<Label className="font-semibold text-sm">
{t("organization.organizations")} <span className="text-red-500">*</span>
</Label>
{isLoadingOrgs ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<ScrollArea className="h-[300px] border border-gray-200 dark:border-gray-700 rounded-md p-2">
<div className="space-y-1">
{organizationsResponse?.items?.map((org) => (
<button
key={`org-${org.id}`}
onClick={() => setSelectedOrg(org.id)}
type="button"
className={cn(
"w-full flex items-center justify-between px-3 py-2 rounded-md text-left text-sm transition-colors",
selectedOrg === org.id
? "bg-green-200 text-green-800 dark:bg-green-900/40 dark:text-green-300"
: "hover:bg-green-100 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-300"
)}
>
<span>{localizedName(org.name)}</span>
{selectedOrg === org.id && (
<Check className="h-4 w-4" />
)}
</button>
))}
</div>
</ScrollArea>
)}
</div>}
{/* Step 2: Units */}
{!hasPreselectedUnit && <div className="space-y-2">
<Label className="font-semibold text-sm">
{t("organization.units")} <span className="text-red-500">*</span>
</Label>
{!selectedOrg ? (
<div className="h-[300px] border border-gray-200 dark:border-gray-700 rounded-md p-4 flex items-center justify-center text-gray-500 dark:text-gray-400">
<p className="text-sm text-center">{t("organization.selectOrganizationFirst")}</p>
</div>
) : isLoadingUnits ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<ScrollArea className="h-[300px] border border-gray-200 dark:border-gray-700 rounded-md p-2">
<div className="space-y-1">
<button
onClick={() => setSelectedUnit("")}
type="button"
className={cn(
"w-full flex items-center justify-between px-3 py-2 rounded-md text-left text-sm transition-colors",
!selectedUnit
? "bg-blue-200 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
: "hover:bg-blue-100 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-300"
)}
>
<span>
{t(
"organization.allOrganizationUsers",
"All organization users"
)}
</span>
{!selectedUnit && <Check className="h-4 w-4" />}
</button>
{unitsResponse?.data?.items?.map((unit: any) => (
<button
key={`unit-${unit.id}`}
onClick={() => setSelectedUnit(unit.id)}
type="button"
className={cn(
"w-full flex items-center justify-between px-3 py-2 rounded-md text-left text-sm transition-colors",
selectedUnit === unit.id
? "bg-blue-200 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
: "hover:bg-blue-100 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-300"
)}
>
<span>{localizedName(unit.name)}</span>
{selectedUnit === unit.id && (
<Check className="h-4 w-4" />
)}
</button>
))}
{unitsResponse?.data?.items?.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-2">
{t("organization.noUnitsFound")}
</p>
)}
</div>
</ScrollArea>
)}
</div>}
{/* Step 3: Users */}
<div className="space-y-2">
<Label className="font-semibold text-sm">
{t("organization.users")} <span className="text-red-500">*</span>
</Label>
{!organizationIdForAssignment ? (
<div className="h-[300px] border border-gray-200 dark:border-gray-700 rounded-md p-4 flex items-center justify-center text-gray-500 dark:text-gray-400">
<p className="text-sm text-center">{t("organization.selectOrganizationFirst")}</p>
</div>
) : isLoadingEmployees ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<div className="space-y-2">
{!unitIdForAssignment && (
<div className="space-y-2">
<Label className="text-xs font-medium text-gray-600 dark:text-gray-300">
{t(
"organization.unitForAdminRole",
"Unit for admin role"
)}{" "}
<span className="text-red-500">*</span>
</Label>
<Select
value={selectedAssignmentUnit}
onValueChange={setSelectedAssignmentUnit}
disabled={isLoadingUnits}
>
<SelectTrigger className="h-9 border border-gray-200 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-gray-100 text-sm [&>span]:truncate w-full">
<SelectValue
placeholder={t("organization.selectUnit")}
/>
</SelectTrigger>
<SelectContent className="w-full min-w-[200px]">
{unitsResponse?.data?.items?.map((unit: any) => (
<SelectItem key={unit.id} value={unit.id}>
<span className="truncate">{localizedName(unit.name)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Input
placeholder={
unitIdForAssignment
? t("organization.searchUsers")
: t(
"organization.searchOrganizationUsers",
"Search organization users"
)
}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-gray-200 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-gray-100 text-sm"
/>
{!unitIdForAssignment && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{t(
"organization.selectUnitToFilterUsers",
"Select a unit to filter this organization user list."
)}
</p>
)}
<ScrollArea className="h-[250px] border border-gray-200 dark:border-gray-700 rounded-md p-2">
<div className="space-y-1">
{filteredEmployees.map((emp) => (
<button
key={`emp-${emp.id || emp.user.id}`}
onClick={() => setSelectedUser(emp.user.id)}
type="button"
className={cn(
"w-full flex items-center justify-between px-3 py-2 rounded-md text-left text-sm transition-colors",
selectedUser === emp.user.id
? "bg-purple-200 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
: "hover:bg-purple-100 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-300"
)}
>
<div className="flex-1">
<p className="font-medium">{emp.user.name?.en || emp.user.email}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{emp.user.email}</p>
</div>
{selectedUser === emp.user.id && (
<Check className="h-4 w-4 ml-2" />
)}
</button>
))}
{filteredEmployees.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-2">
{t("organization.noUsersFound")}
</p>
)}
</div>
</ScrollArea>
{/* Pagination */}
<div className="flex items-center justify-between mt-2 px-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPage((prev) => Math.max(prev - 1, 0))}
disabled={page === 0}
>
{"<"}
</Button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t("common.page")} {page + 1}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const total = employeesResponseByOrg?.count ?? 0;
const maxPage = Math.ceil(total / take) - 1;
setPage((prev) => Math.min(prev + 1, maxPage));
}}
disabled={
!employeesResponseByOrg?.count ||
(page + 1) * take >= employeesResponseByOrg.count
}
>
{">"}
</Button>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
resetForm();
onClose();
}}
disabled={isAssigning}
>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={
isAssigning ||
!organizationIdForAssignment ||
!unitIdForAssignment ||
!selectedUser
}
className="bg-green-600 hover:bg-green-700"
>
{isAssigning
? t("organization.assigning")
: t("organization.assignUser")}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,277 @@
import { FormEvent, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Check, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { ScrollArea } from "@/shared/common/ui/scroll-area";
import { useLocalizedName } from "@/shared/common/localizedName";
import { cn } from "@/super-admin/lib/utils";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import {
assignOrgAdminRole,
RemoveOrAssignOrgAdminPayload,
} from "@/super-admin/services/api/userRoleService";
import { useEmployees } from "@/user-management/hooks/useEmployees";
interface AssignOrgAdminDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function AssignOrgAdminDialog({
isOpen,
onClose,
onSuccess,
}: AssignOrgAdminDialogProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [selectedOrg, setSelectedOrg] = useState("");
const [selectedUser, setSelectedUser] = useState("");
const [search, setSearch] = useState("");
const [isAssigning, setIsAssigning] = useState(false);
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
"Org",
{ take: 300 }
);
const {
employeesResponseByOrg,
isLoadingEmployeesByOrg: isLoadingEmployees,
} = useEmployees({
organizationId: selectedOrg || undefined,
params: { take: 3000, skip: 0 },
});
const filteredEmployees = useMemo(() => {
const query = search.trim().toLowerCase();
const employees = employeesResponseByOrg?.items ?? [];
if (!query) return employees;
return employees.filter((employee) => {
const name = localizedName(employee.user.name).toLowerCase();
const email = employee.user.email?.toLowerCase() ?? "";
return name.includes(query) || email.includes(query);
});
}, [employeesResponseByOrg, localizedName, search]);
const resetForm = () => {
setSelectedOrg("");
setSelectedUser("");
setSearch("");
};
const handleClose = () => {
if (isAssigning) return;
resetForm();
onClose();
};
const handleOrganizationChange = (organizationId: string) => {
setSelectedOrg(organizationId);
setSelectedUser("");
setSearch("");
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedOrg || !selectedUser) {
toast.error(t("organization.allFieldsRequired", "All fields are required"));
return;
}
const payload: RemoveOrAssignOrgAdminPayload = {
organizationId: selectedOrg,
userId: selectedUser,
};
setIsAssigning(true);
try {
await assignOrgAdminRole(payload);
await queryClient.invalidateQueries({
queryKey: ["organizationAdmins"],
});
resetForm();
onClose();
onSuccess();
} catch (error: any) {
toast.error(t("organization.userAssignFailed"), {
description: error?.response?.data?.message,
});
} finally {
setIsAssigning(false);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent className="sm:max-w-[750px]">
<DialogHeader>
<DialogTitle>
{t(
"organization.assignAdminToOrganization",
"Assign admin to organization"
)}
</DialogTitle>
<DialogDescription>
{t(
"organization.assignOrgAdminInstructions",
"Select an organization and a user to assign as its administrator."
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("organization.organizations")}{" "}
<span className="text-red-500">*</span>
</Label>
{isLoadingOrgs ? (
<div className="flex h-[350px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<ScrollArea className="h-[350px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
<div className="space-y-1">
{organizationsResponse?.items?.map((organization) => (
<button
key={organization.id}
type="button"
onClick={() =>
handleOrganizationChange(organization.id)
}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
selectedOrg === organization.id
? "bg-green-200 text-green-800 dark:bg-green-900/40 dark:text-green-300"
: "text-gray-700 hover:bg-green-100 dark:text-gray-300 dark:hover:bg-green-900/30"
)}
>
<span>{localizedName(organization.name)}</span>
{selectedOrg === organization.id && (
<Check className="h-4 w-4" />
)}
</button>
))}
</div>
</ScrollArea>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("organization.users")}{" "}
<span className="text-red-500">*</span>
</Label>
{!selectedOrg ? (
<div className="flex h-[350px] items-center justify-center rounded-md border border-gray-200 p-4 text-gray-500 dark:border-gray-700 dark:text-gray-400">
<p className="text-center text-sm">
{t("organization.selectOrganizationFirst")}
</p>
</div>
) : isLoadingEmployees ? (
<div className="flex h-[350px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<div className="space-y-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t(
"organization.searchOrganizationUsers",
"Search organization users"
)}
/>
<ScrollArea className="h-[308px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
<div className="space-y-1">
{filteredEmployees.map((employee) => (
<button
key={employee.user.id}
type="button"
onClick={() => setSelectedUser(employee.user.id)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
selectedUser === employee.user.id
? "bg-purple-200 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
: "text-gray-700 hover:bg-purple-100 dark:text-gray-300 dark:hover:bg-purple-900/30"
)}
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{localizedName(employee.user.name) ||
employee.user.email}
</p>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
{employee.user.email}
</p>
</div>
{selectedUser === employee.user.id && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</button>
))}
{filteredEmployees.length === 0 && (
<p className="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
{t("organization.noUsersFound")}
</p>
)}
</div>
</ScrollArea>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isAssigning}
>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={isAssigning || !selectedOrg || !selectedUser}
className="bg-green-600 hover:bg-green-700"
>
{isAssigning && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isAssigning
? t("organization.assigning")
: t("organization.assignUser")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,243 @@
import { useEffect, useRef, useState } from "react";
import { useOrganizationAdmins } from "@/super-admin/hooks/useOrganizationAdmins";
import { AssignedAdminsDto } from "@/shared/dto/organization/orgAdminsDto";
import { toast } from "@/shared/common/ui/use-toast";
import { useUserRoles } from "@/super-admin/hooks/useUserRoles";
import { AdminRemoveConfirm } from "./RemoveAdminConfirm";
import { Button } from "@/shared/common/ui/button";
import { useLocalizedName } from "@/shared/common/localizedName";
import { Phone, ChevronLeft, ChevronRight } from "lucide-react";
export const OrganizationAdminList = ({
organizationId,
}: {
organizationId: string;
}) => {
const { resendInvitation, getAllAdminById } = useOrganizationAdmins();
const { removeUnitAdmin } = useUserRoles();
const localizedName = useLocalizedName();
const [admins, setAdmins] = useState<AssignedAdminsDto[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isResending, setIsResending] = useState<boolean>(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [resendingEmail, setResendingEmail] = useState<string | null>(null);
const [take, setTake] = useState(10);
const [skip, setSkip] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const pageSize = 10;
const hasFetched = useRef(false);
useEffect(() => {
const fetchAdmins = async () => {
try {
setIsLoading(true);
const res = await getAllAdminById(organizationId || "", { take, skip });
setAdmins(res?.data?.items || []);
// Set total count only on first load
if (!hasFetched.current && res?.data?.count) {
setTotalCount(res.data.count);
hasFetched.current = true;
}
} catch (err) {
toast({
title: "Error",
description: "Failed to fetch admins.",
variant: "destructive",
});
console.error("Fetch admins error:", err);
} finally {
setIsLoading(false);
}
};
if (organizationId) {
fetchAdmins();
}
}, [take, skip, organizationId]);
const handleNextPage = () => {
const newSkip = skip + pageSize;
if (newSkip < totalCount) {
setSkip(newSkip);
setTake((prev) => prev + pageSize);
}
};
const handlePrevPage = () => {
if (skip > 0) {
const newSkip = Math.max(0, skip - pageSize);
setSkip(newSkip);
setTake((prev) => Math.max(pageSize, prev - pageSize));
}
};
const currentPage = Math.floor(skip / pageSize) + 1;
const totalPages = Math.ceil(totalCount / pageSize);
const hasNextPage = skip + pageSize < totalCount;
const hasPrevPage = skip > 0;
const refetchAdmins = () => {
// Reset to first page and refetch
setTake(10);
setSkip(0);
hasFetched.current = false;
};
const handleRemove = async (adminId: string) => {
try {
setDeletingId(adminId);
await removeUnitAdmin({
organizationId,
userId: adminId,
});
refetchAdmins();
toast({
title: "Admin removed successfully.",
variant: "success",
style: {
padding: "8px 16px",
minHeight: "36px",
color: "var(--primary-800)",
backgroundColor: "var(--primary-100)",
fontWeight: "600",
fontSize: "14px",
},
});
} catch (err) {
toast({
title: "Error",
description: "Failed to remove admin.",
variant: "destructive",
});
console.error("Remove admin error:", err);
} finally {
setDeletingId(null);
}
};
const handleResend = async (admin: AssignedAdminsDto) => {
try {
setIsResending(true);
setResendingEmail(admin.email || null);
await resendInvitation({
email: admin.email || "",
phoneNumber: admin.phoneNumber || "",
});
toast({
title: "Success",
description: "Invitation sent successfully.",
variant: "success",
style: {
padding: "8px 16px",
minHeight: "36px",
color: "var(--primary-800)",
backgroundColor: "var(--primary-100)",
fontWeight: "600",
fontSize: "14px",
},
});
} catch (err) {
toast({
title: "Error",
description: "Failed to resend invitation.",
variant: "destructive",
});
console.error("Resend invitation error:", err);
} finally {
setIsResending(false);
setResendingEmail(null);
}
};
if (isLoading) {
return <p className="text-sm text-muted-foreground">Loading admins...</p>;
}
if (admins.length === 0) {
return <p className="text-sm text-muted-foreground">No admins assigned.</p>;
}
return (
<div className="space-y-4">
<div className="space-y-3">
{admins.map((admin) => (
<div
key={admin.id}
className="rounded-xl p-4 bg-white dark:bg-gray-800 shadow flex justify-between items-center border border-gray-200 dark:border-gray-700 transition hover:bg-primary-50 dark:hover:bg-primary-900/20"
>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{localizedName(admin?.name) || "Unnamed"}
</p>
<p className="text-xs text-gray-600 dark:text-gray-300">
{admin?.email}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Username: {admin?.username}
</p>
{/* ✅ Fixed phone number UI */}
<div className="flex items-center gap-1 text-sm text-gray-900 dark:text-gray-100">
<Phone className="w-4 h-4 text-gray-500 dark:text-gray-400" />
<span>{admin?.phoneNumber || "No phone number"}</span>
</div>
</div>
<div className="flex items-center gap-2">
{!admin.hasSetPassword && (
<Button
className="bg-white dark:bg-gray-900 text-primary-600 dark:text-primary-400 border border-primary-600 dark:border-primary-500 hover:bg-primary-600 dark:hover:bg-primary-700 hover:text-white"
onClick={() => handleResend(admin)}
disabled={isResending && resendingEmail === admin.email}
>
{isResending && resendingEmail === admin.email
? "Sending..."
: "Resend"}
</Button>
)}
<AdminRemoveConfirm
onConfirm={() => handleRemove(admin.id)}
loading={deletingId === admin.id}
/>
</div>
</div>
))}
</div>
{/* Pagination Controls */}
{totalCount > pageSize && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={!hasPrevPage}
className="flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<span className="text-sm text-gray-600 dark:text-gray-300">
Page {currentPage} of {totalPages} ({totalCount} total)
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={!hasNextPage}
className="flex items-center gap-2"
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,134 @@
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Link } from "react-router-dom";
import { Plus, Loader2, UserPlus } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/common/ui/card";
import { toast } from "sonner";
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
export default function OrganizationAdmins() {
const [pageIndex, setPageIndex] = useState(0); // starts at 0
const pageSize = 10;
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const { organizationsAdminsResponse, isLoading, isError, refetch } =
useOrganizations("Admin", {
take: pageSize,
skip: pageIndex * pageSize,
orderBy: "createdAt",
order: "createdAt:Desc",
name: searchTerm || undefined,
});
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
const handleSearchChange = (term: string) => {
setSearchTerm(term);
setPageIndex(0); // Reset to first page when search term changes
};
const handleRefresh = () => {
refetch();
};
if (isLoading) {
return (
<div className="p-6 flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-sm text-muted-foreground">
{t("organization.loadingAdmins")}
</div>
</div>
</div>
);
}
if (isError) {
return (
<div className="p-6 flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4">
<div className="text-red-500 font-medium">
{t("organization.errorLoadingAdmins")}
</div>
<Button variant="outline" onClick={() => refetch()}>
{t("organization.retry")}
</Button>
</div>
</div>
);
}
return (
<>
<div className="p-6 space-y-6 dark:bg-gray-900">
<Card className="col-span-2 shadow-none border-none bg-transparent dark:bg-transparent px-0">
<CardHeader className="flex flex-row justify-between items-center px-0">
<CardTitle className="text-xl font-semibold dark:text-white">
{t("organization.organizationAdmins")}
</CardTitle>
</CardHeader>
<CardContent className="px-0 dark:bg-transparent">
<AdvancedTable
columns={OrganizationAdminsColumnDefn(
localizedName as (name?: { am?: string; en?: string }) => string
)}
data={organizationsAdminsResponse?.items || []}
tableName="Organization Admins"
toolBarPosition="right"
refresh={handleRefresh}
onGlobalFilterChange={handleSearchChange}
disableClientFiltering={true}
extraToolbar={
<div className="flex gap-2">
<Button
onClick={() => setIsAssignDialogOpen(true)}
className="bg-[#4A6CF7] hover:bg-[#3a5ad4] text-white px-5 py-2 rounded-md text-sm font-medium shadow-md"
>
<UserPlus className="w-4 h-4 mr-2" />
{t("organization.assignAdmin")}
</Button>
<Link to="/user-management/add_admin">
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
<Plus className="w-4 h-4 mr-2" />
{t("organization.addAdmin")}
</Button>
</Link>
</div>
}
itemCount={organizationsAdminsResponse?.count || 0}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
{/* Assign Admin Dialog */}
<AssignOrgAdminDialog
isOpen={isAssignDialogOpen}
onClose={() => setIsAssignDialogOpen(false)}
onSuccess={() => {
refetch();
toast.success(t("organization.adminAssignedSuccess"));
}}
/>
</>
);
}

View File

@@ -0,0 +1,80 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../../shared/common/ui/dropdown-menu";
import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react";
import { Button } from "../../../shared/common/ui/button";
import { Link } from "react-router-dom";
import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService";
import { toast } from "sonner";
interface OrganizationAdminsActionsProps {
rowData: OrganizationAdmin;
}
const OrganizationAdminsActions: React.FC<OrganizationAdminsActionsProps> = ({
rowData,
}) => {
const handleDeleteClick = () => {
toast.warning("Delete functionality not implemented yet");
};
const handleStatusChange = (status: string) => {
toast.info(`Admin status change to ${status} not implemented yet`);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to={`/add_admin?id=${rowData.id}`}
className="flex items-center w-full"
>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{rowData.status !== "active" && (
<DropdownMenuItem
onClick={() => handleStatusChange("active")}
className="flex items-center"
>
<UserCheck className="mr-2 h-4 w-4" />
Activate
</DropdownMenuItem>
)}
{rowData.status === "active" && (
<DropdownMenuItem
onClick={() => handleStatusChange("inactive")}
className="flex items-center"
>
<UserX className="mr-2 h-4 w-4" />
Deactivate
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleDeleteClick()}
className="flex items-center"
>
<Trash className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default OrganizationAdminsActions;

View File

@@ -0,0 +1,77 @@
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "../../../shared/common/ui/button";
import { ArrowUpDown, Eye } from "lucide-react";
import { OrganizationAdminsDto } from "@/shared/dto/organization/organizationDto";
import { StatusCell } from "./StatusCell";
import { t } from "i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
import { Link } from "react-router-dom";
import OrganizationAdminsActions from "./OrganizationAdminsActions";
export const OrganizationAdminsColumnDefn = (
localizedName: (name?: { am?: string; en?: string }) => string,
): ColumnDef<OrganizationAdminsDto>[] => {
return [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="p-0 hover:bg-transparent text-gray-700 dark:text-gray-300"
style={{
padding: 0,
}}
>
{t("organization.name")}
<ArrowUpDown className="p-0 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<div className="font-medium">{localizedName(row.original.name)}</div>
),
},
{
accessorKey: "status",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="p-0 hover:bg-transparent text-gray-700 dark:text-gray-300"
style={{
padding: 0,
}}
>
{t("dashboard.Status")}
<ArrowUpDown className="h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const id = row.original.id;
return (
<StatusCell
id={id}
// isAssigned={row.original.isAssigned}
adminsCount={row.original.adminsCount}
/>
);
},
},
{
accessorKey: "actions",
header: t("organization.actions") || "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<Link to={`/super-admin/organizations/${row.original.id}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
<OrganizationAdminsActions rowData={row.original as any} />
</div>
),
},
];
};

View File

@@ -0,0 +1,59 @@
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useTranslation } from "react-i18next";
interface AdminRemoveConfirmProps {
onConfirm: () => void;
loading?: boolean;
}
export const AdminRemoveConfirm = ({
onConfirm,
loading,
}: AdminRemoveConfirmProps) => {
const { t } = useTranslation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={loading}>
{t("common.remove")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("organization.removeAdminTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("organization.removeAdminDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={loading}
className="bg-red-600 hover:bg-red-700"
>
{loading
? t("organization.removingAdmin")
: t("organization.confirmRemoveAdmin")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,50 @@
import { useState } from "react";
import { Eye } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import { OrganizationAdminList } from "./OrganizationAdminList";
export const StatusCell = ({
id,
adminsCount,
}: {
id: string;
adminsCount: number;
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<div className="flex items-center gap-2">
<div className="font-medium">
{adminsCount > 0
?
`${adminsCount} Admin${adminsCount !== 1 ? "s" : ""}`
: "No Admin"}
</div>
{adminsCount > 0 && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="ghost" className="p-1 h-auto w-auto">
<Eye className="h-4 w-4 text-gray-500 dark:text-gray-400 hover:text-primary" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Assigned Admins</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto pr-2">
<OrganizationAdminList organizationId={id} />
</div>
</DialogContent>
</Dialog>
)}
</div>
);
};

View File

@@ -0,0 +1,272 @@
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/common/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Label } from "@/shared/common/ui/label";
import { useOrganizationAdmins } from "@/super-admin/hooks/useOrganizationAdmins";
import { OrganizationAdminPayload, UnitAdminPayload } from "@/super-admin/services/api/organizationAdminService";
import { useNavigate } from "react-router-dom";
import { t } from "i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useEffect, useState } from "react";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnit } from "@/user-management/hooks/useUnit";
const simplifiedSchema = z.object({
name: z.object({
am: z.string().min(1, t("organization.amharicNameRequired")),
en: z.string().min(1, t("organization.englishNameRequired")),
}),
email: z.string().email(t("organization.invalidEmail")),
username: z.string().min(3, t("organization.usernameMinLength")),
unitId: z.string().min(1, t("organization.organizationRequired")),
phone: z
.string()
.regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")),
});
type SimplifiedFormValues = z.infer<typeof simplifiedSchema>;
export const AdminRegistrationForm = () => {
const navigate = useNavigate();
const localizedName = useLocalizedName();
const { organizationsResponse } = useOrganizations("Org", {
take: 3000,
});
const { createUnitAdmin, isCreating } = useOrganizationAdmins();
const { user } = useAuth();
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
const { data: unitsResponse, isLoading: isLoadingUnits } = useUnit().getList(
selectedOrgId || "",
{ take: 300, skip: 0 }
);
// Add state for selected unitId
// Default: if super_admin => "All", otherwise wait for units
const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
useEffect(() => {
// If theres no selectedUnitId yet, default to first unit (if any), otherwise keep "All"
if (!selectedUnitId) {
if (unitsResponse?.data?.items?.length) {
setSelectedUnitId(unitsResponse.data.items[0].id);
} else {
setSelectedUnitId("All");
}
}
}, [unitsResponse, selectedUnitId]);
const form = useForm<SimplifiedFormValues>({
resolver: zodResolver(simplifiedSchema),
defaultValues: {
name: { am: "", en: "" },
email: "",
username: "",
unitId: "",
phone: "",
},
});
const onSubmit = async (values: SimplifiedFormValues) => {
const payload: UnitAdminPayload = {
email: values.email,
name: values.name,
unitId: values.unitId,
username: values.username,
phoneNumber: values.phone,
};
createUnitAdmin({
payload,
successCallback: () => {
navigate("/user-management/organization_admins");
},
});
};
return (
<Card className="max-w-2xl mx-auto shadow-md border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle className="text-xl font-semibold text-gray-800 dark:text-gray-100">
{t("organization.registerAdminTitle")}
</CardTitle>
<CardDescription className="text-gray-500 dark:text-gray-400">
{t("organization.registerAdminDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name.en">
{t("organization.nameEnglish")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="name.en"
{...form.register("name.en")}
placeholder={t("organization.enterEnglishName")}
/>
{form.formState.errors.name?.en && (
<p className="text-red-500 text-xs">
{form.formState.errors.name.en.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="name.am">
{t("organization.nameAmharic")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="name.am"
{...form.register("name.am")}
placeholder={t("organization.enterAmharicName")}
/>
{form.formState.errors.name?.am && (
<p className="text-red-500 text-xs">
{form.formState.errors.name.am.message}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">
{t("organization.email")} <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
{...form.register("email")}
placeholder={t("organization.emailExample")}
/>
{form.formState.errors.email && (
<p className="text-red-500 text-xs">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="username">
{t("organization.username")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="username"
{...form.register("username")}
placeholder={t("organization.enterUsername")}
/>
{form.formState.errors.username && (
<p className="text-red-500 text-xs">
{form.formState.errors.username.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">
{t("organization.phoneNumber")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
{...form.register("phone")}
placeholder={t("organization.phoneNumberExample")}
/>
{form.formState.errors.phone && (
<p className="text-red-500 text-xs">
{form.formState.errors.phone.message}
</p>
)}
</div>
<div className="space-y-2 ">
<Label htmlFor="organizationId">
{t("organization.organization")}{" "}
<span className="text-red-500">*</span>
</Label>
<Select
value={selectedOrgId}
onValueChange={(val: string) => {
setSelectedOrgId(val);
form.setValue("unitId", ""); // reset unit when org changes
}}>
<SelectTrigger>
<SelectValue
placeholder={t("organization.selectOrganization")}
/>
</SelectTrigger>
<SelectContent>
{organizationsResponse?.items
.filter((org) => org.status === "Active")
.map((org) => (
<SelectItem key={org.id} value={org.id}>
{localizedName(org.name)}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.unitId && (
<p className="text-red-500 text-xs">
{form.formState.errors.unitId.message}
</p>
)}
</div>
<Select
value={form.watch("unitId")}
onValueChange={(val: string) => form.setValue("unitId", val)}
disabled={!selectedOrgId || isLoadingUnits}>
<SelectTrigger>
<SelectValue placeholder={t("organization.selectUnit")} />
</SelectTrigger>
<SelectContent>
{unitsResponse?.data?.items?.map((unit:any) => (
<SelectItem key={unit.id} value={unit.id}>
{localizedName(unit.name)}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.unitId && (
<p className="text-red-500 text-xs">
{form.formState.errors.unitId.message}
</p>
)}
<div className="pt-4">
<Button
type="submit"
disabled={isCreating}
className="bg-primary hover:bg-primary/90 text-primary-foreground">
{isCreating
? t("organization.registering")
: t("organization.registerAdmin")}
</Button>
</div>
</form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,62 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
import { Trash2 } from "lucide-react";
import { ReactNode } from "react";
interface DeleteOrganizationPopupProps {
organizationName: string;
onDelete: () => void;
onCancel: () => void;
isLoading: boolean;
trigger: ReactNode;
}
export const DeleteOrganizationPopup = ({
organizationName,
onDelete,
onCancel,
isLoading,
trigger,
}: DeleteOrganizationPopupProps) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("organization.deleteOrganization")}</AlertDialogTitle>
<AlertDialogDescription>
{t("organization.confirmDelete")}{" "}
<strong>{organizationName}</strong>? {t("organization.cannotUndo")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={onCancel}
disabled={isLoading}
className="cursor-pointer"
>
{t("common.Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white cursor-pointer"
disabled={isLoading}
onClick={onDelete}
>
{isLoading ? t("organization.deleting") : t("organization.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,53 @@
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { OrganizationPayload } from "@/shared/services/organizationsService";
import { useEffect, useState } from "react";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import OrganizationForm, { FormValues } from "./common/OrganizationForm";
interface EditOrganizationFormProps {
id: string;
}
export const EditOrganizationForm: React.FC<EditOrganizationFormProps> = ({
id,
}) => {
const [organizationDetails, setOrganizationDetails] =
useState<OrganizationDto>();
const { editOrganization, isEditing, getOrganizationByDetails } =
useOrganizations("Org");
const fetchOrganizationDetails = () => {
getOrganizationByDetails(id, {
onSuccess: (organization: OrganizationDto) => {
setOrganizationDetails(organization);
},
});
};
useEffect(() => {
fetchOrganizationDetails();
}, []);
const onSubmit = (values: FormValues) => {
const requestData: OrganizationPayload = {
key: values.key,
name: values.name,
organizationTypeId: values.organizationTypeId,
isGovernmentOrganization: values.isGovernmentOrganization,
};
if (values.parentId && values.parentId !== "") {
requestData["parentId"] = values.parentId;
}
editOrganization({ id, payload: requestData });
};
return (
<OrganizationForm
isLoading={isEditing}
onSubmit={(values: FormValues) => onSubmit(values)}
type="Edit"
organizationDetails={organizationDetails}
/>
);
};

View File

@@ -0,0 +1,597 @@
import { useEffect, useState } from "react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
DrawerFooter,
DrawerClose,
} from "@/shared/common/ui/drawer";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/shared/common/ui/select";
import { toast } from "sonner";
import {
useCreateGlobalUnitConfig,
useGetListOfGlobalUnitConfig,
useUpdateGlobalUnitConfig,
useDeleteGlobalUnitConfig,
useGetOrganizationConfig,
useCreateOrganizationConfig,
useUpdateOrganizationConfig,
useDeleteOrganizationConfig,
} from "@/super-admin/hooks/useConfig";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Loader2, Trash2 } from "lucide-react";
import { useUnit } from "@/user-management/hooks/useUnit";
import { UnitDto } from "@/user-management/dto/unit/unitDto";
import { useLocalizedName } from "@/shared/common/localizedName";
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
import { Switch } from "@/shared/common/ui/switch";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
import { useMutation, useQuery } from "@tanstack/react-query";
export default function ManageOrganizationDrawer({
manageOrgConfigOpen,
setManageOrgConfigOpen,
organizationId,
}: any) {
const { getList } = useUnit();
const [unit, setUnit] = useState("");
const [maxUnits, setMaxUnits] = useState("");
const [employees, setEmployees] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [canReceiveComplaint, setCanReceiveComplaint] = useState(false);
const [unitConfigurationId, setUnitConfigurationId] = useState<string | null>(
null,
);
const { data: unitsResponse } = organizationId
? getList(organizationId, {
take: 300,
skip: 0,
})
: { data: undefined };
const localizedName = useLocalizedName();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const unitConfigQuery = useGetListOfGlobalUnitConfig(organizationId);
const orgConfigQuery = useGetOrganizationConfig(organizationId);
const [isUnitConfig, setIsUnitConfig] = useState(false);
const listData = isUnitConfig ? unitConfigQuery.data : orgConfigQuery.data;
const isLoading = isUnitConfig
? unitConfigQuery.isLoading
: orgConfigQuery.isLoading;
const refetch = isUnitConfig
? unitConfigQuery.refetch
: orgConfigQuery.refetch;
const createUnitMutation = useCreateGlobalUnitConfig();
const updateUnitMutation = useUpdateGlobalUnitConfig();
const deleteUnitMutation = useDeleteGlobalUnitConfig();
const createOrgMutation = useCreateOrganizationConfig();
const updateOrgMutation = useUpdateOrganizationConfig();
const deleteOrgMutation = useDeleteOrganizationConfig();
const isSubmitting = isUnitConfig
? createUnitMutation.isPending || updateUnitMutation.isPending
: createOrgMutation.isPending || updateOrgMutation.isPending;
const deleteMutation = isUnitConfig ? deleteUnitMutation : deleteOrgMutation;
// Org config is 1-per-organization. Auto-load the existing record into edit
// mode so submit becomes PUT instead of POST.
const existingOrgConfig = (() => {
const items = orgConfigQuery.data?.data?.items ?? [];
if (!Array.isArray(items)) return null;
return (
items.find(
(it: any) =>
(it?.organizationId ?? it?.organization_id) === organizationId,
) ??
items[0] ??
null
);
})();
// Unit config is 1-per-unit. Look up by the selected unit.
const existingUnitConfig = (() => {
if (!unit) return null;
const items = unitConfigQuery.data?.data?.items ?? [];
if (!Array.isArray(items)) return null;
return (
items.find((it: any) => (it?.unitId ?? it?.unit_id) === unit) ?? null
);
})();
const singleUnitId =
isUnitConfig &&
unitsResponse?.data?.items &&
Array.isArray(unitsResponse.data.items) &&
unitsResponse.data.items.length === 1
? unitsResponse.data.items[0].id
: null;
useEffect(() => {
if (!singleUnitId) return;
if (unit) return; // keep user selection if already set
setUnit(singleUnitId);
}, [isUnitConfig, singleUnitId, unit]);
useEffect(() => {
if (isUnitConfig) return;
if (existingOrgConfig?.id && editingId !== existingOrgConfig.id) {
setEditingId(existingOrgConfig.id);
setMaxUnits((existingOrgConfig.maximumNumberOfUnits ?? 0).toString());
}
}, [isUnitConfig, existingOrgConfig, editingId]);
useEffect(() => {
if (!isUnitConfig) return;
if (existingUnitConfig?.id && editingId !== existingUnitConfig.id) {
setEditingId(existingUnitConfig.id);
setMaxUnits((existingUnitConfig.numberOfSubUnits ?? 0).toString());
setEmployees(
(existingUnitConfig.numberOfEmployeesPerUnit ?? 0).toString(),
);
}
}, [isUnitConfig, existingUnitConfig, editingId]);
const handleAddOrUpdate = () => {
if (isUnitConfig && !unit) {
toast.warning(t("superAdmin.messages.selectUnitWarning"));
return;
}
if (isUnitConfig ? !maxUnits || !employees : !maxUnits) {
toast.warning(t("superAdmin.messages.fillAllFields"));
return;
}
if (isUnitConfig) {
const payload = {
unitId: unit,
numberOfSubUnits: Number(maxUnits),
numberOfEmployeesPerUnit: Number(employees),
};
const id = editingId || existingUnitConfig?.id || null;
if (id) {
updateUnitMutation.mutate(
{ id, data: payload },
{
onSuccess: () => {
toast.success(t("superAdmin.messages.configUpdated"));
resetForm();
refetch();
},
onError: handleError,
},
);
} else {
createUnitMutation.mutate(payload, {
onSuccess: () => {
toast.success(t("superAdmin.messages.configAdded"));
resetForm();
refetch();
},
onError: (error: any) => handleDuplicateOrError(error),
});
}
return;
}
// Org config — preserve other fields (e.g. canStartReceivingRecord) from
// the existing record so we don't clobber the visibility toggle.
const payload = {
organizationId,
maximumNumberOfUnits: Number(maxUnits),
...(existingOrgConfig?.canStartReceivingRecord !== undefined && {
canStartReceivingRecord: existingOrgConfig.canStartReceivingRecord,
}),
...(existingOrgConfig?.canCreateBranchByItself !== undefined && {
canCreateBranchByItself: existingOrgConfig.canCreateBranchByItself,
}),
};
const id = editingId || existingOrgConfig?.id || null;
if (id) {
updateOrgMutation.mutate(
{ id, data: payload },
{
onSuccess: () => {
toast.success(t("superAdmin.messages.configUpdated"));
resetForm();
refetch();
},
onError: handleError,
},
);
} else {
createOrgMutation.mutate(payload, {
onSuccess: () => {
toast.success(t("superAdmin.messages.configAdded"));
resetForm();
refetch();
},
onError: (error: any) => handleDuplicateOrError(error),
});
}
};
const handleDuplicateOrError = (error: any) => {
const status = error?.response?.status;
const message: string =
error?.response?.data?.message ?? error?.message ?? "";
if (status === 400 && /duplicate/i.test(message)) {
toast.warning(t("superAdmin.messages.configAlreadyExists"));
refetch();
return;
}
handleError(error);
};
const resetForm = () => {
setUnit("");
setMaxUnits("");
setEmployees("");
setEditingId(null);
setCanReceiveComplaint(false);
setUnitConfigurationId(null);
};
const handleEdit = (config: any) => {
if (isUnitConfig) {
setEditingId(config.id);
setUnit(config.unitId ?? "");
setMaxUnits((config.numberOfSubUnits ?? 0).toString());
setEmployees((config.numberOfEmployeesPerUnit ?? 0).toString());
} else {
setEditingId(config.id);
setMaxUnits((config.maximumNumberOfUnits ?? 0).toString());
}
};
const handleDelete = (config: any) => {
if (!config?.id) return;
deleteMutation.mutate(config.id, {
onSuccess: () => {
toast.success(t("superAdmin.messages.configDeleted"));
if (editingId === config.id) resetForm();
refetch();
},
onError: handleError,
});
};
const configType = isUnitConfig
? t("superAdmin.manageConfig.unit")
: t("superAdmin.manageConfig.organization");
const configLevel = isUnitConfig
? t("superAdmin.manageConfig.unitBased")
: t("superAdmin.manageConfig.organizationLevel");
const internalUnitConfigQuery = useQuery({
queryKey: ["internal-unit-config", unit],
queryFn: () => prefixSuffixService.getInternalConfig(unit),
enabled: isUnitConfig && Boolean(unit),
staleTime: Infinity,
gcTime: Infinity,
});
useEffect(() => {
if (!isUnitConfig || !unit) {
setUnitConfigurationId(null);
setCanReceiveComplaint(false);
return;
}
const internalConfig = internalUnitConfigQuery.data?.data?.items?.[0];
if (!internalConfig?.id) {
setUnitConfigurationId(null);
setCanReceiveComplaint(false);
return;
}
setUnitConfigurationId(internalConfig.id);
setCanReceiveComplaint(Boolean(internalConfig.canReceiveComplaint));
}, [internalUnitConfigQuery.data, isUnitConfig, unit]);
const { mutate: updateCanReceiveComplaint, isPending: isUpdatingComplaint } =
useMutation({
mutationFn: async (checked: boolean) => {
if (!unit) throw new Error("Missing unit id");
// If no internal unit config exists yet, create it first.
if (!unitConfigurationId) {
await prefixSuffixService.createInternalPrefixSuffix({
unitId: unit,
});
}
const current = await prefixSuffixService.getInternalConfig(unit);
const internalConfig = current.data?.items?.[0];
if (!internalConfig?.id) {
throw new Error("Failed to resolve unit configuration id");
}
return prefixSuffixService.updateInternalPrefixSuffix(
{
canReceiveComplaint: checked,
unitId: unit,
},
internalConfig.id,
);
},
onSuccess: (_data, checked) => {
setCanReceiveComplaint(checked);
internalUnitConfigQuery.refetch();
},
onError: (err) => {
console.error(err);
handleError(err);
},
});
return (
<Drawer open={manageOrgConfigOpen} onOpenChange={setManageOrgConfigOpen}>
<DrawerContent className="max-h-[85vh] overflow-hidden flex flex-col top-1/6 bg-white dark:bg-gray-900">
<div className="overflow-y-auto flex-1">
<DrawerHeader className="pb-4">
<DrawerTitle className="text-lg text-primary-700 dark:text-primary-400">
{t("superAdmin.manageConfig.title", { type: configType })}
</DrawerTitle>
<DrawerDescription className="text-primary-600/80 dark:text-primary-300/80 space-y-3">
<div>
{t("superAdmin.manageConfig.description", {
level: configLevel,
})}
</div>
{/* Radio Group for Organization/Unit selection */}
<RadioGroup
value={isUnitConfig ? "unit" : "org"}
onValueChange={(value) => {
setIsUnitConfig(value === "unit");
resetForm();
}}
className="inline-flex rounded-md bg-gray-100 dark:bg-gray-800 p-1">
<RadioGroupItem value="org" id="org-radio" className="hidden" />
<label
htmlFor="org-radio"
className={`cursor-pointer px-4 py-1 rounded-md text-sm font-medium transition-all
${
!isUnitConfig
? "bg-primary-600 text-white"
: "text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/30"
}`}>
{t("superAdmin.manageConfig.organization")}
</label>
<RadioGroupItem
value="unit"
id="unit-radio"
className="hidden"
/>
<label
htmlFor="unit-radio"
className={`cursor-pointer px-4 py-1 rounded-md text-sm font-medium transition-all
${
isUnitConfig
? "bg-primary-600 text-white"
: "text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/30"
}`}>
{t("superAdmin.manageConfig.unit")}
</label>
</RadioGroup>
{/* Unit Selector */}
{isUnitConfig && unitsResponse?.data?.items?.length > 0 && (
<div className="pt-2">
<label className="block text-sm font-medium text-primary-700 dark:text-primary-300">
{t("superAdmin.manageConfig.selectUnit")}
</label>
<Select
value={unit}
onValueChange={(value) => setUnit(value)}>
<SelectTrigger className="mt-1 border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500">
<SelectValue
placeholder={t(
"superAdmin.manageConfig.selectUnitPlaceholder",
)}
/>
</SelectTrigger>
<SelectContent>
{unitsResponse?.data.items.map((u: UnitDto) => (
<SelectItem key={u.id} value={u.id}>
{localizedName(u.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</DrawerDescription>
</DrawerHeader>
<div className="px-6 space-y-4 pb-4">
{/* Input Fields */}
<div
className={`grid gap-4 ${
isUnitConfig ? "grid-cols-2" : "grid-cols-1"
}`}>
<div>
<label className="text-sm font-medium text-primary-700 dark:text-primary-300">
{isUnitConfig
? t("superAdmin.manageConfig.subOrganizations")
: t(
"superAdmin.manageConfig.maxUnits",
"Maximum Number of Units",
)}
</label>
<Input
type="number"
min="0"
value={maxUnits}
onChange={(e) => setMaxUnits(e.target.value)}
placeholder={t("superAdmin.manageConfig.subOrgsPlaceholder")}
className="border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500"
/>
</div>
{isUnitConfig && (
<div>
<label className="text-sm font-medium text-primary-700 dark:text-primary-300">
{t("superAdmin.manageConfig.employeesPerOrg")}
</label>
<Input
type="number"
min="0"
value={employees}
onChange={(e) => setEmployees(e.target.value)}
placeholder={t(
"superAdmin.manageConfig.employeesPlaceholder",
)}
className="border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500"
/>
</div>
)}
{isUnitConfig && (
<div className="col-span-2 mt-1">
<div className="flex items-center justify-between rounded-lg border border-primary-200 bg-primary-50/40 px-4 py-3">
<div>
<div className="text-sm font-semibold text-primary-700 dark:text-primary-300">
{t(
"superAdmin.manageConfig.canReceiveComplaint",
"Can Receive Complaint",
)}
</div>
<div className="text-xs text-primary-600/70">
{t(
"superAdmin.manageConfig.canReceiveComplaintDescription",
"Controls whether this unit can accept complaints.",
)}
</div>
</div>
<Switch
checked={canReceiveComplaint}
onCheckedChange={(checked) =>
updateCanReceiveComplaint(checked)
}
disabled={!unit || isUpdatingComplaint}
/>
</div>
</div>
)}
</div>
{/* Add / Update Button */}
<Button
onClick={handleAddOrUpdate}
className="w-full flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white"
disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
{editingId ||
(!isUnitConfig && existingOrgConfig?.id) ||
(isUnitConfig && existingUnitConfig?.id)
? t("superAdmin.manageConfig.updateConfiguration")
: t("superAdmin.manageConfig.addConfiguration")}
</Button>
{/* Configurations List */}
<div className="border border-primary-300 dark:border-primary-800 rounded-lg p-3 space-y-3 mt-4">
{isLoading ? (
<p className="text-sm text-primary-600/70 text-center py-4">
{t("superAdmin.manageConfig.loading")}
</p>
) : listData?.data?.items?.length ? (
<div className="max-h-48 overflow-y-auto">
{listData.data.items.map((c: any) => (
<div
key={c.id}
className="flex items-center justify-between border-b border-primary-100 pb-2 last:border-none py-2">
<div>
{isUnitConfig && (
<p className="font-semibold capitalize text-primary-700 text-sm">
{localizedName(
unitsResponse?.data?.items?.find(
(u: UnitDto) => u.id === c.unitId,
)?.name,
) || c.unitId}
</p>
)}
<p className="text-xs text-primary-600/80">
{isUnitConfig ? (
<>
{c.numberOfSubUnits}{" "}
{t("superAdmin.manageConfig.subOrganizations")} {" "}
{c.numberOfEmployeesPerUnit}{" "}
{t("superAdmin.manageConfig.employeesPerOrg")}
</>
) : (
<>
{t(
"superAdmin.manageConfig.maxUnits",
"Maximum Number of Units",
)}
: {c.maximumNumberOfUnits ?? 0}
</>
)}
</p>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(c)}
className="h-8 px-2 text-xs text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30">
{t("superAdmin.manageConfig.edit")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(c)}
disabled={
deleteMutation.isPending &&
deleteMutation.variables === c.id
}
className="h-8 w-8 p-0 text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30">
{deleteMutation.isPending &&
deleteMutation.variables === c.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 size={14} />
)}
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-primary-600/70 text-center py-4">
{t("superAdmin.manageConfig.noConfigurations")}
</p>
)}
</div>
</div>
</div>
<DrawerFooter className="pt-4 border-t border-primary-200 dark:border-primary-800 bg-primary-50/40 dark:bg-primary-900/10">
<DrawerClose asChild>
<Button
variant="outline"
onClick={resetForm}
className="w-full border-primary-500 dark:border-primary-700 text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30">
{t("superAdmin.manageConfig.close")}
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,29 @@
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { OrganizationPayload } from "@/shared/services/organizationsService";
import OrganizationForm, { FormValues } from "./common/OrganizationForm";
export const NewOrganizationForm = () => {
const { createOrganization, isCreating } = useOrganizations("Org");
const onSubmit = (values: FormValues) => {
const requestData: OrganizationPayload = {
key: values.key,
name: values.name,
organizationTypeId: values.organizationTypeId,
isGovernmentOrganization: values.isGovernmentOrganization,
};
if (values.parentId && values.parentId !== "") {
requestData["parentId"] = values.parentId;
}
createOrganization(requestData);
};
return (
<OrganizationForm
isLoading={isCreating}
onSubmit={(values: FormValues) => onSubmit(values)}
type="Create"
/>
);
};

View File

@@ -0,0 +1,153 @@
import { useLocalizedName } from "@/shared/common/localizedName";
import { Badge } from "@/shared/common/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { SelectItem } from "@/shared/common/ui/select";
import { Separator } from "@/shared/common/ui/separator";
import { useOrganizationDetail } from "@/super-admin/hooks/useOrganizations";
import { useOrganizationTypes } from "@/super-admin/hooks/useOrganizationTypes";
import { Building, Building2, Home, MapPin, ShieldCheck } from "lucide-react";
interface OrganizationCardProps {
id: string;
}
export const OrganizationCard: React.FC<OrganizationCardProps> = ({ id }) => {
const localized = useLocalizedName();
const { organizationsDetailResponse, isDetailLoading, isDetailError } =
useOrganizationDetail("Org", id);
const {
organizationTypesResponse,
isLoading: isLoadingOrgTypes,
isError: orgTypesError,
} = useOrganizationTypes();
if (isDetailLoading) return <div>Loading...</div>;
if (isDetailError || !organizationsDetailResponse)
return <div>Error loading organization.</div>;
const org = organizationsDetailResponse.items;
const orgType = org.organizationTypeId;
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<Card className="w-full max-w-3xl mx-auto shadow-xl rounded-2xl border border-gray-200 bg-white dark:bg-zinc-900 transition hover:shadow-2xl">
<CardHeader className="flex items-center gap-3 pb-2 border-b border-gray-200 dark:border-gray-700">
<Building2 className="w-7 h-7 text-primary" />
<CardTitle className="text-2xl font-bold">
{localized(org.name)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status & Type Badges */}
<div className="flex flex-wrap gap-2">
{/* Government / Private */}
<Badge
variant={org.isGovernmentOrganization ? "default" : "outline"}
className={`px-3 py-1 rounded-xl ${
org.isGovernmentOrganization
? "bg-primary-100 text-primary-800 dark:bg-primary-800 dark:text-primary-100"
: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100"
}`}>
{org.isGovernmentOrganization ? "Government" : "Private"}
</Badge>
{/* Status: Active / Debarred */}
<Badge
variant="default"
className={`px-3 py-1 rounded-xl ${
org.status === "Active"
? "bg-primary-100 text-primary-800 dark:bg-primary-800 dark:text-primary-100"
: "bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100"
}`}>
{org.status}
</Badge>
</div>
<Separator className="my-2" />
{/* Key Details */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm text-gray-700 dark:text-gray-300">
{/* Created At */}
<div className="order-1">
<p className="font-semibold">Created At:</p>
<p>{formatDate(org.createdAt)}</p>
</div>
{/* Updated At */}
<div className="order-2">
<p className="font-semibold">Updated At:</p>
<p>{formatDate(org.updatedAt)}</p>
</div>
{/* Key spans full width and comes last */}
<div className="sm:col-span-2 order-3">
<p className="font-semibold">Key:</p>
<p className="break-words">{org.key}</p>
</div>
</div>
<Separator className="my-2" />
<div className="flex flex-wrap gap-2 items-center">
{orgType &&
organizationTypesResponse?.items
?.filter((type) => type.id === orgType)
.map((type) => {
let Icon;
let bgColor;
let textColor;
switch (type.key) {
case "super_admin":
Icon = ShieldCheck;
bgColor = "bg-purple-100 dark:bg-purple-800";
textColor = "text-purple-800 dark:text-purple-100";
break;
case "woreda":
Icon = MapPin;
bgColor = "bg-blue-100 dark:bg-blue-800";
textColor = "text-blue-800 dark:text-blue-100";
break;
case "subcity":
Icon = Home;
bgColor = "bg-primary-100 dark:bg-primary-800";
textColor = "text-primary-800 dark:text-primary-100";
break;
case "office":
Icon = Building;
bgColor = "bg-yellow-100 dark:bg-yellow-800";
textColor = "text-yellow-800 dark:text-yellow-100";
break;
default:
Icon = Building;
bgColor = "bg-gray-100 dark:bg-gray-800";
textColor = "text-gray-800 dark:text-gray-100";
}
return (
<span
key={type.id}
className={`flex items-center gap-1 px-3 py-1 rounded-xl font-medium ${bgColor} ${textColor}`}>
<Icon className="w-4 h-4" />
{localized(type.name)}
</span>
);
})}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,263 @@
import { useState, useMemo, useEffect } from "react";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
import { OrganizationsColumnDefn } from "./OrganizationsColumnDefn";
import { Link } from "react-router-dom";
import { Loader, Plus, ChevronDown, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/common/ui/card";
import { WithPermission } from "@/shared/hooks/useHas";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import { useLocalizedName } from "@/shared/common/localizedName";
export default function Organizations() {
const [pageIndex, setPageIndex] = useState(0); // starts at 0
const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set()
);
const [searchTerm, setSearchTerm] = useState("");
const pageSize = 10; // 10 organizations per page
// Reset to first page whenever the search term changes so users land on
// the first page of matches instead of an empty later page.
useEffect(() => {
setPageIndex(0);
}, [searchTerm]);
const { t } = useTranslation();
const localizedName = useLocalizedName();
const { organizationsResponse, isLoading, refetch } = useOrganizations(
"Org",
{
take: 1000, // Get all organizations to build hierarchy
skip: 0,
orderBy: "createdAt",
order: "createdAt:Desc",
}
);
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
const toggleExpanded = (parentId: string) => {
setExpandedParents((prev) => {
const newSet = new Set(prev);
if (newSet.has(parentId)) {
newSet.delete(parentId);
} else {
newSet.add(parentId);
}
return newSet;
});
};
// Create a map of organizations for quick parent lookup
const organizationMap = useMemo(() => {
const map = new Map<string, OrganizationDto>();
(organizationsResponse?.items || []).forEach((org) => {
map.set(org.id, org);
});
return map;
}, [organizationsResponse?.items]);
// Build hierarchical structure for dropdown display
const buildHierarchy = (orgs: OrganizationDto[]) => {
const root: OrganizationDto[] = [];
const children = new Map<string, OrganizationDto[]>();
// Separate root and children
orgs.forEach((org) => {
if (org.parentId) {
if (!children.has(org.parentId)) {
children.set(org.parentId, []);
}
children.get(org.parentId)!.push(org);
} else {
root.push(org);
}
});
return { root, children };
};
const allOrganizations = useMemo(
() => organizationsResponse?.items || [],
[organizationsResponse?.items],
);
const { root, children } = buildHierarchy(allOrganizations);
// When searching, match across ALL orgs (including children) by name in
// either language and display the results as a flat list. When not
// searching, fall back to the normal hierarchical view of root orgs.
const trimmedSearch = searchTerm.trim().toLowerCase();
const isSearching = trimmedSearch.length > 0;
const searchMatches = useMemo(() => {
if (!isSearching) return [] as OrganizationDto[];
return allOrganizations.filter(
(org) =>
(org.name?.en || "").toLowerCase().includes(trimmedSearch) ||
(org.name?.am || "").toLowerCase().includes(trimmedSearch),
);
}, [allOrganizations, isSearching, trimmedSearch]);
// The base list to paginate over — full root in normal mode, the flat set
// of matched orgs in search mode.
const baseList = isSearching ? searchMatches : root;
// Paginate
const startIndex = pageIndex * pageSize;
const endIndex = startIndex + pageSize;
const paginatedBase = baseList.slice(startIndex, endIndex);
// Build the final organizations list
const organizations = useMemo(() => {
const result: (OrganizationDto & {
isChild?: boolean;
parentName?: string;
})[] = [];
if (isSearching) {
// Flat search results — annotate with parent name if it's a child
paginatedBase.forEach((org) => {
const parent = org.parentId ? organizationMap.get(org.parentId) : null;
result.push({
...org,
isChild: !!org.parentId,
parentName: parent ? parent.name.en : undefined,
});
});
return result;
}
paginatedBase.forEach((parent) => {
// Add parent organization
result.push(parent);
// Add children if parent is expanded
if (expandedParents.has(parent.id)) {
const parentChildren = children.get(parent.id) || [];
parentChildren.forEach((child) => {
result.push({
...child,
isChild: true,
parentName: parent.name.en,
});
});
}
});
return result;
}, [paginatedBase, children, expandedParents, isSearching, organizationMap]);
// Create custom column definition with expandable functionality
const customColumns = useMemo(() => {
return OrganizationsColumnDefn.map((column) => {
if ("accessorKey" in column && column.accessorKey === "name.en") {
return {
...column,
cell: ({ row }: any) => {
const org = row.original;
const isChild = org.isChild || false;
const parentName = org.parentName;
const hasChildren = children.has(org.id);
const isExpanded = expandedParents.has(org.id);
return (
<div className="flex items-center gap-2 min-w-0">
{hasChildren && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 flex-shrink-0"
onClick={() => toggleExpanded(org.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
)}
<div className="font-medium flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
{isChild && (
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0"></span>
)}
<div className="min-w-0 flex-1">
<div className="truncate" title={org.name.en}>
{localizedName(org.name)}
</div>
{parentName && (
<div
className="text-sm text-gray-500 dark:text-gray-400 font-normal truncate"
title={`under ${parentName}`}
>
(under {parentName})
</div>
)}
</div>
</div>
</div>
);
},
};
}
return column;
});
}, [children, expandedParents, localizedName]);
if (isLoading) {
return (
<div>
<Loader />
</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-row justify-between items-center px-0">
<CardTitle className="text-xl font-semibold ">
{t("organization.organizations")}
</CardTitle>
</CardHeader>
<CardContent className="px-0">
<AdvancedTable
columns={customColumns}
data={organizations}
tableName="Organizations"
toolBarPosition="right"
itemCount={baseList.length}
pageSize={pageSize}
onGlobalFilterChange={setSearchTerm}
extraToolbar={
<WithPermission
perms={["create:organization", "activate:organization"]}
>
<Link to="/user-management/organizations/new">
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
<Plus className="w-4 h-4 mr-2" />
{t("organization.newOrganization")}
</Button>
</Link>
</WithPermission>
}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
refresh={refetch}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Button } from "../../../shared/common/ui/button";
import {
Archive,
Delete,
Edit,
Eye,
MoreHorizontal,
Settings,
} from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { OrganizationActivateOrDeactivationPopup } from "./common/OrganizationActivateOrDeactivationPopup";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { useTranslation } from "react-i18next";
import ManageOrganizationDrawer from "./ManageOrganizationDrawer";
import { useArchiveActions } from "@/user-management/hooks/useArchived";
interface OrganizationsActionsProps {
rowData: OrganizationDto;
}
const OrganizationsActions: React.FC<OrganizationsActionsProps> = ({
rowData,
}) => {
const [dropdownOpen, setDropdownOpen] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
const [mangeOrgConfigOpen, setManageOrgConfigOpen] = useState(false);
const {
activateOrganization,
deactivateOrganization,
isOrganizationActivating,
isOrganizationDeactivating,
deleteOrganization,
isOrganizationDeleting,
} = useOrganizations("Org");
const { softDeleteOrganization, isArchivingOrganization } =
useArchiveActions();
const handleEditDetails = () => {
setDropdownOpen(false);
navigate(`/user-management/organizations/edit/${rowData?.id}`);
};
const handleViewDetails = () => {
setDropdownOpen(false);
navigate(`/user-management/organizations/detail/${rowData?.id}`);
};
const handleManageOrganization = () => {
setDropdownOpen(false);
setManageOrgConfigOpen(true);
};
const isActive = rowData.status === "Active";
return (
<div className="flex gap-3 align-center">
<OrganizationActivateOrDeactivationPopup
organizationName={rowData.name.en}
isActive={isActive}
onConfirm={() => {
isActive
? deactivateOrganization({
id: rowData.id,
})
: activateOrganization({
id: rowData.id,
});
}}
isLoading={isOrganizationActivating || isOrganizationDeactivating}
trigger={
isActive ? (
<Button
variant="outline"
className={
isActive
? "text-red-600 dark:text-red-400 border-red-600 dark:border-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
: "text-primary-600 dark:text-primary-400 border-primary-600 dark:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/30"
}
>
{t("statusBar.deactivate")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className={
isActive
? "text-red-600 dark:text-red-400 border-red-600 dark:border-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
: "text-primary-600 dark:text-primary-400 border-primary-600 dark:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/30"
}
>
{t("statusBar.activate")}
</Button>
)
}
/>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
aria-haspopup="menu"
aria-expanded={dropdownOpen}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[200px]"
onInteractOutside={(e) => {
// Prevent closing when interacting with dialogs
const target = e.target as HTMLElement;
if (!target.closest('[role="dialog"]')) {
setDropdownOpen(false);
}
}}
>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{/* Always show these */}
<DropdownMenuItem onSelect={handleViewDetails}>
<Eye className="mr-2 h-4 w-4" />
{t("organization.viewOrganization")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleManageOrganization}>
<Settings className="mr-2 h-4 w-4" />
{t("organization.manageOrganization", "Manage Organization")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleEditDetails}>
<Edit className="mr-2 h-4 w-4" />
{t("organization.editOrganization")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false);
softDeleteOrganization(rowData.id);
}}
disabled={isArchivingOrganization}
className="text-amber-700 focus:text-amber-700"
>
<Archive className="mr-2 h-4 w-4" />
{t("archive.archiveOrganization", "Archive Organization")}
</DropdownMenuItem>
</DropdownMenuContent>
{mangeOrgConfigOpen && (
<ManageOrganizationDrawer
manageOrgConfigOpen={mangeOrgConfigOpen}
setManageOrgConfigOpen={setManageOrgConfigOpen}
organizationId={rowData.id}
isUnitConfig={false}
/>
)}
</DropdownMenu>
</div>
);
};
export default OrganizationsActions;

View File

@@ -0,0 +1,122 @@
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import { ColumnDef } from "@tanstack/react-table";
import OrganizationsActions from "@/super-admin/components/organizations/OrganizationsActions";
import { Badge } from "../../../shared/common/ui/badge";
import { Users } from "lucide-react";
import { StatusCell } from "../organizationAdmins/StatusCell";
import { VisibilityCell } from "./VisibilityCell";
import { format } from "date-fns";
import { t } from "i18next";
import i18n from "@/i18n";
export const OrganizationsColumnDefn: ColumnDef<OrganizationDto>[] = [
{
accessorKey: "name.en",
header: () => t("organization.organizationName"),
size: 300,
minSize: 200,
maxSize: 400,
cell: ({ row }) => {
const org = row.original;
const lang = i18n.language;
// This will be overridden in the Organizations component with proper data
return (
<div className="font-medium">
{org.parentId && <span className="text-gray-400 dark:text-gray-500 mr-2"></span>}
{org.name.am}
</div>
);
},
},
{
accessorKey: "createdAt",
header: () => t("organization.createdOn"),
cell: ({ row }) => (
<div className="font-medium">
{format(new Date(row.original.createdAt), "MMM dd, yyyy")}
</div>
),
},
{
accessorKey: "activeEmployeeCount",
header: () => t("organization.numberOfUsers"),
cell: ({ row }) => (
<div className="font-medium flex items-center">
<Users className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
{row.original.activeEmployeeCount !== undefined
? row.original.activeEmployeeCount
: "-"}
</div>
),
},
{
accessorKey: "adminsCount",
header: () => t("organization.assignedAdmin"),
cell: ({ row }) => (
<StatusCell
id={row.original.id}
adminsCount={row.original.adminsCount || 0}
/>
),
},
{
id: "status",
header: () => t("userRecord.Status"),
cell: ({ row }) => {
const rowData = row.original;
return (
<div>
<Badge
className={`${
rowData.status === "Active"
? "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"
: "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"
} rounded-full px-6 py-1 font-medium`}
>
{rowData.status === "Active"
? t("statusBar.activate")
: t("statusBar.deactivate")}
</Badge>
</div>
);
},
},
{
id: "isGovernmentOrganization",
header: () => t("organization.isGovernmentOrganization"),
cell: ({ row }) => {
const rowData = row.original;
return (
<div>
<Badge
className={`${
rowData.isGovernmentOrganization
? "bg-blue-600 text-white hover:bg-blue-400"
: "bg-red-600 text-white hover:bg-red-400"
} rounded-full px-6 py-1 font-medium`}
>
{rowData.isGovernmentOrganization === true
? t("common.yes")
: t("common.no")}
</Badge>
</div>
);
},
},
{
id: "visibility",
header: () => t("organization.visibilityToOthers"),
cell: ({ row }) => {
const rowData = row.original;
return <VisibilityCell organizationId={rowData.id} />;
},
},
{
id: "Actions",
header: () => t("userRecord.Actions"),
cell: ({ row }) => {
const rowData = row.original;
return <OrganizationsActions rowData={rowData as OrganizationDto} />;
},
},
];

View File

@@ -0,0 +1,126 @@
import { useState, useEffect } from "react";
import {
createOrganizationConfig,
updateOrganizationConfig,
getOrganizationConfig,
OrganizationConfig,
OrganizationConfigListResponse,
} from "@/shared/services/organizationConfigService";
import { toast } from "sonner";
import { Switch } from "@/shared/common/ui/switch";
import { useTranslation } from "react-i18next";
interface VisibilityCellProps {
organizationId: string;
}
export const VisibilityCell = ({ organizationId }: VisibilityCellProps) => {
const { t } = useTranslation();
const [config, setConfig] = useState<OrganizationConfig | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
// Load organization configuration
useEffect(() => {
const loadConfig = async () => {
try {
setIsInitialLoading(true);
const response = await getOrganizationConfig(organizationId);
// Handle the new response format with count and items
const responseData = response.data as OrganizationConfigListResponse;
if (
responseData.count > 0 &&
responseData.items &&
responseData.items.length > 0
) {
// Take the first configuration item
const configData = responseData.items[0];
setConfig(configData);
} else {
// No config found - organization is visible by default
setConfig(null);
}
} catch (error) {
// If API call fails, assume no config exists (visible by default)
console.warn(
`Failed to load config for organization ${organizationId}:`,
error
);
setConfig(null);
} finally {
setIsInitialLoading(false);
}
};
loadConfig();
}, [organizationId]);
// Handle visibility toggle
const handleVisibilityToggle = async (canReceive: boolean) => {
try {
setIsLoading(true);
const payload = {
canStartReceivingRecord: canReceive,
organizationId: organizationId,
};
// Simple logic: if we have both ID and organization ID, use PUT; if only organization ID, use POST
if (config && config.id) {
// We have both ID and organization ID - use PUT request
await updateOrganizationConfig(config.id, payload);
setConfig((prev: OrganizationConfig | null) =>
prev ? { ...prev, canStartReceivingRecord: canReceive } : null
);
} else {
// We only have organization ID - use POST request
const response = await createOrganizationConfig(payload);
setConfig(response.data);
}
toast.success(
canReceive
? t("organization.visibilityEnabled")
: t("organization.visibilityDisabled")
);
} catch (error) {
console.error("Error updating organization visibility:", error);
toast.error(t("organization.visibilityUpdateFailed"));
} finally {
setIsLoading(false);
}
};
// Don't render until we have real data from API
if (isInitialLoading) {
return (
<div className="flex items-center justify-center">
<div className="w-4 h-4 border-2 border-gray-300 dark:border-gray-600 border-t-primary-600 rounded-full animate-spin"></div>
</div>
);
}
// Show real API result: if config exists, use its value; if no config, organization is visible by default
const isVisible = config ? config.canStartReceivingRecord : true;
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-300">
{isVisible ? t("organization.visible") : t("organization.hidden")}
</span>
<Switch
checked={isVisible}
onCheckedChange={handleVisibilityToggle}
disabled={isLoading}
className="data-[state=checked]:bg-primary-600"
/>
</div>
{isLoading && (
<div className="w-4 h-4 border-2 border-gray-300 dark:border-gray-600 border-t-primary-600 rounded-full animate-spin"></div>
)}
</div>
);
};

View File

@@ -0,0 +1,69 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/common/ui/alert-dialog";
import { Loader2 } from "lucide-react";
import { ReactNode } from "react";
interface OrganizationActivateOrDeactivationPopupProps {
organizationName: string;
isActive: boolean;
onConfirm: () => void;
isLoading: boolean;
trigger: ReactNode;
}
export const OrganizationActivateOrDeactivationPopup = ({
organizationName,
isActive,
onConfirm,
isLoading,
trigger,
}: OrganizationActivateOrDeactivationPopupProps) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isActive ? "Deactivate" : "Activate"} Organization
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to {isActive ? "deactivate" : "activate"}{" "}
<span className="font-medium">{organizationName}</span>? This action
can be reversed later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading} className="cursor-pointer">
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isLoading}
className={
isActive ? "bg-red-600 hover:bg-red-700 cursor-pointer" : ""
}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{isLoading
? isActive
? "Deactivating..."
: "Activating..."
: isActive
? "Deactivate"
: "Activate"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,324 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Label } from "@/shared/common/ui/label";
import { z } from "zod";
import { useOrganizationTypes } from "@/super-admin/hooks/useOrganizationTypes";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import { useNavigate } from "react-router-dom";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { Switch } from "@/shared/common/ui/switch";
import { t } from "i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
const orgSchema = z.object({
name: z.object({
am: z.string().min(1, "Amharic name is required"),
en: z.string().min(1, "English name is required"),
}),
key: z.string().min(1, "Key is required"),
organizationTypeId: z.string().min(1, "Organization Type is required"),
parentId: z.string().optional(),
isGovernmentOrganization: z.boolean(),
});
export type FormValues = z.infer<typeof orgSchema>;
interface OrganizationFormProps {
onSubmit: (values: FormValues) => void;
type: "Create" | "Edit";
isLoading: boolean;
organizationDetails?: OrganizationDto;
}
const OrganizationForm: React.FC<OrganizationFormProps> = ({
onSubmit,
isLoading,
type,
organizationDetails,
}) => {
const fieldClassName =
"h-10 rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary";
const form = useForm<FormValues>({
resolver: zodResolver(orgSchema),
defaultValues: {
name: { am: "", en: "" },
key: "",
organizationTypeId: "",
parentId: undefined,
isGovernmentOrganization: true,
},
});
const navigate = useNavigate();
const localizedName = useLocalizedName();
const {
organizationTypesResponse,
isLoading: isLoadingOrgTypes,
isError: orgTypesError,
} = useOrganizationTypes();
const {
organizationsResponse,
isLoading: isLoadingOrgs,
isError: orgsError,
} = useOrganizations("Org", {
take: 1000,
});
useEffect(() => {
const en = form.watch("name.en");
if (en) {
const commonNumber = "001"; // Example number
const generatedKey = `${en || ""}_${commonNumber}`
.replace(/\s+/g, "_") // Replace spaces with underscores
.toUpperCase();
form.setValue("key", generatedKey);
}
}, [form.watch("name.en")]);
useEffect(() => {
if (organizationDetails) {
form.reset({
name: organizationDetails.name,
key: organizationDetails.key,
organizationTypeId: organizationDetails.organizationTypeId,
parentId: organizationDetails.parentId ?? undefined,
isGovernmentOrganization:
organizationDetails.isGovernmentOrganization ?? true,
});
}
}, [organizationDetails]);
return (
<div className="px-2 md:px-4">
<Card className="max-w-5xl mx-auto shadow-md border-gray-200 dark:border-gray-700 overflow-hidden">
<CardHeader className="border-b border-gray-200 dark:border-gray-700 bg-gray-50/70 dark:bg-gray-900/40">
<CardTitle className="text-xl md:text-2xl font-semibold text-gray-800 dark:text-gray-100">
{type === "Create"
? t("organization.createNew")
: t("organization.edit")}{" "}
{t("organization.organizations")}
</CardTitle>
<CardDescription className="text-gray-500 dark:text-gray-400">
{t("organization.enterDetails")}
</CardDescription>
</CardHeader>
<CardContent className="p-4 md:p-8">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/30 p-4 md:p-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
<div className="space-y-2">
<Label htmlFor="name.am">
{t("organization.nameAmharic")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input {...form.register("name.am")} className={fieldClassName} />
{form.formState.errors.name?.am && (
<p className="text-red-500 text-xs mt-1">
{form.formState.errors.name.am.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="name.en">
{t("organization.nameEnglish")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input {...form.register("name.en")} className={fieldClassName} />
{form.formState.errors.name?.en && (
<p className="text-red-500 text-xs mt-1">
{form.formState.errors.name.en.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="code">
{t("organization.key")}
<span className="text-red-500">*</span>
</Label>
<Input
{...form.register("key")}
disabled={type === "Edit"}
readOnly={type === "Edit"}
className={fieldClassName}
/>
{form.formState.errors.key && (
<p className="text-red-500 text-xs mt-1">
{form.formState.errors.key.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="organizationType">
{t("organization.organizationType")}{" "}
<span className="text-red-500">*</span>
</Label>
<Select
value={form.watch("organizationTypeId")}
onValueChange={(val: string) =>
form.setValue("organizationTypeId", val)
}
disabled={isLoadingOrgTypes}
>
<SelectTrigger className={fieldClassName}>
<SelectValue
placeholder={
isLoadingOrgTypes
? t("organization.loading")
: t("organization.selectOrganizationType")
}
/>
</SelectTrigger>
<SelectContent>
{isLoadingOrgTypes ? (
<SelectItem value="loading" disabled>
{t("organization.loadingOrganizationTypes")}
</SelectItem>
) : orgTypesError ? (
<SelectItem value="error" disabled>
{t("organization.errorLoadingOrganizationTypes")}
</SelectItem>
) : organizationTypesResponse?.items?.length ? (
organizationTypesResponse.items.map((type) => (
<SelectItem key={type.id} value={type.id}>
{localizedName(type.name)}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
{t("organization.noOrganizationTypesAvailable")}
</SelectItem>
)}
</SelectContent>
</Select>
{form.formState.errors.organizationTypeId && (
<p className="text-red-500 text-xs mt-1">
{form.formState.errors.organizationTypeId.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="organizationType">
{t("organization.parentOrganization")}
</Label>
<Select
value={form.watch("parentId")}
onValueChange={(val: string) => form.setValue("parentId", val)}
disabled={isLoadingOrgs}
>
<SelectTrigger className={fieldClassName}>
<SelectValue
placeholder={
isLoadingOrgs
? t("organization.loading")
: t("organization.selectOrganization")
}
/>
</SelectTrigger>
<SelectContent>
{isLoadingOrgs ? (
<SelectItem value="loading" disabled>
Loading organization types...
</SelectItem>
) : orgsError ? (
<SelectItem value="error" disabled>
Error loading organization types
</SelectItem>
) : organizationsResponse?.items?.length ? (
organizationsResponse.items.map((type) => (
<SelectItem key={type.id} value={type.id}>
{localizedName(type.name)}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
{t("organization.noOrganization")}
</SelectItem>
)}
</SelectContent>
</Select>
{form.formState.errors.organizationTypeId && (
<p className="text-red-500 text-xs mt-1">
{form.formState.errors.organizationTypeId.message}
</p>
)}
</div>
<div className="space-y-2 md:col-span-2 xl:col-span-1">
<Label htmlFor="isPublic">
{t("organization.isOrganizationPublic")}
</Label>
<div className="h-10 px-3 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 flex items-center justify-end">
<Switch
id="isPublic"
checked={form.watch("isGovernmentOrganization")}
onCheckedChange={(value) =>
form.setValue("isGovernmentOrganization", value)
}
/>
</div>
{form.formState.errors.isGovernmentOrganization && (
<p className="text-red-500 text-xs mt-1">
{form.formState.errors.isGovernmentOrganization.message}
</p>
)}
</div>
</div>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
<Button
variant="outline"
type="button"
onClick={() => {
navigate(-1);
}}
className="w-full sm:w-auto"
>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={isLoading}
className="w-full sm:w-auto bg-primary hover:bg-primary/90 text-primary-foreground"
>
{isLoading
? `${type === "Create" ? "Creating" : "Editing"}...`
: `${
type === "Create" ? t("common.create") : t("common.Edit")
} `}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export default OrganizationForm;

View File

@@ -0,0 +1,441 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Loader2, Lock } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Switch } from "@/shared/common/ui/switch";
import { toast } from "sonner";
import {
usePasswordSettingsQuery,
usePasswordSettingsMutations,
} from "../../hooks/usePasswordSettings";
import { PasswordSettings } from "../../services/api/passwordSettingsService";
import Loader from "@/record-management/components/Loader/loader";
interface PasswordSettingsFormValues {
minimumPasswordLength: number;
maximumPasswordLength: number;
passwordExpiry: number;
sessionTimeout: number;
isDefaultPasswordEnabled: boolean;
defaultPassword: string;
}
interface PasswordSettingsFormProps {
onSuccessCallback?: () => void;
isDialogForm?: boolean;
}
export default function PasswordSettingsForm({
onSuccessCallback,
isDialogForm = false,
}: PasswordSettingsFormProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: settings, isLoading, error } = usePasswordSettingsQuery();
const mutations = usePasswordSettingsMutations();
const form = useForm<PasswordSettingsFormValues>({
defaultValues: {
minimumPasswordLength: 8,
maximumPasswordLength: 64,
passwordExpiry: 90,
sessionTimeout: 30,
isDefaultPasswordEnabled: false,
defaultPassword: "Welcome@123",
},
});
useEffect(() => {
if (settings) {
form.reset({
minimumPasswordLength: settings.minimumPasswordLength || 8,
maximumPasswordLength: settings.maximumPasswordLength || 64,
passwordExpiry: settings.passwordExpiry || 90,
sessionTimeout: settings.sessionTimeout || 30,
isDefaultPasswordEnabled: settings.isDefaultPasswordEnabled || false,
defaultPassword: settings.defaultPassword || "Welcome@123",
});
}
}, [settings, form]);
const onSubmit = async (values: PasswordSettingsFormValues) => {
if (values.minimumPasswordLength > values.maximumPasswordLength) {
toast.error(
t(
"passwordSettings.minMaxError",
"Minimum password length cannot be greater than maximum"
)
);
return;
}
if (
values.isDefaultPasswordEnabled &&
!values.defaultPassword?.trim()
) {
toast.error(
t(
"passwordSettings.defaultPasswordRequired",
"Default password is required when enabled"
)
);
return;
}
setIsSubmitting(true);
try {
const payload: PasswordSettings = {
...values,
id: settings?.id,
};
if (settings?.id) {
await mutations.update.mutateAsync(payload);
} else {
await mutations.create.mutateAsync(payload);
}
// Call success callback if provided (for dialog form)
if (onSuccessCallback) {
onSuccessCallback();
}
// Reset form after successful submission
form.reset();
} catch (error) {
// Error is handled by the hook
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return <Loader />;
}
if (error) {
return (
<Card className="border-red-200">
<CardHeader>
<CardTitle className="text-red-600">
{t("passwordSettings.error", "Error Loading Settings")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">
{t(
"passwordSettings.errorMessage",
"Failed to load password settings. Please try again."
)}
</p>
</CardContent>
</Card>
);
}
return (
<Card className={isDialogForm ? "w-full border-0 shadow-none" : "w-full"}>
{!isDialogForm && (
<CardHeader>
<div className="flex items-center gap-2">
<Lock className="w-5 h-5 text-primary" />
<div>
<CardTitle>
{t("passwordSettings.title", "Password Settings")}
</CardTitle>
<CardDescription>
{t(
"passwordSettings.description",
"Configure password policies and security settings for your organization"
)}
</CardDescription>
</div>
</div>
</CardHeader>
)}
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Password Length Section */}
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Lock className="w-4 h-4" />
{t("passwordSettings.passwordLength", "Password Length Requirements")}
</h3>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="minimumPasswordLength"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"passwordSettings.minimumLength",
"Minimum Password Length"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min="1"
max="100"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value))
}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription className="text-xs">
{t("passwordSettings.minimumLengthDesc", "Minimum: 1, Recommended: 8")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maximumPasswordLength"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"passwordSettings.maximumLength",
"Maximum Password Length"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min="1"
max="256"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value))
}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription className="text-xs">
{t("passwordSettings.maximumLengthDesc", "Maximum: 256, Recommended: 64")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Password Expiry Section */}
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Lock className="w-4 h-4" />
{t("passwordSettings.passwordExpiry", "Password Expiry Policy")}
</h3>
<FormField
control={form.control}
name="passwordExpiry"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"passwordSettings.passwordExpiryDays",
"Password Expiry (Days)"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="365"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value))
}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription>
{t(
"passwordSettings.passwordExpiryDesc",
"Number of days before passwords expire (0 = never expires)"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Session Timeout Section */}
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Lock className="w-4 h-4" />
{t("passwordSettings.sessionManagement", "Session Management")}
</h3>
<FormField
control={form.control}
name="sessionTimeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"passwordSettings.sessionTimeout",
"Session Timeout (Minutes)"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min="1"
max="1440"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value))
}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription>
{t(
"passwordSettings.sessionTimeoutDesc",
"Idle session timeout in minutes (1 minute to 24 hours)"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Default Password Section */}
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Lock className="w-4 h-4" />
{t("passwordSettings.defaultPassword", "Default Password")}
</h3>
<FormField
control={form.control}
name="isDefaultPasswordEnabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-gray-800">
<div className="space-y-0.5">
<FormLabel>
{t(
"passwordSettings.enableDefaultPassword",
"Enable Default Password"
)}
</FormLabel>
<FormDescription>
{t(
"passwordSettings.enableDefaultPasswordDesc",
"Use a default password for new users"
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("isDefaultPasswordEnabled") && (
<FormField
control={form.control}
name="defaultPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"passwordSettings.defaultPasswordValue",
"Default Password"
)}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter default password"
{...field}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription className="text-xs">
{t(
"passwordSettings.defaultPasswordValueDesc",
"This password will be used for newly created user accounts"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
{/* Buttons */}
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => form.reset()}
disabled={isSubmitting}
>
{t("common.reset", "Reset")}
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-primary hover:bg-primary-700"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t("common.saving", "Saving")}
</>
) : (
t("common.save", "Save Changes")
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { SiteDto, SitePayloadDto } from "@/super-admin/dto/SitesDto";
import { toSnakeCase } from "@/super-admin/hooks/useSites";
interface SiteFormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (payload: SitePayloadDto) => void;
site?: SiteDto | null; // If provided, we are in Edit mode
isSubmitting: boolean;
existingKeys: string[]; // for frontend uniqueness check
}
export default function SiteFormModal({
isOpen,
onClose,
onSubmit,
site = null,
isSubmitting,
existingKeys,
}: SiteFormModalProps) {
const { t } = useTranslation();
const isEdit = !!site;
const [nameAm, setNameAm] = useState("");
const [nameEn, setNameEn] = useState("");
const [key, setKey] = useState("");
const [domain, setDomain] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (isOpen) {
if (site) {
setNameAm(site.name?.am || "");
setNameEn(site.name?.en || "");
setKey(site.key || "");
setDomain(site.domain || "");
} else {
setNameAm("");
setNameEn("");
setKey("");
setDomain("");
}
setErrors({});
}
}, [isOpen, site]);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!nameAm.trim()) {
newErrors.nameAm = t("sites.form.nameAmRequired");
}
if (!nameEn.trim()) {
newErrors.nameEn = t("sites.form.nameEnRequired");
}
if (!key.trim()) {
newErrors.key = t("sites.form.keyRequired");
} else if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
newErrors.key = t("sites.form.keyFormat");
} else if (!isEdit && existingKeys.includes(key.trim())) {
newErrors.key = t("sites.form.keyUnique");
}
if (!domain.trim()) {
newErrors.domain = t("sites.form.domainRequired");
} else if (/\s/.test(domain)) {
newErrors.domain = t("sites.form.domainNoSpaces");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
onSubmit({
name: {
am: nameAm.trim(),
en: nameEn.trim(), // transformed to snake_case in the hook/mutation
},
key: key.trim(),
domain: domain.trim(),
});
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-slate-800 dark:text-slate-100">
{isEdit ? t("sites.form.titleEdit") : t("sites.form.titleAdd")}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-2">
{/* Site Name (Amharic) */}
<div className="space-y-1">
<Label htmlFor="nameAm" className="text-sm font-semibold">
{t("sites.form.nameAmLabel")} <span className="text-red-500">*</span>
</Label>
<Input
id="nameAm"
value={nameAm}
onChange={(e) => setNameAm(e.target.value)}
placeholder={t("sites.form.nameAmPlaceholder")}
className={errors.nameAm ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{errors.nameAm && (
<span className="text-xs text-red-500 font-medium">{errors.nameAm}</span>
)}
</div>
{/* Site Name (English) */}
<div className="space-y-1">
<Label htmlFor="nameEn" className="text-sm font-semibold">
{t("sites.form.nameEnLabel")} <span className="text-red-500">*</span>
</Label>
<Input
id="nameEn"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t("sites.form.nameEnPlaceholder")}
className={errors.nameEn ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{nameEn.trim() && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{t("sites.form.previewLabel")} <span className="font-mono text-primary font-semibold">{toSnakeCase(nameEn)}</span>
</p>
)}
{errors.nameEn && (
<span className="text-xs text-red-500 font-medium">{errors.nameEn}</span>
)}
</div>
{/* Site Key */}
<div className="space-y-1">
<Label htmlFor="key" className="text-sm font-semibold">
{t("sites.form.keyLabel")} <span className="text-red-500">*</span>
</Label>
<Input
id="key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder={t("sites.form.keyPlaceholder")}
disabled={isEdit}
className={errors.key ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{!isEdit && (
<p className="text-xs text-slate-400">
{t("sites.form.keyHint")}
</p>
)}
{errors.key && (
<span className="text-xs text-red-500 font-medium">{errors.key}</span>
)}
</div>
{/* Site Domain */}
<div className="space-y-1">
<Label htmlFor="domain" className="text-sm font-semibold">
{t("sites.form.domainLabel")} <span className="text-red-500">*</span>
</Label>
<Input
id="domain"
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder={t("sites.form.domainPlaceholder")}
className={errors.domain ? "border-red-500 focus-visible:ring-red-500" : ""}
/>
{errors.domain && (
<span className="text-xs text-red-500 font-medium">{errors.domain}</span>
)}
</div>
<DialogFooter className="pt-4 border-t gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? t("sites.form.saving") : isEdit ? t("sites.form.saveChanges") : t("sites.form.createSite")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,104 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { useTranslation } from "react-i18next";
import { SiteDto } from "@/super-admin/dto/SitesDto";
interface SiteViewModalProps {
isOpen: boolean;
onClose: () => void;
site?: SiteDto | null;
}
export default function SiteViewModal({
isOpen,
onClose,
site,
}: SiteViewModalProps) {
const { t } = useTranslation();
if (!site) return null;
const isActive = site.status === "Active" || site.status !== "Archived";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold text-slate-800 dark:text-slate-100">
{t("sites.view.title")}
</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-4">
{/* Detail cards */}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.nameEn")}
</span>
<p className="text-base font-semibold text-slate-800 dark:text-slate-100">
{site.name?.en || "-"}
</p>
</div>
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.nameAm")}
</span>
<p className="text-base font-semibold text-slate-800 dark:text-slate-100">
{site.name?.am || "-"}
</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.siteKey")}
</span>
<p className="text-base font-mono text-slate-800 dark:text-slate-100 truncate" title={site.key}>
{site.key || "-"}
</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.status")}
</span>
<div>
<Badge
className={`${
isActive
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-950/40"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-950/40"
} rounded-full px-3 py-0.5 font-medium border-none`}
>
{site.status || "Active"}
</Badge>
</div>
</div>
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
{t("sites.view.domain")}
</span>
<p className="text-base font-semibold text-slate-800 dark:text-slate-100 truncate" title={site.domain}>
{site.domain || "-"}
</p>
</div>
</div>
</div>
<DialogFooter className="border-t pt-4">
<Button onClick={onClose} className="w-full sm:w-auto">
{t("sites.view.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,143 @@
import { SiteDto } from "@/super-admin/dto/SitesDto";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Archive, Trash2, Edit, Eye, MoreHorizontal, RefreshCw, Palette } from "lucide-react";
import { t } from "i18next";
interface ColumnsProps {
onView: (site: SiteDto) => void;
onEdit: (site: SiteDto) => void;
onArchive: (site: SiteDto) => void;
onRestore: (site: SiteDto) => void;
onDelete: (site: SiteDto) => void;
onBranding: (site: SiteDto) => void;
}
export const getSitesColumnDefn = ({
onView,
onEdit,
onArchive,
onRestore,
onDelete,
onBranding,
}: ColumnsProps): ColumnDef<SiteDto>[] => [
{
id: "name",
header: () => t("sites.columns.siteName"),
cell: ({ row }) => {
const site = row.original;
return (
<div>
<div className="font-semibold text-slate-800 dark:text-slate-200">
{site.name?.en || "-"}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{site.name?.am || "-"}
</div>
</div>
);
},
},
{
accessorKey: "key",
header: () => t("sites.columns.key"),
cell: ({ row }) => (
<span className="font-mono text-slate-700 dark:text-slate-300">
{row.original.key}
</span>
),
},
{
accessorKey: "domain",
header: () => t("sites.columns.domain"),
cell: ({ row }) => (
<span className="text-slate-700 dark:text-slate-300">
{row.original.domain}
</span>
),
},
{
accessorKey: "status",
header: () => t("sites.columns.status"),
cell: ({ row }) => {
const status = row.original.status || "Active";
const isActive = status === "Active";
return (
<Badge
className={`${
isActive
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-950/40"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-950/40"
} rounded-full px-3 py-0.5 font-medium border-none`}
>
{status}
</Badge>
);
},
},
{
id: "actions",
header: () => t("sites.columns.actions"),
cell: ({ row }) => {
const site = row.original;
const isArchived = site.status === "Archived";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open Menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuLabel>{t("sites.columns.actions")}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onView(site)}>
<Eye className="mr-2 h-4 w-4 text-slate-500" />
{t("sites.actions.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(site)}>
<Edit className="mr-2 h-4 w-4 text-blue-500" />
{t("sites.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onBranding(site)}>
<Palette className="mr-2 h-4 w-4 text-purple-500" />
{t("sites.actions.branding")}
</DropdownMenuItem>
{!isArchived ? (
<DropdownMenuItem
onClick={() => onArchive(site)}
className="text-amber-600 focus:text-amber-600 cursor-pointer"
>
<Archive className="mr-2 h-4 w-4 text-amber-500" />
{t("sites.actions.archive")}
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => onRestore(site)}
className="text-emerald-600 focus:text-emerald-600 cursor-pointer"
>
<RefreshCw className="mr-2 h-4 w-4 text-emerald-500" />
{t("sites.actions.restore")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => onDelete(site)}
className="text-rose-600 focus:text-rose-600 cursor-pointer"
>
<Trash2 className="mr-2 h-4 w-4 text-rose-500" />
{t("sites.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];

View File

@@ -0,0 +1,363 @@
import { useState, useMemo, useEffect } from "react";
import { useSites } from "@/super-admin/hooks/useSites";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { getSitesColumnDefn } from "./SitesColumnDefn";
import { Loader, Plus } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { SiteDto, SitePayloadDto } from "@/super-admin/dto/SitesDto";
import SiteFormModal from "./SiteFormModal";
import SiteViewModal from "./SiteViewModal";
export default function SitesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [searchTerm, setSearchTerm] = useState("");
// Modals state
const [formOpen, setFormOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteDto | null>(null);
// Dialogs state for archive/delete/restore
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
// Query & mutation hook
const {
sitesResponse,
isLoading,
refetch,
createSite,
isCreating,
updateSite,
isUpdating,
archiveSite,
isArchiving,
deleteSite,
isDeleting,
restoreSite,
isRestoring,
} = useSites({
take: pageSize,
skip: pageIndex * pageSize,
orderBy: "createdAt:DESC",
});
useEffect(() => {
setPageIndex(0);
}, [searchTerm, pageSize]);
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
const handleAddNewClick = () => {
setSelectedSite(null);
setFormOpen(true);
};
const handleFormSubmit = (payload: SitePayloadDto) => {
if (selectedSite) {
updateSite(
{ id: selectedSite.id, payload },
{
onSuccess: () => {
setFormOpen(false);
setSelectedSite(null);
},
},
);
} else {
createSite(payload, {
onSuccess: () => {
setFormOpen(false);
},
});
}
};
const handleArchiveConfirm = () => {
if (selectedSite) {
archiveSite(
{ id: selectedSite.id },
{
onSuccess: () => {
setArchiveDialogOpen(false);
setSelectedSite(null);
},
},
);
}
};
const handleDeleteConfirm = () => {
if (selectedSite) {
deleteSite(
{ id: selectedSite.id },
{
onSuccess: () => {
setDeleteDialogOpen(false);
setSelectedSite(null);
},
},
);
}
};
const handleRestoreConfirm = () => {
if (selectedSite) {
restoreSite(
{ id: selectedSite.id },
{
onSuccess: () => {
setRestoreDialogOpen(false);
setSelectedSite(null);
},
},
);
}
};
// Extract all sites from response
const allSites = useMemo(() => sitesResponse?.items || [], [sitesResponse]);
// Existing keys list (for create uniqueness validation)
const existingKeys = useMemo(() => allSites.map((s) => s.key), [allSites]);
// Get table column definitions
const columns = useMemo(
() =>
getSitesColumnDefn({
onView: (site) => {
setSelectedSite(site);
setViewOpen(true);
},
onEdit: (site) => {
setSelectedSite(site);
setFormOpen(true);
},
onArchive: (site) => {
setSelectedSite(site);
setArchiveDialogOpen(true);
},
onRestore: (site) => {
setSelectedSite(site);
setRestoreDialogOpen(true);
},
onDelete: (site) => {
setSelectedSite(site);
setDeleteDialogOpen(true);
},
onBranding: (site) => {
navigate(`/user-management/web-management/Branding/Branding/${site.id}`);
},
}),
[],
);
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-row justify-between items-center px-0">
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{t("sites.title")}
</CardTitle>
</CardHeader>
<CardContent className="px-0">
<AdvancedTable
columns={columns}
data={sitesResponse?.items || []}
tableName={t("sites.tableName")}
toolBarPosition="right"
itemCount={sitesResponse?.count || 0}
pageSize={pageSize}
onGlobalFilterChange={setSearchTerm}
extraToolbar={
<Button
onClick={handleAddNewClick}
className="px-5 py-2 rounded-md text-sm font-medium shadow-md cursor-pointer"
>
<Plus className="w-4 h-4 mr-2" />
{t("sites.addNew")}
</Button>
}
pageIndex={pageIndex}
onPageChange={handlePageChange}
onPageSizeChange={setPageSize}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
refresh={refetch}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Create / Edit Form Modal */}
<SiteFormModal
isOpen={formOpen}
onClose={() => {
setFormOpen(false);
setSelectedSite(null);
}}
onSubmit={handleFormSubmit}
site={selectedSite}
isSubmitting={isCreating || isUpdating}
existingKeys={existingKeys}
/>
{/* Read-Only View Modal */}
<SiteViewModal
isOpen={viewOpen}
onClose={() => {
setViewOpen(false);
setSelectedSite(null);
}}
site={selectedSite}
/>
{/* Archive Confirmation (Soft Delete) */}
<AlertDialog
open={archiveDialogOpen}
onOpenChange={(open) => !open && setArchiveDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold">
{t("sites.dialogs.archiveTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("sites.dialogs.archiveDescription", {
name: selectedSite?.name?.en,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isArchiving}
onClick={() => {
setArchiveDialogOpen(false);
setSelectedSite(null);
}}
className="cursor-pointer"
>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isArchiving}
onClick={handleArchiveConfirm}
className="bg-amber-600 hover:bg-amber-700 text-white cursor-pointer"
>
{isArchiving
? t("sites.dialogs.archiving")
: t("sites.dialogs.archiveConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Permanent Delete Confirmation (Hard Delete) */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={(open) => !open && setDeleteDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold text-red-600">
{t("sites.dialogs.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
<span
dangerouslySetInnerHTML={{
__html: t("sites.dialogs.deleteDescription", {
name: selectedSite?.name?.en,
}),
}}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isDeleting}
onClick={() => {
setDeleteDialogOpen(false);
setSelectedSite(null);
}}
className="cursor-pointer"
>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isDeleting}
onClick={handleDeleteConfirm}
className="bg-red-600 hover:bg-red-700 text-white cursor-pointer"
>
{isDeleting
? t("sites.dialogs.deleting")
: t("sites.dialogs.deleteConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Restore Confirmation */}
<AlertDialog
open={restoreDialogOpen}
onOpenChange={(open) => !open && setRestoreDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold">
{t("sites.dialogs.restoreTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("sites.dialogs.restoreDescription", {
name: selectedSite?.name?.en,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isRestoring}
onClick={() => {
setRestoreDialogOpen(false);
setSelectedSite(null);
}}
className="cursor-pointer"
>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isRestoring}
onClick={handleRestoreConfirm}
className="bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
{isRestoring
? t("sites.dialogs.restoring")
: t("sites.dialogs.restoreConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/shared/common/ui/card";
import { FileText, Plus } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useState } from "react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useTemplate } from "../service/useTemplate";
import { TemplateForm } from "./TemplateForm";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
import i18n from "@/i18n";
import { CreateTemplateTypes } from "../types/templateTypes";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const TemplateCard = () => {
const { handleError } = useErrorHandler(t);
const {
templates,
isLoading,
isError,
refetch,
createTemplate,
updateTemplate,
isCreatingTemplate,
isUpdatingTemplate,
} = useTemplate();
const lang = i18n.language;
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
null
);
const [showCreateForm, setShowCreateForm] = useState(false);
const templateList: CreateTemplateTypes[] = Array.isArray(templates) ? templates : templates?.items || [];
const selectedTemplate =
templateList.find((t) => t.id === selectedTemplateId) ?? null;
return (
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t("contentManagement.letterTemplate")}
</CardTitle>
<CardDescription>
{t("contentManagement.createMsg")}
</CardDescription>
</div>
<Button onClick={() => setShowCreateForm(true)} size="sm">
<Plus className="h-4 w-4 mr-1" />
{t("contentManagement.addTemplate")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{isLoading ? (
<>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-20 w-full" />
</>
) : isError ? (
<p className="text-sm text-red-600">
{t("contentManagement.failedToLoadTemplates")}
</p>
) : (
<>
{templateList.length > 0 && (
<Select
onValueChange={(value) => {
setSelectedTemplateId(value);
setShowCreateForm(false);
}}
value={selectedTemplateId ?? undefined}
>
<SelectTrigger>
<SelectValue placeholder={t("contentManagement.selectTemplate")} />
</SelectTrigger>
<SelectContent>
{templateList.map((template) => (
<SelectItem key={template.id!} value={template.id!}>
{lang === "en" ? template.name.en : template.name.am}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{(selectedTemplate || showCreateForm) && (
<TemplateForm
isSubmitting={isCreatingTemplate || isUpdatingTemplate}
template={selectedTemplate}
onCancel={() => {
setShowCreateForm(false);
setSelectedTemplateId(null);
}}
onSubmitCreate={(values) => {
if (selectedTemplate?.id) {
updateTemplate(
{ id: selectedTemplate.id, template: values },
{
onSuccess: () => {
toast.success(t("contentManagement.updateTemplate"));
refetch();
setShowCreateForm(false);
},
onError: (error) => {
handleError(error);
},
}
);
} else {
createTemplate(values, {
onSuccess: (newTemplate: any) => {
toast.success(
`${values.name.en} ${t("contentManagement.templateSuccessMsg")}`
);
refetch();
setShowCreateForm(false);
// setSelectedTemplateId(newTemplate.id);
},
onError: (error) => {
handleError(error);
},
});
}
}}
/>
)}
</>
)}
</CardContent>
</Card>
);
};
export default TemplateCard;

View File

@@ -0,0 +1,132 @@
import React, { useMemo, useState, useEffect } from "react";
import { Editor as TinyMCEEditor } from "@/record-management/common/editor/rte";
import type { Editor as TinyMCEEditorType } from "tinymce";
export interface Placeholder {
key: string;
label: string;
}
interface TemplateEditorProps {
value: string;
onEditorChange: (value: string, editor: TinyMCEEditorType) => void;
apiKey?: string;
placeholders?: Placeholder[];
}
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
value,
onEditorChange,
apiKey,
placeholders,
}) => {
const hasPlaceholders = placeholders && placeholders.length > 0;
const [isDarkMode, setIsDarkMode] = useState(() =>
typeof window !== "undefined" && document.documentElement.classList.contains("dark")
);
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
const init = useMemo(() => {
return {
height: 600,
menubar: true,
skin: isDarkMode ? "oxide-dark" : "oxide",
skin_url: isDarkMode ? "/tinymce/skins/ui/oxide-dark" : "/tinymce/skins/ui/oxide",
content_css: isDarkMode ? "dark" : "default",
content_style: `
body {
font-family:Helvetica,Arial,sans-serif;
font-size:14px;
${isDarkMode ? `
background-color: #1f2937;
color: #e5e7eb;
` : `
background-color: #ffffff;
color: #1f2937;
`}
}
${hasPlaceholders ? `
.placeholder {
${isDarkMode ? `
background-color: #1e3a5f;
border: 1px dashed #60a5fa;
color: #93c5fd;
` : `
background-color: #e0f2fe;
border: 1px dashed #38bdf8;
`}
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
display: inline-block;
margin: 0 2px;
}
` : ""}
`,
plugins: [
"autolink", "link", "lists", "code", "table", "image", "preview", "searchreplace", "wordcount",
...(hasPlaceholders ? ["noneditable"] : [])
].join(" "),
toolbar: [
"undo redo | bold italic underline |",
"alignleft aligncenter alignright alignjustify |",
"bullist numlist outdent indent | link image table | code preview",
hasPlaceholders ? "| placeholders" : ""
].join(" "),
noneditable_class: "mceNonEditable",
setup: (editor: TinyMCEEditorType) => {
if (hasPlaceholders) {
editor.ui.registry.addMenuButton("placeholders", {
text: "Placeholders",
fetch: (callback) => {
const items = placeholders.map((p) => ({
type: "menuitem" as const,
text: p.label,
onAction: () => {
const html = `<span class="mceNonEditable placeholder" data-key="${p.key}">{{${p.key}}}</span>&#8203;`;
editor.insertContent(html);
},
}));
callback(items);
},
});
editor.on("click", (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains("placeholder")) {
editor.selection.select(target);
}
});
editor.on("keydown", (e) => {
if (e.key === "Backspace" || e.key === "Delete") {
const node = editor.selection.getNode();
if (node && node.classList.contains("placeholder")) {
e.preventDefault();
node.remove();
}
}
});
}
},
};
}, [placeholders, hasPlaceholders, isDarkMode]);
return (
<TinyMCEEditor
key={isDarkMode ? "dark" : "light"}
tinymceScriptSrc="/tinymce/tinymce.min.js"
apiKey={apiKey}
value={value}
onEditorChange={onEditorChange}
init={init}
/>
);
};

View File

@@ -0,0 +1,195 @@
import { Controller, useForm } from "react-hook-form";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { CreateTemplateTypes } from "../types/templateTypes";
import { Editor } from "@/record-management/common/editor/rte";
import { TemplateEditor } from "./TemplateEditor";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
interface Props {
template?: CreateTemplateTypes | null;
isSubmitting: boolean;
onCancel: () => void;
onSubmitCreate: (data: CreateTemplateTypes) => void;
}
const editorApiKey = import.meta.env.VITE_EDITOR_API_KEY || "";
export const TemplateForm = ({
template,
isSubmitting,
onCancel,
onSubmitCreate,
}: Props) => {
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<CreateTemplateTypes>({
defaultValues: {
name: {
en: template?.name?.en ?? "",
am: template?.name?.am ?? "",
},
key: template?.key ?? "",
subject: template?.subject ?? "",
body: template?.body ?? "",
sincerelyText: template?.sincerelyText ?? "",
locale: template?.locale ?? "en",
},
});
const onSubmit = (values: CreateTemplateTypes) => {
onSubmitCreate(values);
if (!template) {
reset();
}
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 shadow-sm dark:shadow-gray-900/50 space-y-8"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Template Key
</label>
<Input
placeholder="Template Key"
{...register("key", { required: "Template key is required" })}
/>
{errors.key && (
<p className="text-red-500 text-sm mt-1">{errors.key.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subject
</label>
<Input
placeholder="Subject"
{...register("subject", { required: "Subject is required" })}
/>
{errors.subject && (
<p className="text-red-500 text-sm mt-1">
{errors.subject.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
English Name
</label>
<Input
placeholder="English Name"
{...register("name.en", { required: "English name is required" })}
/>
{errors.name?.en && (
<p className="text-red-500 text-sm mt-1">
{errors.name.en.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Amharic Name
</label>
<Input
placeholder="Amharic Name"
{...register("name.am", { required: "Amharic name is required" })}
/>
{errors.name?.am && (
<p className="text-red-500 text-sm mt-1">
{errors.name.am.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Language
</label>
<Controller
name="locale"
control={control}
rules={{ required: "Language is required" }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="am">Amharic</SelectItem>
</SelectContent>
</Select>
)}
/>
{errors.locale && (
<p className="text-red-500 text-sm mt-1">{errors.locale.message}</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Body
</label>
<Controller
name="body"
control={control}
rules={{ required: true }}
render={({ field, fieldState }) => (
<>
<TemplateEditor
value={field.value}
onEditorChange={field.onChange}
apiKey={editorApiKey}
placeholders={[
{ key: "delegatorName", label: "Delegator Name" },
{ key: "delegatorDepartment", label: "Delegator Department" },
{ key: "delegateeName", label: "Delegatee Name" },
{ key: "delegateeDepartment", label: "Delegatee Department" },
{ key: "startDate", label: "Start Date" },
{ key: "endDate", label: "End Date" },
{ key: "startDateTime", label: "Start Date & Time" },
{ key: "endDateTime", label: "End Date & Time" },
]}
/>
{fieldState.invalid && (
<p className="text-red-500 text-sm mt-1">Body is required</p>
)}
</>
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Sincerely Text
</label>
<Input placeholder="Sincerely Text" {...register("sincerelyText")} />
</div>
<div className="flex gap-2 pt-4 justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : template ? "Update" : "Save"}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,402 @@
import React, { useState } from "react";
import { sanitizeHtml } from "@/shared/lib/sanitize";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { FileText, Plus, Edit, Trash2, Eye } from "lucide-react";
import { TemplateForm } from "./TemplateForm";
import { CreateTemplateTypes } from "../types/templateTypes";
import { useLocalizedName } from "@/shared/common/localizedName";
import { toast } from "sonner";
import { t } from "i18next";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { useTemplate } from "../service/useTemplate";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const TemplateTable = () => {
const { handleError } = useErrorHandler(t);
const {
templates,
isLoading,
isError,
refetch,
createTemplate,
updateTemplate,
deleteTemplate,
isCreatingTemplate,
isUpdatingTemplate,
isDeletingTemplate,
} = useTemplate();
const templateList: CreateTemplateTypes[] = Array.isArray(templates) ? templates : templates?.items || [];
const localizedName = useLocalizedName();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] =
useState<CreateTemplateTypes | null>(null);
const preventDialogCloseFromTinyMce = (event: Event) => {
const target = event.target as HTMLElement | null;
if (
target?.closest(
".tox-tinymce-aux, .moxman-window, .tam-assetmanager-root"
)
) {
event.preventDefault();
}
};
const handleCreate = (values: CreateTemplateTypes) => {
createTemplate(values, {
onSuccess: (newTemplate: any) => {
// Assume API returns the created config or just general success
toast.success(t("contentManagement.templateSuccessMsg"));
refetch();
setIsCreateDialogOpen(false);
},
onError: (error) => {
handleError(error);
},
});
};
const handleUpdate = (values: CreateTemplateTypes) => {
if (!selectedTemplate?.id) return;
updateTemplate({ id: selectedTemplate.id, template: values }, {
onSuccess: () => {
toast.success(t("contentManagement.updateTemplate"));
refetch();
setIsEditDialogOpen(false);
setSelectedTemplate(null);
},
onError: (error) => {
handleError(error);
},
});
};
const handleDelete = () => {
if (!selectedTemplate?.id) return;
deleteTemplate(selectedTemplate.id, {
onSuccess: () => {
toast.success(t("contentManagement.deleteTemplate"));
refetch();
setIsDeleteDialogOpen(false);
setSelectedTemplate(null);
},
onError: (error) => {
handleError(error);
},
});
};
const openEditDialog = (template: CreateTemplateTypes) => {
setSelectedTemplate(template);
setIsEditDialogOpen(true);
};
const openViewDialog = (template: CreateTemplateTypes) => {
setSelectedTemplate(template);
setIsViewDialogOpen(true);
};
const openDeleteDialog = (template: CreateTemplateTypes) => {
setSelectedTemplate(template);
setIsDeleteDialogOpen(true);
};
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t("contentManagement.letterTemplate")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array(3)
.fill(0)
.map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t("contentManagement.letterTemplate")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-red-600">
{t("contentManagement.failedToLoadTemplates")}
</p>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<CardTitle className="flex items-center">
<FileText className="h-5 w-5 mr-2" />
{t("contentManagement.letterTemplate")}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{t("contentManagement.createMsg")}
</p>
</div>
<Button
onClick={() => setIsCreateDialogOpen(true)}
size="sm"
className="shrink-0"
>
<Plus className="h-4 w-4 mr-1" />
{t("contentManagement.addTemplate")}
</Button>
</div>
</CardHeader>
<CardContent>
{templateList.length === 0 ? (
<div className="text-center py-8">
<FileText className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{t("contentManagement.noTemplates")}
</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[150px]">
{t("common.name")}
</TableHead>
<TableHead className="min-w-[200px] hidden md:table-cell">
{t("contentManagement.sincerelyText")}
</TableHead>
<TableHead className="text-right min-w-[120px]">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templateList.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">
<div className="flex flex-col">
<span>{localizedName(template.name)}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 md:hidden">
{template.sincerelyText &&
template.sincerelyText.length > 30
? `${template.sincerelyText.substring(0, 30)}...`
: template.sincerelyText ||
t("common.notAvailable")}
</span>
</div>
</TableCell>
<TableCell className="max-w-xs truncate hidden md:table-cell">
{template.sincerelyText || t("common.notAvailable")}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openViewDialog(template)}
title={t("contentManagement.viewTemplate")}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(template)}
title={t("contentManagement.editTemplate")}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteDialog(template)}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
title={t("contentManagement.deleteTemplate")}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent
className="max-w-[95vw] lg:max-w-[1200px] max-h-[90vh] overflow-y-auto w-full"
onInteractOutside={preventDialogCloseFromTinyMce}
>
<DialogHeader>
<DialogTitle>{t("contentManagement.createTemplate")}</DialogTitle>
</DialogHeader>
<TemplateForm
isSubmitting={isCreatingTemplate}
onCancel={() => setIsCreateDialogOpen(false)}
onSubmitCreate={handleCreate}
/>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent
className="max-w-[95vw] lg:max-w-[1200px] max-h-[90vh] overflow-y-auto w-full"
onInteractOutside={preventDialogCloseFromTinyMce}
>
<DialogHeader>
<DialogTitle>{t("contentManagement.editTemplate")}</DialogTitle>
</DialogHeader>
<TemplateForm
template={selectedTemplate}
isSubmitting={isUpdatingTemplate}
onCancel={() => {
setIsEditDialogOpen(false);
setSelectedTemplate(null);
}}
onSubmitCreate={handleUpdate}
/>
</DialogContent>
</Dialog>
{/* View Dialog */}
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="max-w-[95vw] lg:max-w-[1200px] max-h-[90vh] overflow-y-auto w-full">
<DialogHeader>
<DialogTitle>{t("contentManagement.viewTemplate")}</DialogTitle>
</DialogHeader>
{selectedTemplate && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
{t("common.name")}
</label>
<p className="text-sm text-gray-900 dark:text-gray-100 mt-1">
{localizedName(selectedTemplate.name)}
</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
{t("contentManagement.sincerelyText")}
</label>
<p className="text-sm text-gray-900 dark:text-gray-100 mt-1">
{selectedTemplate.sincerelyText || t("common.notAvailable")}
</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
{t("contentManagement.body")}
</label>
<div
className="text-sm text-gray-900 dark:text-gray-100 mt-1 p-3 border border-gray-200 dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-700 max-h-60 overflow-y-auto"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(selectedTemplate.body) }}
/>
</div>
<div className="flex justify-end">
<Button onClick={() => setIsViewDialogOpen(false)}>
{t("common.close")}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("contentManagement.deleteTemplate")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("contentManagement.deleteTemplateConfirm", {
name: selectedTemplate
? localizedName(selectedTemplate.name)
: "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeletingTemplate}
className="bg-red-600 hover:bg-red-700"
>
{isDeletingTemplate ? t("common.deleting") : t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default TemplateTable;

View File

@@ -0,0 +1,23 @@
import React from "react";
import TemplateTable from "./TemplateTable";
const TemplatePage = () => {
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold bg-gradient-to-r from-primary to-primary-400 bg-clip-text text-transparent">
Manage Delegation Templates
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Create, edit, and view delegation global templates.
</p>
</div>
<div className="space-y-6">
<TemplateTable />
</div>
</div>
);
};
export default TemplatePage;

View File

@@ -0,0 +1,105 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
adoptDelegationTemplate,
createDelegationTemplate,
deleteDelegationTemplate,
getDelegationTemplateById,
getDelegationTemplates,
updateDelegationTemplate,
} from "@/super-admin/services/api/delegationTemplate";
import { CreateTemplateTypes } from "../types/templateTypes";
import { AdoptTemplateTypes } from "../template";
export const useTemplate = (id?: string) => {
const {
data: templates,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["templates"],
queryFn: async () => {
return await getDelegationTemplates();
},
});
const {
data: template,
isLoading: isLoadingTemplate,
isError: isErrorTemplate,
refetch: refetchTemplate,
} = useQuery({
queryKey: ["template", id],
queryFn: async () => {
return await getDelegationTemplateById(id!, { take: 300 });
},
});
const {
mutate: createTemplate,
isPending: isCreatingTemplate,
isError: isErrorCreateTemplate,
} = useMutation({
mutationFn: async (template: CreateTemplateTypes) => {
return await createDelegationTemplate(template);
},
});
const {
mutate: updateTemplate,
isPending: isUpdatingTemplate,
isError: isErrorUpdateTemplate,
} = useMutation({
mutationFn: async ({
id,
template,
}: {
id: string;
template: CreateTemplateTypes;
}) => {
return await updateDelegationTemplate(id, template);
},
});
const {
mutate: deleteTemplate,
isPending: isDeletingTemplate,
isError: isErrorDeleteTemplate,
} = useMutation({
mutationFn: async (id: string) => {
return await deleteDelegationTemplate(id);
},
});
const {
mutate: adoptTemplate,
isPending: isAdoptingTemplate,
isError: isErrorAdoptingTemplate,
} = useMutation({
mutationFn: async (item: AdoptTemplateTypes) => {
return await adoptDelegationTemplate(item);
},
});
return {
templates,
isLoading,
isError,
refetch,
template,
isLoadingTemplate,
isErrorTemplate,
refetchTemplate,
createTemplate,
isCreatingTemplate,
isErrorCreateTemplate,
updateTemplate,
isUpdatingTemplate,
isErrorUpdateTemplate,
deleteTemplate,
isDeletingTemplate,
isErrorDeleteTemplate,
adoptTemplate,
isAdoptingTemplate,
isErrorAdoptingTemplate,
};
};

View File

@@ -0,0 +1,17 @@
export interface CreateTemplateTypes {
sincerelyText: string,
body: string,
subject: string,
key: string,
name: {
am: string,
en: string
}
}
export interface AdoptTemplateTypes {
templateId: string,
headerId: string,
footerId: string,
}

View File

@@ -0,0 +1,12 @@
export interface CreateTemplateTypes {
id?: string;
sincerelyText: string;
body: string;
subject: string;
key: string;
name: {
am: string;
en: string;
};
locale: "am" | "en";
}

View File

@@ -0,0 +1,37 @@
export type UserStatus = "pending" | "accepted" | "rejected" | "submitted"; // Based on backend API response
export interface LocalizedName {
am: string;
en: string;
}
export interface ExternalUserDto {
id: string;
createdAt: string;
updatedAt: string;
name: LocalizedName;
username: string;
phoneNumber: string;
email: string;
userType: "external_organization" | "employee" | "individual";
sharepointId: string | null;
status: UserStatus;
isActive: boolean;
hasSetPassword: boolean;
isPhoneNumberVerified: boolean;
}
export interface ExternalUsersResponseDto {
count: number;
items: ExternalUserDto[];
}
export enum UserType {
NON_SMART_USER = "non_smart_user",
INTERNAL = "internal",
}
export interface UserTypeDto {
type: UserType;
count: number;
}

View File

@@ -0,0 +1,33 @@
export interface SiteNameDto {
am: string;
en: string;
}
export interface SiteDto {
id: string;
name: SiteNameDto;
key: string;
domain: string;
status: "Active" | "Archived" | string;
createdAt?: string;
updatedAt?: string;
}
export interface SitePayloadDto {
name: SiteNameDto;
key: string;
domain: string;
}
export interface SitesResponseDto {
items: SiteDto[];
count: number;
}
export interface SiteQueryParams {
orderBy?: string;
take?: number;
skip?: number;
order?: string;
search?: string;
}

View File

@@ -0,0 +1,51 @@
export interface MetadataDTO {
uploadedBy: {
am: string;
en: string;
};
deliveredBy: {
am: string;
en: string;
};
organizationName: {
am: string;
en: string;
};
}
export interface ContentDTO {
id: string;
subject: string;
receiverEmployees: any[]; // replace `any` with proper type if known
receiverPositions: any[]; // replace `any` with proper type if known
}
export interface RecordDTO {
createdAt: string;
updatedAt: string;
id: string;
referenceNumber: string;
letterNumber: string;
metadata: MetadataDTO;
dispatchedDate: string;
amharicDispatchedDate: string;
amharicCreatedAt: string;
statusKey: string;
recordTypeKey: string;
initiator: string | null;
content: ContentDTO[];
}
export interface MainDTO {
createdAt: string;
updatedAt: string;
id: string;
amharicCreatedAt: string;
status: string;
record: RecordDTO;
}
export interface MainDtoResponse {
items:MainDTO[];
count:number;
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,15 @@
import { ItemDTO, User } from "@/shared/dto/user/usersDto";
import { getArchivedUserId, OrgQueryParams } from "@/shared/services/organizationsService";
import { useQuery } from "@tanstack/react-query";
export const useArchivedUsers = (UnitId: string,params?:OrgQueryParams) => {
return useQuery({
queryKey: ["archived-users", UnitId,params],
queryFn: async () => {
if (!UnitId) return { items: [], count: 0 };
const { data } = await getArchivedUserId(UnitId,params);
return data as { items: ItemDTO[]; count: number };
},
enabled: !!UnitId,
});
};

View File

@@ -0,0 +1,167 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import {
createGlobalOrgConfig,
updateGlobalOrgConfig,
getGlobalOrgConfig,
getListOfGlobalOrgConfig,
createGlobalUnitConfig,
updateGlobalUnitConfig,
deleteGlobalUnitConfig,
getGlobalUnitConfig,
getListOfGlobalUnitConfig,
GlobalOrgConfig,
GlobalUnitConfig,
createOrganizationConfig,
updateOrganizationConfig,
deleteOrganizationConfig,
getOrganizationConfig,
OrganizationConfigPayload,
} from "@/shared/services/organizationConfigService";
// 🔹 Global Org Config Hooks
export const useGetGlobalOrgConfig = (id: string) => {
return useQuery<AxiosResponse>({
queryKey: ["globalOrgConfig", id],
queryFn: () => getGlobalOrgConfig(id),
enabled: !!id,
});
};
export const useGetListOfGlobalOrgConfig = (id: string) => {
return useQuery<AxiosResponse>({
queryKey: ["listGlobalOrgConfig", id],
queryFn: () => getListOfGlobalOrgConfig(id),
enabled: !!id,
});
};
export const useCreateGlobalOrgConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: GlobalOrgConfig) => createGlobalOrgConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listGlobalOrgConfig"] });
},
});
};
export const useUpdateGlobalOrgConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: GlobalOrgConfig }) =>
updateGlobalOrgConfig(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["globalOrgConfig", variables.id],
});
queryClient.invalidateQueries({
queryKey: ["listGlobalOrgConfig"],
});
},
});
};
// 🔹 Global Unit Config Hooks
export const useGetGlobalUnitConfig = (id: string) => {
return useQuery<AxiosResponse>({
queryKey: ["globalUnitConfig", id],
queryFn: () => getGlobalUnitConfig(id),
enabled: !!id,
});
};
export const useGetListOfGlobalUnitConfig = (id: string) => {
return useQuery<AxiosResponse>({
queryKey: ["listGlobalUnitConfig", id],
queryFn: () => getListOfGlobalUnitConfig(id),
enabled: !!id,
});
};
export const useCreateGlobalUnitConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => createGlobalUnitConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listGlobalUnitConfig"] });
},
});
};
export const useUpdateGlobalUnitConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
updateGlobalUnitConfig(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["globalUnitConfig", variables.id],
});
queryClient.invalidateQueries({
queryKey: ["listGlobalUnitConfig"],
});
},
});
};
export const useDeleteGlobalUnitConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteGlobalUnitConfig(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listGlobalUnitConfig"] });
queryClient.invalidateQueries({ queryKey: ["globalUnitConfig"] });
},
});
};
// 🔹 Organization Configuration Hooks (/organization-configurations)
export const useGetOrganizationConfig = (organizationId: string) => {
return useQuery<AxiosResponse>({
queryKey: ["organizationConfig", organizationId],
queryFn: () => getOrganizationConfig(organizationId),
enabled: !!organizationId,
});
};
export const useCreateOrganizationConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: OrganizationConfigPayload) =>
createOrganizationConfig(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["organizationConfig", variables.organizationId],
});
},
});
};
export const useUpdateOrganizationConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: OrganizationConfigPayload;
}) => updateOrganizationConfig(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["organizationConfig", variables.data.organizationId],
});
},
});
};
export const useDeleteOrganizationConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteOrganizationConfig(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizationConfig"] });
},
});
};

View File

@@ -0,0 +1,194 @@
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import { getOrganizations } from "@/shared/services/organizationsService";
import { useQuery } from "@tanstack/react-query";
import Cookies from "js-cookie";
import { toast } from "sonner";
import { useOrganizations } from "./useOrganizations";
// Types for dashboard data
export interface DashboardStats {
totalOrganizations: number;
activeOrganizations: number;
inactiveOrganizations: number;
pendingRequests: number;
orgAdmins: number;
recentActivities: Activity[];
percentChanges: {
orgs: number;
active: number;
inactive: number;
pending: number;
admins: number;
};
}
export interface Activity {
id: string;
type: string;
description: string;
user: string;
timestamp: string;
entity: string;
entityId: string;
}
// Mock data
const mockDashboardData: DashboardStats = {
totalOrganizations: 48,
activeOrganizations: 35,
inactiveOrganizations: 13,
pendingRequests: 7,
orgAdmins: 21,
recentActivities: [
{
id: "1",
type: "CREATE",
description: "Created new organization",
user: "John Doe",
timestamp: "2023-07-15T10:30:00Z",
entity: "Woreda 01",
entityId: "org-1",
},
{
id: "2",
type: "UPDATE",
description: "Updated organization status",
user: "Jane Smith",
timestamp: "2023-07-14T14:45:00Z",
entity: "Woreda 02",
entityId: "org-2",
},
{
id: "3",
type: "DELETE",
description: "Deleted organization",
user: "Admin User",
timestamp: "2023-07-13T09:15:00Z",
entity: "Test Org",
entityId: "org-3",
},
{
id: "4",
type: "CREATE",
description: "Added admin user",
user: "System",
timestamp: "2023-07-12T16:20:00Z",
entity: "Samuel Bekele",
entityId: "user-1",
},
{
id: "5",
type: "UPDATE",
description: "Changed organization settings",
user: "Jane Smith",
timestamp: "2023-07-11T11:05:00Z",
entity: "Yeka Subcity",
entityId: "org-4",
},
],
percentChanges: {
orgs: 12.5,
active: 8.2,
inactive: -3.7,
pending: -15.4,
admins: 5.6,
},
};
const fetchDashboardStats = async (
totalOrganizations: number
): Promise<DashboardStats> => {
// Use mock data for now, but replace totalOrganizations with the actual count
const dashboardData = { ...mockDashboardData };
dashboardData.totalOrganizations =
totalOrganizations || dashboardData.totalOrganizations;
return dashboardData;
};
const fetchRecentOrganizations = async (): Promise<OrganizationDto[]> => {
try {
const tenantKey = Cookies.get("tenant-key");
if (!tenantKey) {
console.error("No tenant key found");
return [];
}
const headers = {
tenantKey,
unitId: Cookies.get("unit-id") || undefined,
};
const { data } = await getOrganizations({
orderBy: "createdAt",
take: 5,
});
return data.items;
} catch (error) {
console.error("Error fetching recent organizations:", error);
return [];
}
};
export const useDashboardData = () => {
const { organizationsResponse } = useOrganizations("Org");
const {
data: stats,
isLoading: isStatsLoading,
isError: isStatsError,
refetch: refetchStats,
} = useQuery({
queryKey: ["dashboardStats"],
queryFn: async () => {
try {
const totalOrganizations = organizationsResponse?.count || 0;
return await fetchDashboardStats(totalOrganizations);
} catch (error) {
console.error("Failed to fetch dashboard statistics:", error);
toast.error("Failed to fetch dashboard statistics");
return mockDashboardData;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
const {
data: recentOrgs,
isLoading: isOrgsLoading,
isError: isOrgsError,
refetch: refetchOrgs,
} = useQuery({
queryKey: ["recentOrganizations"],
queryFn: async () => {
try {
return await fetchRecentOrganizations();
} catch (error) {
console.error("Failed to fetch recent organizations:", error);
toast.error("Failed to fetch recent organizations");
return [];
}
},
staleTime: 2 * 60 * 1000, // 2 minutes
retry: 1,
});
const isLoading = isStatsLoading || isOrgsLoading;
const isError = isStatsError || isOrgsError;
const refetch = () => {
refetchStats();
refetchOrgs();
};
const orgCount = organizationsResponse?.count;
return {
stats: stats || orgCount,
recentOrgs: recentOrgs || [],
isLoading,
isError,
refetch,
};
};

View File

@@ -0,0 +1,253 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { Position } from "./usePositions";
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface Department {
id: string;
name: string;
positions: Position[];
isExpanded?: boolean;
parentDepartmentId?: string;
}
export const useDepartments = (unitId: string) => {
const [departments, setDepartments] = useState<Department[]>([]);
const [selectedDepartmentId, setSelectedDepartmentId] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Fetch departments for a unit
const fetchDepartments = async () => {
if (!unitId) {
setDepartments([]);
setSelectedDepartmentId("");
return;
}
setLoading(true);
try {
// In a real app, this would be an API call
setTimeout(() => {
// Mock departments for this unit
const mockDepartments: Department[] = [
{
id: "dept1",
name: "Directorate",
positions: [],
isExpanded: true,
},
{
id: "dept2",
name: "Development",
positions: [],
isExpanded: false,
},
{
id: "dept3",
name: "Operations",
positions: [],
isExpanded: false,
},
];
setDepartments(mockDepartments);
if (mockDepartments.length > 0) {
setSelectedDepartmentId(mockDepartments[0].id);
} else {
setSelectedDepartmentId("");
}
setLoading(false);
}, 500);
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to fetch departments")
);
setLoading(false);
}
};
// Create a new department
const createDepartment = async (
departmentData: Partial<Department>
): Promise<Department> => {
if (!unitId) {
throw new Error("No unit selected");
}
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.post<ApiResponse<Department>>(
// `/api/units/${unitId}/departments`,
// departmentData
// );
// const newDepartment = response.data.data;
// For now, simulate API response
const newDepartment: Department = {
id: `dept-${Date.now()}`,
name: departmentData.name || "New Department",
positions: [],
isExpanded: false,
parentDepartmentId: departmentData.parentDepartmentId,
};
setDepartments((prev) => [...prev, newDepartment]);
setLoading(false);
return newDepartment;
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to create department")
);
setLoading(false);
throw err;
}
};
// Update an existing department
const updateDepartment = async (
departmentId: string,
updates: Partial<Department>
): Promise<Department> => {
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.put<ApiResponse<Department>>(
// `/api/departments/${departmentId}`,
// updates
// );
// const updatedDepartment = response.data.data;
// For now, simulate API response
const updatedDepartments = departments.map((department) =>
department.id === departmentId
? { ...department, ...updates }
: department
);
setDepartments(updatedDepartments);
setLoading(false);
const updatedDepartment = updatedDepartments.find(
(d) => d.id === departmentId
);
if (!updatedDepartment) {
throw new Error("Department not found after update");
}
return updatedDepartment;
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to update department")
);
setLoading(false);
throw err;
}
};
// Delete a department
const deleteDepartment = async (departmentId: string): Promise<void> => {
setLoading(true);
try {
// In a real app, this would be an API call
// await axios.delete<ApiResponse<void>>(`/api/departments/${departmentId}`);
// For now, simulate API response
setDepartments((prev) =>
prev.filter((department) => department.id !== departmentId)
);
// If the deleted department was selected, select another department
if (selectedDepartmentId === departmentId) {
const remaining = departments.filter(
(department) => department.id !== departmentId
);
if (remaining.length > 0) {
setSelectedDepartmentId(remaining[0].id);
} else {
setSelectedDepartmentId("");
}
}
setLoading(false);
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to delete department")
);
setLoading(false);
throw err;
}
};
// Add a sub-department
const addSubDepartment = async (
parentDepartmentId: string,
name: string
): Promise<Department> => {
const subdepartment: Partial<Department> = {
name,
parentDepartmentId,
};
return createDepartment(subdepartment);
};
// Toggle expand/collapse for a department
const toggleDepartmentExpand = (departmentId: string) => {
setDepartments((prev) =>
prev.map((department) =>
department.id === departmentId
? { ...department, isExpanded: !department.isExpanded }
: department
)
);
};
// Get root-level departments (no parent)
const getRootDepartments = () => {
return departments.filter((department) => !department.parentDepartmentId);
};
// Get sub-departments of a department
const getSubDepartments = (parentDepartmentId: string) => {
return departments.filter(
(department) => department.parentDepartmentId === parentDepartmentId
);
};
// Select a department
const selectDepartment = (departmentId: string) => {
setSelectedDepartmentId(departmentId);
};
// Load departments when unitId changes
useEffect(() => {
fetchDepartments();
}, [unitId]);
return {
departments,
selectedDepartmentId,
selectedDepartment: departments.find(
(department) => department.id === selectedDepartmentId
),
loading,
error,
fetchDepartments,
createDepartment,
updateDepartment,
deleteDepartment,
addSubDepartment,
toggleDepartmentExpand,
getRootDepartments,
getSubDepartments,
selectDepartment,
};
};

View File

@@ -0,0 +1,341 @@
import { useState, useEffect } from "react";
import axios from "axios";
export type InviteStatus = "Pending" | "Accepted" | "Not Invited";
export type EmployeeRole =
| "TeamLeader"
| "TeamMember"
| "Director"
| "Manager"
| "Regular";
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface Employee {
id: string;
name: string;
email?: string;
role?: EmployeeRole;
inviteStatus?: InviteStatus;
title?: string;
phone?: string;
dateAdded?: string;
}
export const useEmployees = (positionId: string, departmentId?: string) => {
const [employees, setEmployees] = useState<Employee[]>([]);
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Fetch employees for a position or department
const fetchEmployees = async () => {
if (!positionId && !departmentId) {
setEmployees([]);
setSelectedEmployeeId("");
return;
}
setLoading(true);
try {
// In a real app, this would be an API call
// if (positionId) {
// const response = await axios.get<ApiResponse<Employee[]>>(`/api/positions/${positionId}/employees`);
// setEmployees(response.data.data);
// } else if (departmentId) {
// const response = await axios.get<ApiResponse<Employee[]>>(`/api/departments/${departmentId}/employees`);
// setEmployees(response.data.data);
// }
// For now, simulate API response with mock data
setTimeout(() => {
const mockEmployees: Employee[] = [
{
id: "emp1",
name: "Abebe Kebede",
email: "abebe@example.com",
role: "Director",
title: "Department Director",
phone: "+251911123456",
dateAdded: "2023-05-15",
inviteStatus: "Accepted",
},
{
id: "emp2",
name: "Kebede Abebe",
email: "kebede@example.com",
role: "TeamLeader",
title: "Team Lead - Development",
phone: "+251922123456",
dateAdded: "2023-06-20",
inviteStatus: "Pending",
},
{
id: "emp3",
name: "Chala Demeke",
email: "chala@example.com",
role: "TeamMember",
title: "Senior Developer",
phone: "+251933123456",
dateAdded: "2023-07-10",
inviteStatus: "Not Invited",
},
{
id: "emp4",
name: "Tigist Alemu",
email: "tigist@example.com",
role: "Manager",
title: "Project Manager",
phone: "+251944123456",
dateAdded: "2023-08-05",
inviteStatus: "Accepted",
},
];
setEmployees(mockEmployees);
if (mockEmployees.length > 0) {
setSelectedEmployeeId(mockEmployees[0].id);
} else {
setSelectedEmployeeId("");
}
setLoading(false);
}, 500);
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to fetch employees")
);
setLoading(false);
}
};
// Create a new employee
const createEmployee = async (
employeeData: Partial<Employee>
): Promise<Employee> => {
if (!positionId && !departmentId) {
throw new Error("No position or department selected");
}
setLoading(true);
try {
// In a real app, this would be an API call
// let response;
// if (positionId) {
// response = await axios.post<ApiResponse<Employee>>(
// `/api/positions/${positionId}/employees`,
// employeeData
// );
// } else if (departmentId) {
// response = await axios.post<ApiResponse<Employee>>(
// `/api/departments/${departmentId}/employees`,
// employeeData
// );
// } else {
// throw new Error('No position or department ID provided');
// }
// const newEmployee = response.data.data;
// For now, simulate API response
const newEmployee: Employee = {
id: `emp-${Date.now()}`,
name: employeeData.name || "New Employee",
email: employeeData.email,
role: employeeData.role || "Regular",
title: employeeData.title,
phone: employeeData.phone,
dateAdded: new Date().toISOString().split("T")[0],
inviteStatus: employeeData.inviteStatus || "Not Invited",
};
setEmployees((prev) => [...prev, newEmployee]);
setLoading(false);
return newEmployee;
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to create employee")
);
setLoading(false);
throw err;
}
};
// Update an existing employee
const updateEmployee = async (
employeeId: string,
updates: Partial<Employee>
): Promise<Employee> => {
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.put<ApiResponse<Employee>>(
// `/api/employees/${employeeId}`,
// updates
// );
// const updatedEmployee = response.data.data;
// For now, simulate API response
const updatedEmployees = employees.map((employee) =>
employee.id === employeeId ? { ...employee, ...updates } : employee
);
setEmployees(updatedEmployees);
setLoading(false);
const updatedEmployee = updatedEmployees.find((e) => e.id === employeeId);
if (!updatedEmployee) {
throw new Error("Employee not found after update");
}
return updatedEmployee;
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to update employee")
);
setLoading(false);
throw err;
}
};
// Delete an employee
const deleteEmployee = async (employeeId: string): Promise<void> => {
setLoading(true);
try {
// In a real app, this would be an API call
// await axios.delete<ApiResponse<void>>(`/api/employees/${employeeId}`);
// For now, simulate API response
setEmployees((prev) =>
prev.filter((employee) => employee.id !== employeeId)
);
// If the deleted employee was selected, select another employee
if (selectedEmployeeId === employeeId) {
const remaining = employees.filter(
(employee) => employee.id !== employeeId
);
if (remaining.length > 0) {
setSelectedEmployeeId(remaining[0].id);
} else {
setSelectedEmployeeId("");
}
}
setLoading(false);
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to delete employee")
);
setLoading(false);
throw err;
}
};
// Send invitation to an employee
const inviteEmployee = async (employeeId: string): Promise<string> => {
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.post<ApiResponse<{ inviteLink: string }>>(
// `/api/employees/${employeeId}/invite`
// );
// return response.data.data.inviteLink;
// For now, simulate API response
return new Promise((resolve) => {
setTimeout(() => {
const inviteLink = `https://example.com/invite/${employeeId}-${Date.now()}`;
// Update employee status in state
setEmployees((prev) =>
prev.map((employee) =>
employee.id === employeeId
? { ...employee, inviteStatus: "Pending" as InviteStatus }
: employee
)
);
setLoading(false);
resolve(inviteLink);
}, 1000);
});
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to send invitation")
);
setLoading(false);
throw err;
}
};
// Get invite status for an employee
const getInviteStatus = async (employeeId: string): Promise<InviteStatus> => {
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.get<ApiResponse<{ status: InviteStatus }>>(
// `/api/employees/${employeeId}/invite-status`
// );
// return response.data.data.status;
// For now, simulate API response
return new Promise((resolve) => {
setTimeout(() => {
const employee = employees.find((e) => e.id === employeeId);
setLoading(false);
resolve(employee?.inviteStatus || "Not Invited");
}, 500);
});
} catch (err) {
setError(
err instanceof Error
? err
: new Error("Failed to get invitation status")
);
setLoading(false);
throw err;
}
};
// Select an employee
const selectEmployee = (employeeId: string) => {
setSelectedEmployeeId(employeeId);
};
// Filter employees by role
const getEmployeesByRole = (role: EmployeeRole) => {
return employees.filter((employee) => employee.role === role);
};
// Filter employees by invite status
const getEmployeesByInviteStatus = (status: InviteStatus) => {
return employees.filter((employee) => employee.inviteStatus === status);
};
// Load employees when positionId or departmentId changes
useEffect(() => {
fetchEmployees();
}, [positionId, departmentId]);
return {
employees,
selectedEmployeeId,
selectedEmployee: employees.find(
(employee) => employee.id === selectedEmployeeId
),
loading,
error,
fetchEmployees,
createEmployee,
updateEmployee,
deleteEmployee,
inviteEmployee,
getInviteStatus,
selectEmployee,
getEmployeesByRole,
getEmployeesByInviteStatus,
};
};

View File

@@ -0,0 +1,106 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
activateUser,
approveUser,
deactivateUser,
deleteUser,
getAllExternalUsers,
getPendingUsers,
} from "../services/api/userService";
// 📦 Query Key
const queryKeys = {
pendingUsers: ["external-users", "pending"],
allExternalUsers: ["external-users", "all"],
};
export enum userTypeEnum {
external = "external_organization",
individual = "individual",
externalUsers = "external_organization,individual",
}
export interface ExternalQueryParams {
orderBy?: string;
take?: number;
skip?: number;
order?: string;
userType?: userTypeEnum;
name?: string;
email?: string;
username?: string;
phoneNumber?: string;
}
// ✅ Hook: Get pending external users
export const usePendingExternalUsers = (params?: ExternalQueryParams) => {
return useQuery({
queryKey: queryKeys.pendingUsers,
queryFn: async () => {
const users = await getPendingUsers(params);
return users.data;
},
});
};
// ✅ Hook: Get all external users
export const useAllExternalUsers = (params?: ExternalQueryParams) => {
return useQuery({
queryKey: [...queryKeys.allExternalUsers, params],
queryFn: async () => {
const users = await getAllExternalUsers(params);
return users.data;
},
});
};
// ✅ Mutation: Activate user
export const useActivateExternalUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => activateUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.pendingUsers });
queryClient.invalidateQueries({ queryKey: queryKeys.allExternalUsers });
},
});
};
// Mutatae Approve user
export const useApproveExternalUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
approveUser(id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.pendingUsers });
queryClient.invalidateQueries({ queryKey: queryKeys.allExternalUsers });
},
});
};
// ✅ Mutation: Deactivate user
export const useDeactivateExternalUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deactivateUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.pendingUsers });
queryClient.invalidateQueries({ queryKey: queryKeys.allExternalUsers });
},
});
};
// ✅ Mutation: Delete user
export const useDeleteExternalUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.pendingUsers });
queryClient.invalidateQueries({ queryKey: queryKeys.allExternalUsers });
},
});
};

View File

@@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { MainDtoResponse } from "../dto/SuperAdminDto";
import { getAllRecords } from "../services/api/userService";
import { ExternalQueryParams } from "./useExternalUsers";
export const useMigratedData = (unitId: string,params?:ExternalQueryParams) => {
return useQuery({
queryKey: ["migratedData",unitId,params],
queryFn: async () => {
const { data } = await getAllRecords(unitId,params);
return data as MainDtoResponse;
},
});
};

View File

@@ -0,0 +1,186 @@
import { resendVerificationCode } from "@/shared/services/authService";
import {
assignOrganizationAdmin,
assignUnitAdmin,
fetchAllUnitAdminById,
fetchUnitAdminById,
getOrganizationAdminById,
getOrganizationAdmins,
OrgAdminQueryParams,
OrganizationAdminPayload,
UnitAdminPayload,
updateOrganizationAdmin,
} from "@/super-admin/services/api/organizationAdminService";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
// Resend verification code API call
const resendVerificationCodeToAdmin = async ({
organizationId,
email,
phoneNumber,
}: {
organizationId: string;
email: string;
phoneNumber: string;
}) => {
const response = await fetch("/api/resend-verification-code", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ organizationId, email, phoneNumber }),
});
if (!response.ok) {
throw new Error("Failed to resend verification code");
}
return response.json();
};
export const useOrganizationAdmins = (params?: OrgAdminQueryParams) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// Get all admins
const {
data: adminsResponse,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["organizationAdmins", params],
queryFn: async () => {
const { data } = await getOrganizationAdmins(params);
return {
count: data,
items: data,
};
},
staleTime: 5 * 60 * 1000,
retry: false,
enabled: false,
});
// Create admin
const { mutate: createAdminMutation, isPending: isCreating } = useMutation({
mutationFn: async ({
payload,
successCallback,
}: {
payload: OrganizationAdminPayload;
successCallback: () => void;
}) => assignOrganizationAdmin(payload),
onSuccess: (_data, variables) => {
toast.success("Admin assigned successfully");
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
variables.successCallback();
},
onError: (error) => {
handleError(error);
},
});
const { mutate: createUnitAdminMutation, isPending: isUnitCreating } = useMutation({
mutationFn: async ({
payload,
successCallback,
}: {
payload: UnitAdminPayload;
successCallback: () => void;
}) => assignUnitAdmin(payload),
onSuccess: (_data, variables) => {
toast.success("Admin assigned successfully");
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
variables.successCallback();
},
onError: (error) => {
handleError(error);
},
});
// Update admin
const { mutate: updateAdminMutation, isPending: isUpdating } = useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: OrganizationAdminPayload;
}) => updateOrganizationAdmin(id, data),
onSuccess: () => {
toast.success("Admin updated successfully");
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
},
onError: (error) => {
handleError(error);
},
});
// Get admin by ID
const getAdminById = async (id: string) => {
try {
return await getOrganizationAdminById(id);
} catch (error) {
toast.error("Failed to fetch admin details");
return undefined;
}
};
const getUnitAdminById = async (id: string) => {
try {
return await fetchUnitAdminById(id);
} catch (error) {
toast.error("Failed to fetch unit admin details");
return undefined;
}
};
const getAllAdminById = async (id: string, params?: OrgAdminQueryParams) => {
try {
return await fetchAllUnitAdminById(id, params);
} catch (error) {
toast.error("Failed to fetch all admin details");
return undefined;
}
};
// Resend invitation (verification code)
const resendInvitation = async ({
email,
phoneNumber,
}: {
email: string;
phoneNumber: string;
}) => {
try {
await resendVerificationCode({
email,
phoneNumber,
});
} catch (error) {
console.error("Failed to resend verification code:", error);
throw error;
}
};
return {
adminsResponse,
createAdmin: createAdminMutation,
createUnitAdmin:createUnitAdminMutation,
updateAdmin: updateAdminMutation,
getAdminById,
getUnitAdminById,
resendInvitation,
getAllAdminById,// 👈 important to expose this
isCreating,
isUpdating,
isLoading,
isError,
refetch,
};
};

View File

@@ -0,0 +1,83 @@
import { useQuery } from "@tanstack/react-query";
import {
getOrganizationTypes,
OrgTypeResponse,
OrgType,
} from "@/super-admin/services/api/organizationTypesService";
import Cookies from "js-cookie";
// Mock data to use when the API fails
const MOCK_ORGANIZATION_TYPES: OrgType[] = [
{
id: "1",
name: {
en: "Government",
am: "መንግስታዊ",
},
key: "government",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "2",
name: {
en: "Private",
am: "የግል",
},
key: "private",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "3",
name: {
en: "Non-Governmental",
am: "መንግስታዊ ያልሆነ",
},
key: "ngo",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
export const useOrganizationTypes = () => {
const {
data: organizationTypesResponse,
isLoading,
isError,
refetch,
} = useQuery<OrgTypeResponse>({
queryKey: ["organizationTypes"],
queryFn: async () => {
try {
// Use a default tenant key if none is found
const tenantKey = Cookies.get("tenant-key") || "default-tenant-key";
const unitId = Cookies.get("unit-id");
const response = await getOrganizationTypes();
return response.data;
} catch (error) {
console.error("Error fetching organization types:", error);
// Return mock data so the UI doesn't break
return {
count: MOCK_ORGANIZATION_TYPES.length,
items: MOCK_ORGANIZATION_TYPES,
};
}
},
staleTime: 5 * 60 * 1000,
// Always enable the query, regardless of tenant key
enabled: true,
});
return {
// Ensure we always return at least the mock data if nothing is returned
organizationTypesResponse: organizationTypesResponse || {
count: MOCK_ORGANIZATION_TYPES.length,
items: MOCK_ORGANIZATION_TYPES,
},
isLoading,
isError,
refetch,
};
};

View File

@@ -0,0 +1,251 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createOrganization,
OrgQueryParams,
OrganizationPayload,
getOrganizationById,
updateOrganization,
activateOrganization,
deActivateOrganization,
getOrganizationsWithAdminFlag,
softDeleteOrganization,
} from "@/shared/services/organizationsService";
import {
OrganizationAdminsDto,
OrganizationDetailDTO,
OrganizationDto,
} from "@/shared/dto/organization/organizationDto";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
interface Unit {
id: string;
name: string;
departments: any[];
isExpanded?: boolean;
}
export interface Organization {
id: string;
name: string;
units: Unit[];
isExpanded?: boolean;
}
export const useOrganizations = (
type: "Admin" | "Org",
params?: OrgQueryParams,
) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const {
data: organizationsResponse,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["organizations", params],
queryFn: async () => {
const { data } = await getOrganizationsWithAdminFlag(params);
return {
count: data.count as number,
items: data.items as OrganizationDto[],
};
},
staleTime: 5 * 60 * 1000,
retry: false,
enabled: !!(type === "Org"),
});
const {
data: organizationsAdminsResponse,
isLoading: isLoadingAdmins,
isError: isErrorAdmins,
refetch: refetchAdmins,
} = useQuery({
queryKey: ["organizationAdmins", params?.skip, params?.take, params?.name, params?.orderBy],
queryFn: async () => {
const { data } = await getOrganizationsWithAdminFlag(params);
return {
count: data.count as number,
items: data.items as OrganizationAdminsDto[],
};
},
staleTime: 5 * 60 * 1000,
retry: false,
enabled: !!(type === "Admin"),
});
const {
mutate: createMutation,
isPending: isOrganizationCreating,
isError: organizationCreationError,
status: organizationCreationStatus,
} = useMutation({
mutationFn: async (payload: OrganizationPayload) => {
const { data } = await createOrganization(payload);
return data;
},
onSuccess: () => {
toast.success("Organization created successfully!", {
description: "Redirecting...",
});
navigate("/user-management/organizations");
queryClient.invalidateQueries({ queryKey: ["organizations"] });
},
onError: (error) => {
handleError(error);
},
});
const {
mutate: editMutation,
isPending: isOrganizationEditing,
isError: organizationEditError,
status: organizationEditStatus,
} = useMutation({
mutationFn: async ({
id,
payload,
}: {
id: string;
payload: OrganizationPayload;
}) => {
const { data } = await updateOrganization(id, payload);
return data;
},
onSuccess: () => {
toast.success("Organization created successfully!", {
description: "Redirecting...",
});
navigate("/user-management/organizations");
queryClient.invalidateQueries({ queryKey: ["organizations"] });
},
onError: (error) => {
handleError(error);
},
});
const { mutate: activateMutation, isPending: isOrganizationActivating } =
useMutation({
mutationFn: async ({ id }: { id: string }) => {
const { data } = await activateOrganization(id);
return data;
},
onSuccess: () => {
toast.success("Organization Activated successfully!", {
description: "Redirecting...",
});
navigate("/user-management/organizations");
queryClient.invalidateQueries({ queryKey: ["organizations"] });
},
onError: (error) => {
handleError(error);
},
});
const { mutate: deActivateMutation, isPending: isOrganizationDeactivating } =
useMutation({
mutationFn: async ({ id }: { id: string }) => {
const { data } = await deActivateOrganization(id);
return data;
},
onSuccess: () => {
toast.success("Organization De-Activated successfully!", {
description: "Redirecting...",
});
navigate("/user-management/organizations");
queryClient.invalidateQueries({ queryKey: ["organizations"] });
},
onError: (error) => {
handleError(error);
},
});
const { mutate: deleteMutation, isPending: isOrganizationDeleting } =
useMutation({
mutationFn: async ({ id }: { id: string }) => {
const { data } = await softDeleteOrganization(id);
return data;
},
onSuccess: () => {
toast.success("Organization Deleted successfully!", {
description: "Redirecting...",
});
navigate("/user-management/organizations");
queryClient.invalidateQueries({ queryKey: ["organizations"] });
},
onError: (error) => {
handleError(error);
},
});
const { mutate: getOrganization, isPending: isFetchingOrganization } =
useMutation({
mutationFn: async (id: string) => {
const { data } = await getOrganizationById(id);
return data;
},
onSuccess: (data) => {
return data;
},
});
return {
organizationsResponse,
isLoading,
isError,
refetch,
createOrganization: createMutation,
editOrganization: editMutation,
isCreating: isOrganizationCreating,
isEditing: isOrganizationEditing,
editStatus: organizationEditStatus,
organizationCreationError,
organizationEditError,
getOrganizationByDetails: getOrganization,
isFetchingOrganization,
activateOrganization: activateMutation,
isOrganizationActivating,
deactivateOrganization: deActivateMutation,
isOrganizationDeactivating,
deleteOrganization: deleteMutation,
isOrganizationDeleting,
organizationsAdminsResponse,
isLoadingAdmins,
isErrorAdmins,
refetchAdmins,
};
};
export function useOrganizationDetail(type: string, id: string) {
const query = useQuery<{ items: OrganizationDetailDTO }, Error>({
queryKey: ["organization-detail", id],
queryFn: async () => {
const { data } = await getOrganizationById(id);
return { items: data };
},
staleTime: 5 * 60 * 1000,
retry: false,
enabled: type === "Org" && !!id,
});
return {
organizationsDetailResponse: query.data,
isDetailLoading: query.isLoading,
isDetailError: query.isError,
};
}

View File

@@ -0,0 +1,68 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
createPasswordSettings,
getPasswordSettings,
updatePasswordSettings,
deletePasswordSettings,
PasswordSettings,
} from "../services/api/passwordSettingsService";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export const usePasswordSettingsQuery = () =>
useQuery({
queryKey: ["passwordSettings"],
queryFn: () => getPasswordSettings(),
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
});
export const usePasswordSettingsMutations = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const update = useMutation({
mutationFn: (payload: PasswordSettings) => updatePasswordSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["passwordSettings"] });
toast.success(
t("passwordSettings.updated", "Password settings updated successfully")
);
},
onError: (error: any) => {
handleError(error);
},
});
const create = useMutation({
mutationFn: (payload: PasswordSettings) => createPasswordSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["passwordSettings"] });
toast.success(
t("passwordSettings.created", "Password settings created successfully")
);
},
onError: (error: any) => {
handleError(error);
},
});
const delete_mutation = useMutation({
mutationFn: (id: string) => deletePasswordSettings(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["passwordSettings"] });
toast.success(
t("passwordSettings.deleted", "Password settings deleted successfully")
);
},
onError: (error: any) => {
console.error("Delete mutation error:", error);
handleError(error);
},
});
return { update, create, delete: delete_mutation };
};

View File

@@ -0,0 +1,284 @@
import { useState, useEffect } from "react";
import axios from "axios";
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export type PositionType = "TeamLeader" | "TeamMember" | "Director" | "Other";
export interface Position {
id: string;
title: string;
type?: PositionType;
employeeCount: number;
employees: any[];
isExpanded?: boolean;
}
export const usePositions = (departmentId: string, unitId: string) => {
const [positions, setPositions] = useState<Position[]>([]);
const [selectedPositionId, setSelectedPositionId] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Fetch positions for a department
const fetchPositions = async () => {
if (!departmentId) {
setPositions([]);
setSelectedPositionId("");
return;
}
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.get<ApiResponse<Position[]>>(`/api/departments/${departmentId}/positions`);
// const fetchedPositions = response.data.data;
// For now, simulate API response with mock data
setTimeout(() => {
// Mock positions for this department
const mockPositions: Position[] = [
{
id: "pos1",
title: "Team Leader",
type: "TeamLeader",
employeeCount: 1,
employees: [
{
id: "emp1",
name: "Abebe Kebede",
email: "abebe@example.com",
role: "Manager",
inviteStatus: "Accepted",
},
],
isExpanded: true,
},
{
id: "pos2",
title: "Team Member",
type: "TeamMember",
employeeCount: 3,
employees: [
{
id: "emp2",
name: "Kebede Abebe",
email: "kebede@example.com",
inviteStatus: "Pending",
},
{
id: "emp3",
name: "Chala Demeke",
email: "chala@example.com",
inviteStatus: "Not Invited",
},
],
},
{
id: "pos3",
title: "Director",
type: "Director",
employeeCount: 1,
employees: [
{
id: "emp4",
name: "Tigist Alemu",
email: "tigist@example.com",
role: "Director",
inviteStatus: "Accepted",
},
],
},
];
setPositions(mockPositions);
if (mockPositions.length > 0) {
setSelectedPositionId(mockPositions[0].id);
} else {
setSelectedPositionId("");
}
setLoading(false);
}, 500);
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to fetch positions")
);
setLoading(false);
}
};
// Create a new position
const createPosition = async (
positionData: Partial<Position>
): Promise<Position> => {
if (!departmentId) {
throw new Error("No department selected");
}
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.post<ApiResponse<Position>>(
// `/api/departments/${departmentId}/positions`,
// positionData
// );
// const newPosition = response.data.data;
// For now, simulate API response
const newPosition: Position = {
id: `pos-${Date.now()}`,
title: positionData.title || "New Position",
type: positionData.type || "Other",
employeeCount: 0,
employees: [],
isExpanded: false,
};
setPositions((prev) => [...prev, newPosition]);
setLoading(false);
return newPosition;
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to create position")
);
setLoading(false);
throw err;
}
};
// Update an existing position
const updatePosition = async (
positionId: string,
updates: Partial<Position>
): Promise<Position> => {
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.put<ApiResponse<Position>>(
// `/api/positions/${positionId}`,
// updates
// );
// const updatedPosition = response.data.data;
// For now, simulate API response
const updatedPositions = positions.map((position) =>
position.id === positionId ? { ...position, ...updates } : position
);
setPositions(updatedPositions);
setLoading(false);
const updatedPosition = updatedPositions.find((p) => p.id === positionId);
if (!updatedPosition) {
throw new Error("Position not found after update");
}
return updatedPosition;
} catch (err) {
setError(
err instanceof Error ? err : new Error("Only a super admin can update this position type")
);
setLoading(false);
throw err;
}
};
// Delete a position
const deletePosition = async (positionId: string): Promise<void> => {
setLoading(true);
try {
// In a real app, this would be an API call
// await axios.delete<ApiResponse<void>>(`/api/positions/${positionId}`);
// For now, simulate API response
setPositions((prev) =>
prev.filter((position) => position.id !== positionId)
);
// If the deleted position was selected, select another position
if (selectedPositionId === positionId) {
const remaining = positions.filter(
(position) => position.id !== positionId
);
if (remaining.length > 0) {
setSelectedPositionId(remaining[0].id);
} else {
setSelectedPositionId("");
}
}
setLoading(false);
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to delete position")
);
setLoading(false);
throw err;
}
};
// Toggle expand/collapse for a position
const togglePositionExpand = (positionId: string) => {
setPositions((prev) =>
prev.map((position) =>
position.id === positionId
? { ...position, isExpanded: !position.isExpanded }
: position
)
);
};
// Select a position
const selectPosition = (positionId: string) => {
setSelectedPositionId(positionId);
};
// Filter positions by type
const getPositionsByType = (type: PositionType) => {
return positions.filter((position) => position.type === type);
};
// Get team leaders
const getTeamLeaders = () => {
return positions.filter((position) => position.type === "TeamLeader");
};
// Get team members
const getTeamMembers = () => {
return positions.filter((position) => position.type === "TeamMember");
};
// Get directors
const getDirectors = () => {
return positions.filter((position) => position.type === "Director");
};
// Load positions when departmentId changes
useEffect(() => {
fetchPositions();
}, [departmentId, unitId]);
return {
positions,
selectedPositionId,
selectedPosition: positions.find(
(position) => position.id === selectedPositionId
),
loading,
error,
fetchPositions,
createPosition,
updatePosition,
deletePosition,
togglePositionExpand,
selectPosition,
getPositionsByType,
getTeamLeaders,
getTeamMembers,
getDirectors,
};
};

View File

@@ -0,0 +1,19 @@
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
import { useQuery } from "@tanstack/react-query";
export interface RemarkParams{
skip:number,
take:number,
orderBy?:string;
}
export const useRemarkByUnitId = (unitId: string, params:RemarkParams) => {
return useQuery({
queryKey: ["remark", unitId,params],
queryFn: async () => {
const response = await prefixSuffixService.getRemarkList(unitId,params);
return response.data ;
},
});
};

View File

@@ -0,0 +1,185 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getSites,
getSiteById,
createSite,
updateSite,
softDeleteSite,
deleteSite,
restoreSite,
} from "@/super-admin/services/api/sitesService";
import { SiteDto, SitePayloadDto, SiteQueryParams } from "@/super-admin/dto/SitesDto";
import { toast } from "sonner";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
// Helper function to transform English name into snake_case format
export function toSnakeCase(str: string): string {
if (!str) return "";
return str
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-_]/g, "") // remove special characters
.replace(/[\s-]+/g, "_"); // replace spaces or hyphens with underscores
}
export const useSites = (params?: SiteQueryParams) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// 🔵 Query: Fetch sites list
const {
data: sitesResponse,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["sites", params],
queryFn: async () => {
const { data } = await getSites(params);
// Defensively support both [{...}] and { items: [...], count: X } formats
const items = Array.isArray(data) ? data : (data?.items || []);
const count = Array.isArray(data) ? data.length : (data?.count || items.length || 0);
return {
items: items as SiteDto[],
count: count as number,
};
},
staleTime: 5 * 60 * 1000,
retry: false,
});
// 🟢 Mutation: Create Site
const { mutate: createMutation, isPending: isCreating } = useMutation({
mutationFn: async (payload: SitePayloadDto) => {
const transformedPayload: SitePayloadDto = {
...payload,
name: {
...payload.name,
en: toSnakeCase(payload.name.en),
},
};
const { data } = await createSite(transformedPayload);
return data;
},
onSuccess: () => {
toast.success("Site created successfully!");
queryClient.invalidateQueries({ queryKey: ["sites"] });
},
onError: (error) => {
handleError(error);
},
});
// 🟠 Mutation: Update Site
const { mutate: updateMutation, isPending: isUpdating } = useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: SitePayloadDto }) => {
const transformedPayload: SitePayloadDto = {
...payload,
name: {
...payload.name,
en: toSnakeCase(payload.name.en),
},
};
const { data } = await updateSite(id, transformedPayload);
return data;
},
onSuccess: () => {
toast.success("Site updated successfully!");
queryClient.invalidateQueries({ queryKey: ["sites"] });
},
onError: (error) => {
handleError(error);
},
});
// 🟡 Mutation: Archive Site (Soft Delete)
const { mutate: archiveMutation, isPending: isArchiving } = useMutation({
mutationFn: async ({ id }: { id: string }) => {
const { data } = await softDeleteSite(id);
return data;
},
onSuccess: () => {
toast.success("Site archived successfully!");
queryClient.invalidateQueries({ queryKey: ["sites"] });
},
onError: (error) => {
handleError(error);
},
});
// 🔴 Mutation: Permanent Delete Site
const { mutate: deleteMutation, isPending: isDeleting } = useMutation({
mutationFn: async ({ id }: { id: string }) => {
const { data } = await deleteSite(id);
return data;
},
onSuccess: () => {
toast.success("Site permanently deleted!");
queryClient.invalidateQueries({ queryKey: ["sites"] });
},
onError: (error) => {
handleError(error);
},
});
// 🟣 Mutation: Restore Archived Site
const { mutate: restoreMutation, isPending: isRestoring } = useMutation({
mutationFn: async ({ id }: { id: string }) => {
const { data } = await restoreSite(id);
return data;
},
onSuccess: () => {
toast.success("Site restored successfully!");
queryClient.invalidateQueries({ queryKey: ["sites"] });
},
onError: (error) => {
handleError(error);
},
});
return {
sitesResponse,
isLoading,
isError,
refetch,
createSite: createMutation,
isCreating,
updateSite: updateMutation,
isUpdating,
archiveSite: archiveMutation,
isArchiving,
deleteSite: deleteMutation,
isDeleting,
restoreSite: restoreMutation,
isRestoring,
};
};
export const useSiteDetail = (id: string, enabled: boolean = true) => {
const {
data: site,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["site", id],
queryFn: async () => {
const { data } = await getSiteById(id);
return data;
},
enabled: enabled && !!id,
staleTime: 5 * 60 * 1000,
retry: false,
});
return {
site,
isLoading,
isError,
refetch,
};
};

View File

@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { getUnitInfo, getUnitInfoByUnitID } from "../services/api/userService";
export const useUnitInfo = () => {
return useQuery({
queryKey: ["unitInfo"],
queryFn: async () => {
const { data } = await getUnitInfo();
return data ;
},
});
};
export const useUnitInfoByUnitID = (unitID:string) => {
return useQuery({
queryKey: ["unitInfobyID"],
queryFn: async () => {
const { data } = await getUnitInfoByUnitID(unitID);
return data;
},
});
};

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from "react";
import axios from "axios";
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface Department {
id: string;
name: string;
positions: any[];
isExpanded?: boolean;
}
export interface Unit {
id: string;
name: string;
departments: Department[];
isExpanded?: boolean;
}
export const useUnits = (organizationId: string) => {
const [units, setUnits] = useState<Unit[]>([]);
const [selectedUnitId, setSelectedUnitId] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Fetch units for an organization
const fetchUnits = async () => {
if (!organizationId) {
setUnits([]);
setSelectedUnitId("");
return;
}
setLoading(true);
};
// Create a new unit
const createUnit = async (unitData: Partial<Unit>): Promise<Unit> => {
if (!organizationId) {
throw new Error("No organization selected");
}
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.post<ApiResponse<Unit>>(
// `/api/organizations/${organizationId}/units`,
// unitData
// );
// const newUnit = response.data.data;
// For now, simulate API response
const newUnit: Unit = {
id: `unit-${Date.now()}`,
name: unitData.name || "New Unit",
departments: [],
isExpanded: false,
};
setUnits((prev) => [...prev, newUnit]);
setLoading(false);
return newUnit;
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to create unit"));
setLoading(false);
throw err;
}
};
// Update an existing unit
const updateUnit = async (
unitId: string,
updates: Partial<Unit>
): Promise<Unit> => {
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.put<ApiResponse<Unit>>(
// `/api/units/${unitId}`,
// updates
// );
// const updatedUnit = response.data.data;
// For now, simulate API response
const updatedUnits = units.map((unit) =>
unit.id === unitId ? { ...unit, ...updates } : unit
);
setUnits(updatedUnits);
setLoading(false);
const updatedUnit = updatedUnits.find((u) => u.id === unitId);
if (!updatedUnit) {
throw new Error("Unit not found after update");
}
return updatedUnit;
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to update unit"));
setLoading(false);
throw err;
}
};
// Delete a unit
const deleteUnit = async (unitId: string): Promise<void> => {
setLoading(true);
try {
// In a real app, this would be an API call
// await axios.delete<ApiResponse<void>>(`/api/units/${unitId}`);
// For now, simulate API response
setUnits((prev) => prev.filter((unit) => unit.id !== unitId));
// If the deleted unit was selected, select another unit
if (selectedUnitId === unitId) {
const remaining = units.filter((unit) => unit.id !== unitId);
if (remaining.length > 0) {
setSelectedUnitId(remaining[0].id);
} else {
setSelectedUnitId("");
}
}
setLoading(false);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to delete unit"));
setLoading(false);
throw err;
}
};
// Add department to a unit
const addDepartment = async (
unitId: string,
departmentName: string
): Promise<Department> => {
setLoading(true);
try {
// In a real app, this would be an API call
// const response = await axios.post<ApiResponse<Department>>(
// `/api/units/${unitId}/departments`,
// { name: departmentName }
// );
// const newDepartment = response.data.data;
// For now, simulate API response
const newDepartment: Department = {
id: `dept-${Date.now()}`,
name: departmentName,
positions: [],
isExpanded: false,
};
setUnits((prev) =>
prev.map((unit) =>
unit.id === unitId
? { ...unit, departments: [...unit.departments, newDepartment] }
: unit
)
);
setLoading(false);
return newDepartment;
} catch (err) {
setError(
err instanceof Error ? err : new Error("Failed to add department")
);
setLoading(false);
throw err;
}
};
// Toggle expand/collapse for a unit
const toggleUnitExpand = (unitId: string) => {
setUnits((prev) =>
prev.map((unit) =>
unit.id === unitId ? { ...unit, isExpanded: !unit.isExpanded } : unit
)
);
};
// Toggle expand/collapse for a department
const toggleDepartmentExpand = (unitId: string, deptId: string) => {
setUnits((prev) =>
prev.map((unit) => {
if (unit.id !== unitId) return unit;
return {
...unit,
departments: unit.departments.map((dept) =>
dept.id === deptId
? { ...dept, isExpanded: !dept.isExpanded }
: dept
),
};
})
);
};
// Select a unit
const selectUnit = (unitId: string) => {
setSelectedUnitId(unitId);
};
// Load units when organizationId changes
useEffect(() => {
fetchUnits();
}, [organizationId]);
return {
units,
selectedUnitId,
selectedUnit: units.find((unit) => unit.id === selectedUnitId),
loading,
error,
fetchUnits,
createUnit,
updateUnit,
deleteUnit,
addDepartment,
toggleUnitExpand,
toggleDepartmentExpand,
selectUnit,
};
};

View File

@@ -0,0 +1,121 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import {
removeUnitAdminRole,
assignFirstsForSecond,
assignSecondsForFirst,
deleteUserRole,
removeFirstsForSecond,
removeSecondsForFirst,
RemoveOrAssignUnitAdminPayload,
removeOrgAdminRole,
RemoveOrAssignOrgAdminPayload,
} from "@/super-admin/services/api/userRoleService";
export const useUserRoles = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// Remove Unit Admin Role
const { mutateAsync: removeAdminRole, isPending: isRemoving } = useMutation({
mutationFn: (payload: RemoveOrAssignUnitAdminPayload) =>
removeUnitAdminRole(payload),
onSuccess: () => {
toast.success("Unit admin role removed successfully");
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
},
onError: (error) => {
handleError(error);
},
});
const { mutateAsync: removeUnitAdmin, isPending: isRemovingUnitAdmin } =
useMutation({
mutationFn: (payload: RemoveOrAssignOrgAdminPayload) =>
removeOrgAdminRole(payload),
onSuccess: () => {
toast.success("Unit admin role removed successfully");
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
},
onError: (error) => {
handleError(error);
},
});
// Assign Firsts for Second
const { mutate: assignFirsts, isPending: isAssigningFirsts } = useMutation({
mutationFn: (payload: { secondId: string; firstIds: string[] }) =>
assignFirstsForSecond(payload),
onSuccess: () => {
toast.success("Firsts assigned successfully");
},
onError: (error) => {
handleError(error);
},
});
// Assign Seconds for First
const { mutate: assignSeconds, isPending: isAssigningSeconds } = useMutation({
mutationFn: (payload: { firstId: string; secondIds: string[] }) =>
assignSecondsForFirst(payload),
onSuccess: () => {
toast.success("Seconds assigned successfully");
},
onError: (error) => {
handleError(error);
},
});
// Remove Firsts for Second
const { mutate: removeFirsts, isPending: isRemovingFirsts } = useMutation({
mutationFn: (payload: { secondId: string; firstIds: string[] }) =>
removeFirstsForSecond(payload),
onSuccess: () => {
toast.success("Firsts removed successfully");
},
onError: (error) => {
handleError(error);
},
});
// Remove Seconds for First
const { mutate: removeSeconds, isPending: isRemovingSeconds } = useMutation({
mutationFn: (payload: { firstId: string; secondIds: string[] }) =>
removeSecondsForFirst(payload),
onSuccess: () => {
toast.success("Seconds removed successfully");
},
onError: (error) => {
handleError(error);
},
});
// Delete User Role by ID
const { mutate: deleteRole, isPending: isDeleting } = useMutation({
mutationFn: (id: string) => deleteUserRole(id),
onSuccess: () => {
toast.success("Role deleted successfully");
},
onError: (error) => {
handleError(error);
},
});
return {
removeAdminRole,
removeUnitAdmin,
assignFirsts,
assignSeconds,
removeFirsts,
removeSeconds,
deleteRole,
isRemoving,
isRemovingUnitAdmin,
isAssigningFirsts,
isAssigningSeconds,
isRemovingFirsts,
isRemovingSeconds,
isDeleting,
};
};

View File

@@ -0,0 +1,25 @@
import { OrganizationUserDto, User } from "@/shared/dto/user/usersDto";
import { getEmployeesUnderOrg } from "@/shared/services/organizationsService";
import { useQuery } from "@tanstack/react-query";
import { getUserById } from "../services/api/userService";
export const useUsers = (organizationId: string) => {
return useQuery({
queryKey: ["users"],
queryFn: async () => {
const { data } = await getEmployeesUnderOrg(organizationId);
return data as { items: OrganizationUserDto[]; count: number };
},
});
};
export const useUsersByID = (id: string) => {
return useQuery({
queryKey: ["user",id],
queryFn: async () => {
const response = await getUserById(id);
return response.data as User;
},
});
};

View File

@@ -0,0 +1,5 @@
import { formatDistanceToNow, parseISO } from "date-fns";
export const getRelativeTime = (isoDate: string): string => {
return formatDistanceToNow(parseISO(isoDate), { addSuffix: true });
};

View File

@@ -0,0 +1,28 @@
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(input: string | number | Date): string {
const date = new Date(input);
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
export function formatDateTime(input: string | number | Date): string {
const date = new Date(input);
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
});
}
// Add any other utility functions here that might be needed

View File

@@ -0,0 +1,90 @@
import axios from "@/shared/services/axiosInstance";
import { DashboardStatsDto } from "@/super-admin/components/dashboard/types/dashboardStatsDto";
import axiosInstance from "../../../shared/services/axiosInstance";
import { AxiosResponse, AxiosError } from "axios";
// This is your DTO structure
export const getDashboardStats = async (): Promise<DashboardStatsDto> => {
const { data } = await axios.get("/dashboard/stats");
return data;
};
// Types for organization report data
export interface OrganizationReport {
unitsCount: number;
employeesCount: number;
positionsCount: number;
totalDocuments?: number;
recentActivities?: {
id: string;
type: string;
description: string;
timestamp: string;
}[];
}
export type UnitReport = Omit<OrganizationReport, "unitsCount"> & {
unitsCount?: number;
};
/**
* Fetches organization report data for the dashboard
* @param organizationId - The ID of the organization
* @returns Promise with the organization report data
*/
export const getOrganizationReport = async (
organizationId: string
): Promise<AxiosResponse<OrganizationReport>> => {
try {
return await axiosInstance.get(`/organizations/${organizationId}/report`);
} catch (error: unknown) {
if (error instanceof AxiosError && error.response) {
// Handle specific error cases if needed
}
throw error;
}
};
export const getUnitReport = async (
organizationId: string,
unitId: string,
): Promise<AxiosResponse<UnitReport>> => {
return axiosInstance.get(
`/organizations/${organizationId}/report/${unitId}/unit`,
);
};
/**
* Fetches total employees count for an organization
* @param organizationId - The ID of the organization
* @returns Promise with the total employees count
*/
export const getOrganizationEmployees = async (
organizationId: string
): Promise<AxiosResponse<{ total: number }>> => {
return axiosInstance.get(`/organizations/${organizationId}/employees/count`);
};
/**
* Fetches total departments count for an organization
* @param organizationId - The ID of the organization
* @returns Promise with the total departments count
*/
export const getOrganizationDepartments = async (
organizationId: string
): Promise<AxiosResponse<{ total: number }>> => {
return axiosInstance.get(
`/organizations/${organizationId}/departments/count`
);
};
/**
* Fetches total units count for an organization
* @param organizationId - The ID of the organization
* @returns Promise with the total units count
*/
export const getOrganizationUnits = async (
organizationId: string
): Promise<AxiosResponse<{ total: number }>> => {
return axiosInstance.get(`/organizations/${organizationId}/units/count`);
};

View File

@@ -0,0 +1,68 @@
import { externalParam } from "@/external-portal/services/portalOutgoingService";
import { withHeaders } from "@/record-management/services/api/withHeaders";
import recordAxiosInstance from "@/shared/services/recordAxiosInstance";
import { AdoptTemplateTypes } from "@/super-admin/components/templates/template";
import { CreateTemplateTypes } from "@/super-admin/components/templates/types/templateTypes";
export const getDelegationTemplates = async () => {
const { data } = await recordAxiosInstance.get("/record-templates/global", {
headers: withHeaders(),
});
return data;
};
export const getDelegationTemplateById = async (
id: string,
params: externalParam,
) => {
const { data } = await recordAxiosInstance.get(`/record-templates/${id}`, {
headers: withHeaders(),
...params,
});
return data;
};
export const createDelegationTemplate = async (
template: CreateTemplateTypes,
) => {
const { data } = await recordAxiosInstance.post(
`/record-templates/global/delegation`,
template,
{
headers: withHeaders(),
},
);
return data;
};
export const updateDelegationTemplate = async (
id: string,
template: CreateTemplateTypes,
) => {
const { data } = await recordAxiosInstance.patch(
`/record-templates/global/${id}`,
template,
{
headers: withHeaders(),
},
);
return data;
};
export const deleteDelegationTemplate = async (id: string) => {
const { data } = await recordAxiosInstance.delete(`/record-templates/${id}`, {
headers: withHeaders(),
});
return data;
};
export const adoptDelegationTemplate = async (item: AdoptTemplateTypes) => {
const { data } = await recordAxiosInstance.post(
`/record-templates/adopt-to-unit`,
item,
{
headers: withHeaders(),
},
);
return data;
};

View File

@@ -0,0 +1,112 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import axiosInstance from "../../../shared/services/axiosInstance";
import { AxiosResponse } from "axios";
export interface OrgHeaders {
tenantKey: string;
unitId?: string;
}
export interface OrganizationAdminPayload {
organizationId: string;
username: string;
email: string;
name: {
am: string;
en: string;
};
phoneNumber: string;
}
export interface UnitAdminPayload {
unitId: string;
username: string;
email: string;
name: {
am: string;
en: string;
};
phoneNumber: string;
}
export interface OrganizationAdmin {
id: string;
organizationId: string;
username: string;
email: string;
name: {
am: string;
en: string;
};
status: string;
}
export interface OrgAdminQueryParams {
orderBy?: string;
take?: number;
skip?: number;
order?: string;
}
export const getOrganizationAdmins = async (
params?: OrgAdminQueryParams
): Promise<AxiosResponse> => {
return axiosInstance.get("/organizations/org-admins", {
...{ headers: withHeaders() },
params,
});
};
export const getOrganizationAdminById = async (
id: string,
params?: OrgAdminQueryParams
): Promise<AxiosResponse> => {
return axiosInstance.get(`/organizations/org-admins/${id}`, {
...{ headers: withHeaders() },
params,
});
};
export const fetchUnitAdminById = async (
id: string,
params?: OrgAdminQueryParams
): Promise<AxiosResponse> => {
return axiosInstance.get(`/units/unit-admins/${id}`, {
...{ headers: withHeaders() },
params,
});
};
export const fetchAllUnitAdminById = async (
id: string,
params?: OrgAdminQueryParams
): Promise<AxiosResponse> => {
return axiosInstance.get(`/organizations/all-admins/${id}`, {
...{ headers: withHeaders() },
params,
});
};
export const assignOrganizationAdmin = async (
data: OrganizationAdminPayload
): Promise<AxiosResponse> => {
return axiosInstance.post("/organizations/org-admin", data, {
headers: withHeaders(),
});
};
export const assignUnitAdmin = async (
data: UnitAdminPayload
): Promise<AxiosResponse> => {
return axiosInstance.post("/units/unit-admin", data, {
headers: withHeaders(),
});
};
export const updateOrganizationAdmin = async (
id: string | number,
data: OrganizationAdminPayload
): Promise<AxiosResponse> => {
return axiosInstance.put(`/organizations/org-admin/${id}`, data, {
headers: withHeaders(),
});
};

View File

@@ -0,0 +1,30 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import axiosInstance from "../../../shared/services/axiosInstance";
import { AxiosResponse } from "axios";
export interface OrgHeaders {
tenantKey: string;
unitId?: string;
}
export interface OrgType {
id: string;
name: {
am: string;
en: string;
};
key: string;
createdAt: string;
updatedAt: string;
}
export interface OrgTypeResponse {
count: number;
items: OrgType[];
}
export const getOrganizationTypes = async (): Promise<
AxiosResponse<OrgTypeResponse>
> => {
return axiosInstance.get("/organization-types", { headers: withHeaders() });
};

View File

@@ -0,0 +1,72 @@
import axiosInstance from "@/shared/services/axiosInstance";
export interface PasswordSettings {
id?: string;
minimumPasswordLength: number;
maximumPasswordLength: number;
passwordExpiry: number;
sessionTimeout: number;
isDefaultPasswordEnabled: boolean;
defaultPassword?: string;
}
/**
* Get system password settings
*/
export const getPasswordSettings = async (): Promise<PasswordSettings | null> => {
const { data } = await axiosInstance.get("/system-configurations");
console.log("Password settings API response:", data);
// API returns an array with {count, items}
if (data && data.items && Array.isArray(data.items)) {
console.log("Found items array, returning first item:", data.items[0]);
// Return the first (latest) item if it exists
return data.items[0] || null;
}
// Fallback for direct object response
console.log("No items array, returning data as is:", data);
return data || null;
};
/**
* Update system password settings
*/
export const updatePasswordSettings = async (
payload: PasswordSettings
): Promise<PasswordSettings> => {
const { data } = await axiosInstance.put(
`/system-configurations/${payload.id}`,
payload
);
return data;
};
/**
* Create system password settings
*/
export const createPasswordSettings = async (
payload: PasswordSettings
): Promise<PasswordSettings> => {
const { data } = await axiosInstance.post(
"/system-configurations",
payload
);
return data;
};
/**
* Delete system password settings
*/
export const deletePasswordSettings = async (
id: string
): Promise<void> => {
console.log("Calling DELETE /system-configurations/" + id);
try {
const response = await axiosInstance.delete(`/system-configurations/${id}`);
console.log("Delete response:", response);
} catch (error) {
console.error("Delete API error:", error);
throw error;
}
};

View File

@@ -0,0 +1,69 @@
import axiosInstance from "@/shared/services/axiosInstance";
import { withHeaders } from "@/record-management/services/api/withHeaders";
import { AxiosResponse } from "axios";
import { SiteDto, SitePayloadDto, SiteQueryParams } from "@/super-admin/dto/SitesDto";
// Fetch all sites (supports pagination/filter params)
export const getSites = async (
params?: SiteQueryParams
): Promise<AxiosResponse<any>> => {
return axiosInstance.get("/sites", {
headers: withHeaders(),
params,
});
};
// Fetch site by ID
export const getSiteById = async (
id: string
): Promise<AxiosResponse<SiteDto>> => {
return axiosInstance.get(`/sites/${id}`, {
headers: withHeaders(),
});
};
// Create a new site
export const createSite = async (
data: SitePayloadDto
): Promise<AxiosResponse<SiteDto>> => {
return axiosInstance.post("/sites", data, {
headers: withHeaders(),
});
};
// Update an existing site
export const updateSite = async (
id: string,
data: SitePayloadDto
): Promise<AxiosResponse<SiteDto>> => {
return axiosInstance.put(`/sites/${id}`, data, {
headers: withHeaders(),
});
};
// Soft delete (archive) a site
export const softDeleteSite = async (
id: string
): Promise<AxiosResponse<void>> => {
return axiosInstance.delete(`/sites/${id}/soft`, {
headers: withHeaders(),
});
};
// Permanently delete a site
export const deleteSite = async (
id: string
): Promise<AxiosResponse<void>> => {
return axiosInstance.delete(`/sites/${id}`, {
headers: withHeaders(),
});
};
// Restore an archived site
export const restoreSite = async (
id: string
): Promise<AxiosResponse<SiteDto>> => {
return axiosInstance.patch(`/sites/${id}/restore`, null, {
headers: withHeaders(),
});
};

View File

@@ -0,0 +1,103 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import axiosInstance from "@/shared/services/axiosInstance";
import { AxiosResponse } from "axios";
export interface UserRoleHeaders {
delegatorPositionId: string;
currentProjectId?: string;
}
export interface RemoveOrAssignUnitAdminPayload {
unitId: string;
userId: string;
}
export interface RemoveOrAssignOrgAdminPayload {
organizationId: string;
userId: string;
}
// Assign Org Admin Role
export const assignOrgAdminRole = async (
payload: RemoveOrAssignOrgAdminPayload
): Promise<AxiosResponse> =>
axiosInstance.post("/user-roles/assign-org-admin-role", payload, {
headers: withHeaders(),
});
// Remove Org Admin Role
export const removeOrgAdminRole = async (
payload: RemoveOrAssignOrgAdminPayload
): Promise<AxiosResponse> =>
axiosInstance.post("/user-roles/remove-org-admin-role", payload, {
headers: withHeaders(),
});
//Assign unit Admin Role
export const assignUnitAdminRole = async (
payload: RemoveOrAssignUnitAdminPayload
): Promise<AxiosResponse> =>
axiosInstance.post("/user-roles/assign-unit-admin-role", payload, {
headers: withHeaders(),
});
// Remove Unit Admin Role
export const removeUnitAdminRole = async (
payload: RemoveOrAssignUnitAdminPayload
): Promise<AxiosResponse> =>
axiosInstance.post("/user-roles/remove-unit-admin-role", payload, {
headers: withHeaders(),
});
// Get roles given first (e.g., user has been assigned roles)
export const getRolesGivenFirst = async (id: string): Promise<AxiosResponse> =>
axiosInstance.get(`/user-roles/given-first/${id}`, {
headers: withHeaders(),
});
// Get roles given second (e.g., entities assigned to a user)
export const getRolesGivenSecond = async (id: string): Promise<AxiosResponse> =>
axiosInstance.get(`/user-roles/given-second/${id}`, {
headers: withHeaders(),
});
// Assign firsts for second
export const assignFirstsForSecond = async (payload: {
secondId: string;
firstIds: string[];
}): Promise<AxiosResponse> =>
axiosInstance.post(`/user-roles/assign-firsts-for-second`, payload, {
headers: withHeaders(),
});
// Assign seconds for first
export const assignSecondsForFirst = async (payload: {
firstId: string;
secondIds: string[];
}): Promise<AxiosResponse> =>
axiosInstance.post(`/user-roles/assign-seconds-for-first`, payload, {
headers: withHeaders(),
});
// Delete user role by id
export const deleteUserRole = async (id: string): Promise<AxiosResponse> =>
axiosInstance.delete(`/user-roles/${id}`, { headers: withHeaders() });
// Remove firsts for second
export const removeFirstsForSecond = async (payload: {
secondId: string;
firstIds: string[];
}): Promise<AxiosResponse> =>
axiosInstance.post(`/user-roles/remove-firsts-for-second`, payload, {
headers: withHeaders(),
});
// Remove seconds for first
export const removeSecondsForFirst = async (payload: {
firstId: string;
secondIds: string[];
}): Promise<AxiosResponse> =>
axiosInstance.post(`/user-roles/remove-seconds-for-first`, payload, {
headers: withHeaders(),
});

View File

@@ -0,0 +1,103 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import axiosInstance from "@/shared/services/axiosInstance";
import recordAxiosInstance from "@/shared/services/recordAxiosInstance";
import {
ExternalUsersResponseDto,
UserTypeDto,
} from "@/super-admin/dto/ExternalUsersDto";
import { MainDtoResponse } from "@/super-admin/dto/SuperAdminDto";
import { ExternalQueryParams } from "@/super-admin/hooks/useExternalUsers";
import { AxiosResponse } from "axios";
// 🔵 Get pending users
export const getPendingUsers = async (
params?: ExternalQueryParams
): Promise<AxiosResponse<ExternalUsersResponseDto>> =>
axiosInstance.get("/users/pending", { headers: withHeaders(), params });
// Dashboard Information
export const getUnitInfo = async (
params?: ExternalQueryParams
): Promise<AxiosResponse<UserTypeDto>> =>
recordAxiosInstance.get("/record-boxes/my-units-dashboard", {
headers: withHeaders(),
params,
});
// unit infromation from super admin side
export const getUnitInfoByUnitID = async (
UnitId: string,
params?: ExternalQueryParams
): Promise<AxiosResponse<UserTypeDto>> =>
recordAxiosInstance.get(`/record-boxes/summary/${UnitId}`, {
headers: withHeaders(),
params,
});
// 🔵 Get all users (both external_organization and employee)
export const getAllExternalUsers = async (
params?: ExternalQueryParams
): Promise<AxiosResponse<ExternalUsersResponseDto>> => {
const queryParams = {
...params,
// No userType filter - fetch both external_organization and employee users
};
return axiosInstance.get("/users/filter", {
headers: withHeaders(),
params: queryParams,
});
};
export const getExternalUsersNeesApproval = async (
params?: ExternalQueryParams
): Promise<AxiosResponse<ExternalUsersResponseDto>> => {
const queryParams = {
...params,
// No userType filter - fetch both external_organization and employee users
};
return axiosInstance.get("/users/filter", {
headers: withHeaders(),
params: queryParams,
});
};
// 🔴 Delete user
export const deleteUser = async (id: string): Promise<AxiosResponse<void>> =>
axiosInstance.delete(`/users/${id}`, { headers: withHeaders() });
export const getUserById = async (id: string): Promise<AxiosResponse<any>> =>
axiosInstance.get(`/users/${id}`, { headers: withHeaders() });
// 🟢 Activate user
export const activateUser = async (id: string): Promise<AxiosResponse<void>> =>
axiosInstance.patch(`/users/activate-user/${id}`, null, {
headers: withHeaders(),
});
export const approveUser = async (
id: string,
status: string
): Promise<AxiosResponse<void>> =>
axiosInstance.patch(
`/users/approve/${id}`,
{ status },
{
headers: withHeaders(),
}
);
// 🟠 Deactivate user
export const deactivateUser = async (
id: string
): Promise<AxiosResponse<void>> =>
axiosInstance.patch(`/users/deactivate-user/${id}`, null, {
headers: withHeaders(),
});
export const getAllRecords = async (
UnitId: string,
params?: ExternalQueryParams
): Promise<AxiosResponse<MainDtoResponse>> =>
recordAxiosInstance.get(`/record-boxes/unit-box/${UnitId}`, {
headers: withHeaders(),
params,
});