diff --git a/src/application/profile/ProfilePage.tsx b/src/application/profile/ProfilePage.tsx index d1f2415..25b8ac5 100644 --- a/src/application/profile/ProfilePage.tsx +++ b/src/application/profile/ProfilePage.tsx @@ -1,192 +1,567 @@ -import React, { useEffect, useState } from "react"; -import { toast } from "react-toastify"; +import React, { useState, useEffect } from "react"; import { useAuth } from "../../context/AuthContext"; +import { CustomLoader, CustomModal, CustomInput, CustomButton } from "../../components/custom"; +import { useTranslation } from "react-i18next"; +import { authApi } from "../authentication/AuthApi"; +import { Key, Pencil } from "lucide-react"; +import type { PasswordForm, FormatDateFunction, FormatNameFunction, RenderStatusBadgeFunction } from "./ProfileTypes"; import { useTheme } from "../../context/ThemeContext"; -import { authApi } from "../Authentication/AuthApi"; import { paletteApi } from "../theme/PaletteApi"; import type { ColorPalette } from "../theme/ThemeTypes"; -import CustomButton from "../../components/custom/CustomButton"; -import { Loader } from "../../components/custom/CustomLoader"; -import { Check, User } from "lucide-react"; + +type ProfileForm = { + firstName: string; + lastName: string; + phoneNumber: string; +}; + +const formatDate: FormatDateFunction = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +}; + +const formatName: FormatNameFunction = (firstName: string, lastName?: string | null) => + [firstName, lastName].filter(Boolean).join(" "); + +const renderStatusBadge: RenderStatusBadgeFunction = (status?: string) => ( + + {status ? status.charAt(0).toUpperCase() + status.slice(1) : "Unknown"} + +); const ProfilePage: React.FC = () => { - const { user, refreshUser } = useAuth(); + const { t } = useTranslation(['profile', 'common']); + const { user, isLoading, refreshUser } = useAuth(); + + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); + const [passwordForm, setPasswordForm] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + const [passwordError, setPasswordError] = useState(""); + const [passwordSuccess, setPasswordSuccess] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [profileForm, setProfileForm] = useState({ + firstName: "", + lastName: "", + phoneNumber: "", + }); + const [profileError, setProfileError] = useState(""); + const [profileSuccess, setProfileSuccess] = useState(""); + const [isProfileSubmitting, setIsProfileSubmitting] = useState(false); + + // Theme Logic const { currentPalette, setTheme } = useTheme(); - const [palettes, setPalettes] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); - - // Form states - const [firstName, setFirstName] = useState(user?.first_name || ""); - const [lastName, setLastName] = useState(user?.last_name || ""); - const [phone, setPhone] = useState(user?.phone_number || ""); + const [isThemesLoading, setIsThemesLoading] = useState(false); useEffect(() => { const fetchPalettes = async () => { - setIsLoading(true); + setIsThemesLoading(true); try { const data = await paletteApi.getAllPalettes(); setPalettes(data); } catch (error) { console.error("Failed to fetch palettes", error); } finally { - setIsLoading(false); + setIsThemesLoading(false); } }; - fetchPalettes(); }, []); - // Update form when user data changes - useEffect(() => { - if (user) { - setFirstName(user.first_name || ""); - setLastName(user.last_name || ""); - setPhone(user.phone_number || ""); - } - }, [user]); - - const handleThemeSelect = (palette: ColorPalette) => { - setTheme(palette); - toast.success("Theme updated successfully"); - }; - - const handleProfileUpdate = async (e: React.FormEvent) => { - e.preventDefault(); - if (!user) return; - - setIsSaving(true); - try { - await authApi.updateProfile(user.id, { - first_name: firstName, - last_name: lastName, - phone_number: phone, - }); - await refreshUser(); - toast.success("Profile updated successfully"); - } catch (error) { - console.error("Failed to update profile", error); - toast.error("Failed to update profile"); - } finally { - setIsSaving(false); - } - }; - - if (isLoading && palettes.length === 0) { + if (isLoading) { return ( -
- +
+
); } + if (!user) { + return ( +
+ {t('messages.loadError')} +
+ ); + } + + const fullName = formatName(user.first_name, user.last_name); + const initials = + user.first_name && user.last_name + ? `${user.first_name[0]}${user.last_name[0]}` + : user.first_name + ? user.first_name[0] + : "U"; + + const handlePasswordChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setPasswordForm((prev) => ({ ...prev, [name]: value })); + setPasswordError(""); + }; + + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setPasswordError(""); + setPasswordSuccess(""); + + // Client-side validation + if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) { + setPasswordError(t('messages.requiredFields')); + return; + } + + if (passwordForm.newPassword.length < 8) { + setPasswordError(t('messages.passwordLength')); + return; + } + + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + setPasswordError(t('messages.passwordMismatch')); + return; + } + + setIsSubmitting(true); + + try { + const response = await authApi.resetPassword( + passwordForm.currentPassword, + passwordForm.newPassword + ); + setPasswordSuccess(response.message || t('messages.passwordSuccess')); + setPasswordForm({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + + // Close modal after 1.5 seconds + setTimeout(() => { + setIsPasswordModalOpen(false); + setPasswordSuccess(""); + }, 1500); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to update password"; + setPasswordError(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + const handlePasswordModalClose = () => { + setIsPasswordModalOpen(false); + setPasswordForm({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + setPasswordError(""); + setPasswordSuccess(""); + }; + + // Edit Profile handlers + const handleOpenEditModal = () => { + if (user) { + setProfileForm({ + firstName: user.first_name || "", + lastName: user.last_name || "", + phoneNumber: user.phone_number || "", + }); + } + setIsEditModalOpen(true); + }; + + const handleProfileChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setProfileForm((prev) => ({ ...prev, [name]: value })); + setProfileError(""); + }; + + const handleProfileSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setProfileError(""); + setProfileSuccess(""); + + if (!user) return; + + if (!profileForm.firstName.trim()) { + setProfileError(t('messages.firstNameRequired')); + return; + } + + setIsProfileSubmitting(true); + + try { + await authApi.updateProfile(user.id, { + first_name: profileForm.firstName.trim(), + last_name: profileForm.lastName.trim() || undefined, + phone_number: profileForm.phoneNumber.trim() || undefined, + }); + setProfileSuccess(t('messages.profileSuccess')); + + // Refresh user data + if (refreshUser) { + await refreshUser(); + } + + // Close modal after 1.5 seconds + setTimeout(() => { + setIsEditModalOpen(false); + setProfileSuccess(""); + }, 1500); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to update profile"; + setProfileError(errorMessage); + } finally { + setIsProfileSubmitting(false); + } + }; + + const handleEditModalClose = () => { + setIsEditModalOpen(false); + setProfileError(""); + setProfileSuccess(""); + }; + return ( -
+
{/* Header */}
-

Profile Settings

-

Manage your account settings and preferences.

+

{t('title')}

+

+ {t('subTitle')} +

- {/* Profile Information Section */} -
-
-
- -
-
-

Personal Information

-

Update your personal details.

-
-
- -
-
-
- - setFirstName(e.target.value)} - className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none" - required - /> -
-
- - setLastName(e.target.value)} - className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none" - /> -
-
- - -
-
- - setPhone(e.target.value)} - className="w-full px-3 py-2 rounded-lg border border-[var(--card-border)] bg-[var(--background)] text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--primary)] outline-none" - /> -
-
- -
- - Save Changes - -
-
-
- - {/* Theme Selection Section */} -
-
-

Theme Preferences

-

Choose a color theme for your interface.

-
- -
- {palettes.map((palette) => ( - - ))} + {/* User Details Section */} +
+

+ {t('sections.accountInfo')} +

+
+ {/* First Name */} +
+

+ {t('fields.firstName')} +

+

+ {user.first_name} +

+
+ + {/* Last Name */} +
+

+ {t('fields.lastName')} +

+

+ {user.last_name || "--"} +

+
+ + {/* Email */} +
+

+ {t('fields.email')} +

+

+ {user.email} +

+
+ + {/* Phone Number */} +
+

+ {t('fields.phone')} +

+

+ {user.phone_number || "--"} +

+
+ + {/* Tenant */} +
+

+ {t('fields.tenant')} +

+

+ {user.tenant_name || "--"} +

+
+ + {/* Role */} +
+

+ {t('fields.role')} +

+

+ {user.role?.role_name || "--"} +

+
+ + {/* Account Created */} +
+

+ {t('fields.accountCreated')} +

+

+ {formatDate(user.created_at)} +

+
+ + {/* Last Updated */} +
+

+ {t('fields.lastUpdated')} +

+

+ {formatDate(user.updated_at)} +

+
+
+ + {/* Appearance / Theme Section */} +
+

+ {t('sections.appearance')} +

+

+ {t('sections.appearanceDesc')} +

+ + {isThemesLoading ? ( +
+ +
+ ) : ( +
+ {palettes.map((palette) => ( + + ))} +
+ )} +
+ + {/* Change Password Modal */} + + + {t('common:actions.cancel')} + + + {t('buttons.updatePassword')} + + + } + > +
+ + + + + {passwordError && ( +
+ {passwordError} +
+ )} + + {passwordSuccess && ( +
+ {passwordSuccess} +
+ )} + +
+ + {/* Edit Profile Modal */} + + + {t('common:actions.cancel')} + + + {t('buttons.saveChanges')} + + + } + > +
+ + + + + {profileError && ( +
+ {profileError} +
+ )} + + {profileSuccess && ( +
+ {profileSuccess} +
+ )} + +
); }; diff --git a/src/application/profile/ProfileTypes.ts b/src/application/profile/ProfileTypes.ts new file mode 100644 index 0000000..312bc70 --- /dev/null +++ b/src/application/profile/ProfileTypes.ts @@ -0,0 +1,13 @@ +import type { JSX } from "react/jsx-dev-runtime"; + +export interface PasswordForm { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +export type FormatDateFunction = (value: string) => string; + +export type FormatNameFunction = (firstName: string, lastName?: string | null) => string; + +export type RenderStatusBadgeFunction = (status?: string) => JSX.Element; diff --git a/src/application/roles/RolesApi.ts b/src/application/roles/RolesApi.ts new file mode 100644 index 0000000..f3a3fb9 --- /dev/null +++ b/src/application/roles/RolesApi.ts @@ -0,0 +1,52 @@ +import { apiClient } from "../../lib/apiClient"; +import type { + Role, + RoleCreateRequest, + RoleUpdateRequest, + RoleAccess, + RolePaginatedResponse, +} from "./RolesTypes"; + +type RoleWithAccesses = Role & { accesses: RoleAccess[] }; + +const buildQueryString = (params: Record): string => { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + searchParams.append(key, String(value)); + } + }); + const query = searchParams.toString(); + return query ? `?${query}` : ""; +}; + +export const rolesApi = { + getAll: () => apiClient.get("/api/role/get"), + + getById: (roleId: string) => + apiClient.get(`/api/role/get/${roleId}`), + + getAccesses: () => apiClient.get("/api/access/get"), + + create: (payload: RoleCreateRequest) => + apiClient.post("/api/role/create", payload, { successMessage: "Role created successfully", errorMessage: "Failed to create role" }), + + update: (roleId: string, payload: RoleUpdateRequest) => + apiClient.put(`/api/role/update/${roleId}`, payload, { successMessage: "Role updated successfully", errorMessage: "Failed to update role" }), + + remove: (roleId: string) => + apiClient.delete<{ message: string }>(`/api/role/delete/${roleId}`, { successMessage: "Role deleted", errorMessage: "Failed to delete role" }), + + getPaginated: (params: { + page?: number; + page_size?: number; + search?: string; + }) => { + const queryString = buildQueryString({ + page: params.page, + page_size: params.page_size, + search: params.search, + }); + return apiClient.get(`/api/role/list${queryString}`); + }, +}; \ No newline at end of file diff --git a/src/application/roles/RolesTypes.ts b/src/application/roles/RolesTypes.ts new file mode 100644 index 0000000..6f179c1 --- /dev/null +++ b/src/application/roles/RolesTypes.ts @@ -0,0 +1,38 @@ +export type Role = { + id: string; + role_name: string; + tenant_id?: string | null; + tenant_name?: string | null; + is_default: boolean; + created_at: string; + updated_at: string; +}; + +export type RoleAccess = { + id: string; + access_code: string; + category: string; + name: string; + parent_id?: string | null; +}; + +export type RoleCreateRequest = { + role_name: string; + tenant_id?: string; + access_ids?: string[]; + is_default?: boolean; +}; + +export type RoleUpdateRequest = { + role_name?: string; + access_ids?: string[] | null; + is_default?: boolean; +}; + +export type RolePaginatedResponse = { + items: Role[]; + total: number; + page: number; + page_size: number; + total_pages: number; +}; \ No newline at end of file diff --git a/src/application/roles/components/AddRoles.tsx b/src/application/roles/components/AddRoles.tsx new file mode 100644 index 0000000..cec86f7 --- /dev/null +++ b/src/application/roles/components/AddRoles.tsx @@ -0,0 +1,280 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + CustomButton, + CustomDropdown, + CustomInput, + CustomBackButton, + +} from "../../../components/custom"; +import { rolesApi } from "../RolesApi"; +import type { RoleAccess, RoleCreateRequest } from "../RolesTypes"; +import { tenantsApi } from "../../tenants/TenantsApi"; +import type { Tenant } from "../../tenants/TenantsTypes"; +import { useAuth } from "../../../context/AuthContext"; +import { GroupedAccessSelector } from "./GroupedAccessSelector"; + +const AddRoles = () => { + const { t } = useTranslation(['roles', 'common']); + const { user, hasAccess, isLoading: isAuthLoading } = useAuth(); + const canReadAllTenants = hasAccess("superadmin.tenant.read"); + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + role_name: "", + tenant_id: "", + access_ids: [], + is_default: false, + }); + const [isLoading, setIsLoading] = useState(false); + const [accessOptions, setAccessOptions] = useState([]); + const [isAccessLoading, setIsAccessLoading] = useState(false); + const [tenants, setTenants] = useState([]); + const [isTenantLoading, setIsTenantLoading] = useState(false); + const [currentTenantName, setCurrentTenantName] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + if (isAuthLoading) { + return; + } + + let isMounted = true; + const loadAccesses = async () => { + setIsAccessLoading(true); + try { + const data = await rolesApi.getAccesses(); + if (isMounted) { + setAccessOptions(data); + } + } catch (error) { + if (isMounted) { + const message = + error instanceof Error + ? error.message + : "Unable to load access options."; + setErrorMessage(message); + } + } finally { + if (isMounted) { + setIsAccessLoading(false); + } + } + }; + + const loadTenants = async () => { + if (!canReadAllTenants) { + setIsTenantLoading(true); + try { + if (user?.tenant_id && user?.tenant_name) { + if (isMounted) { + setCurrentTenantName(user.tenant_name); + setTenants([]); + setFormData((prev) => ({ + ...prev, + tenant_id: user.tenant_id ?? "", + })); + } + return; + } + + const data = await tenantsApi.getMine(); + if (isMounted) { + setCurrentTenantName(data.tenant_name); + setTenants([]); + setFormData((prev) => ({ + ...prev, + tenant_id: data.id, + })); + } + } catch (error) { + if (isMounted) { + const message = + error instanceof Error + ? error.message + : "Unable to load tenant."; + setErrorMessage(message); + setFormData((prev) => ({ + ...prev, + tenant_id: user?.tenant_id ?? "", + })); + } + } finally { + if (isMounted) { + setIsTenantLoading(false); + } + } + return; + } + + setIsTenantLoading(true); + try { + const data = await tenantsApi.getAll(); + if (isMounted) { + setTenants(data); + } + } catch (error) { + if (isMounted) { + const message = + error instanceof Error + ? error.message + : "Unable to load tenants."; + setErrorMessage(message); + } + } finally { + if (isMounted) { + setIsTenantLoading(false); + } + } + }; + + loadAccesses(); + loadTenants(); + return () => { + isMounted = false; + }; + }, [canReadAllTenants, isAuthLoading, user?.tenant_id, user?.tenant_name]); + + const handleChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + // Filter accessOptions to only show what the current user has access to + const availableAccessOptions = useMemo(() => { + if (!user?.role?.accesses) return []; + // If the user has access codes in their token, filter the list + return accessOptions.filter((option) => + user.role?.accesses.includes(option.access_code) + ); + }, [accessOptions, user]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setErrorMessage(""); + setIsLoading(true); + + try { + const payload: RoleCreateRequest = { + role_name: formData.role_name.trim(), + tenant_id: formData.tenant_id?.trim() || undefined, + access_ids: formData.access_ids ?? [], + is_default: formData.is_default, + }; + + await rolesApi.create(payload); + navigate("/roles"); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to add role."; + setErrorMessage(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ +
+

{t('add')}

+

+ {t('subTitle')} +

+
+
+
+ +
+
+ + {canReadAllTenants ? ( + + setFormData((prev) => ({ + ...prev, + tenant_id: event.target.value || undefined, + })) + } + options={tenants.map((tenant) => ({ + label: tenant.tenant_name, + value: tenant.id, + }))} + disabled={isTenantLoading} + /> + ) : ( + + )} +
+ + {canReadAllTenants && ( +
+ + setFormData((prev) => ({ ...prev, is_default: e.target.checked })) + } + className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" + /> + +
+ )} + + + setFormData((prev) => ({ ...prev, access_ids: ids })) + } + isLoading={isAccessLoading} + /> + + {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+ + {t('actions.create')} + +
+ +
+ ); +}; + +export default AddRoles; diff --git a/src/application/roles/components/AllRoles.tsx b/src/application/roles/components/AllRoles.tsx new file mode 100644 index 0000000..632baf5 --- /dev/null +++ b/src/application/roles/components/AllRoles.tsx @@ -0,0 +1,519 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { Edit2, Eye, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + CustomButton, + CustomConfirmationModal, + CustomInput, + CustomModal, + CustomTable, + CustomLoader, + CustomActionMenu, + CustomActionItem, +} from "../../../components/custom"; +import { GroupedAccessSelector } from "./GroupedAccessSelector"; +import { GroupedAccessViewer } from "./GroupedAccessViewer"; +import type { ColumnDef } from "../../../components/custom/CustomTable"; +import type { + Role, + RoleAccess, + RoleUpdateRequest, + RolePaginatedResponse, +} from "../RolesTypes"; +import { rolesApi } from "../RolesApi"; +import { tenantsApi } from "../../tenants/TenantsApi"; +import type { Tenant } from "../../tenants/TenantsTypes"; +import { ProtectedComponent } from "../../../components/auth/ProtectedComponent"; +import { useAuth } from "../../../context/AuthContext"; +import { useDebounce } from "../../../components/hooks/useDebounce"; + +const formatDate = (dateString?: string | null, language: string = 'en') => { + if (!dateString) return ""; + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) return dateString; + + const hasTime = dateString.includes("T") || dateString.includes(":"); + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", + }; + + if (hasTime) { + options.hour = "2-digit"; + options.minute = "2-digit"; + options.hour12 = false; + } + + return date.toLocaleString(language === 'ar' ? 'ar-EG' : 'en-GB', options); + } catch { + return dateString; + } +}; + +const AllRoles = () => { + const { t, i18n } = useTranslation(['roles', 'common']); + const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth(); + const canReadAllTenants = hasAccess("superadmin.tenant.read"); + + const [roles, setRoles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + + + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [search, setSearch] = useState(""); + const [totalRows, setTotalRows] = useState(0); + const [, setTotalPages] = useState(0); + + const debouncedSearch = useDebounce(search, 500); + const searchInputRef = useRef(null); + const prevLoadingRef = useRef(isLoading); + + const [selectedRole, setSelectedRole] = useState(null); + const [selectedRoleAccesses, setSelectedRoleAccesses] = useState(null); + const [isViewOpen, setIsViewOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isDetailLoading, setIsDetailLoading] = useState(false); + + const [accessOptions, setAccessOptions] = useState([]); + const [isAccessLoading, setIsAccessLoading] = useState(false); + const [tenants, setTenants] = useState([]); + const [currentTenant, setCurrentTenant] = useState(null); + + const [editForm, setEditForm] = useState<{ role_name: string; is_default?: boolean }>({ role_name: "" }); + const [editAccessIds, setEditAccessIds] = useState([]); + const [editError, setEditError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Filter access options based on current user's permissions + const availableAccessOptions = useMemo(() => { + if ( + !currentUser || + !currentUser.role || + !currentUser.role.accesses || + !Array.isArray(currentUser.role.accesses) + ) { + return []; + } + + return accessOptions.filter((opt) => + currentUser.role!.accesses.includes(opt.access_code) + ); + }, [accessOptions, currentUser]); + + useEffect(() => { + let isMounted = true; + + const loadSupportingData = async () => { + setIsAccessLoading(true); + try { + const accesses = await rolesApi.getAccesses(); + if (isMounted) setAccessOptions(accesses); + + if (canReadAllTenants) { + const tenantsData = await tenantsApi.getAll(); + if (isMounted) setTenants(tenantsData); + } else if (currentUser?.tenant_id) { + const tenant = await tenantsApi.getMine(); + if (isMounted) setCurrentTenant(tenant); + } + } catch (err) { + console.error("Failed to load supporting data:", err); + } finally { + if (isMounted) setIsAccessLoading(false); + } + }; + + const loadRoles = async () => { + setIsLoading(true); + setErrorMessage(""); + + try { + const response: RolePaginatedResponse = await rolesApi.getPaginated({ + page, + page_size: pageSize, + search: debouncedSearch || undefined, + }); + + if (isMounted) { + setRoles(response.items); + setTotalRows(response.total); + setTotalPages(response.total_pages); + } + } catch (err) { + if (isMounted) { + const msg = err instanceof Error ? err.message : t('messages.error'); + setErrorMessage(msg); + } + } finally { + if (isMounted) setIsLoading(false); + } + }; + + if (!isAuthLoading) { + loadSupportingData(); + loadRoles(); + } + + return () => { + isMounted = false; + }; + }, [ + isAuthLoading, + canReadAllTenants, + currentUser?.tenant_id, + page, + pageSize, + debouncedSearch, + t + ]); + + // Reset page when search changes + useEffect(() => { + setPage(1); + }, [debouncedSearch]); + + // Restore search focus after loading + useEffect(() => { + if (prevLoadingRef.current && !isLoading && search.trim()) { + searchInputRef.current?.focus({ preventScroll: true }); + } + prevLoadingRef.current = isLoading; + }, [isLoading, search]); + + const getTenantName = useCallback( + (tenantId?: string | null) => { + if (!tenantId) return ""; + const found = tenants.find((t) => t.id === tenantId); + if (found) return found.tenant_name; + if (currentTenant?.id === tenantId) return currentTenant.tenant_name; + if (currentUser?.tenant_id === tenantId && currentUser?.tenant_name) { + return currentUser.tenant_name; + } + return tenantId; + }, + [tenants, currentTenant, currentUser] + ); + + const openView = useCallback(async (role: Role) => { + setSelectedRole(role); + setSelectedRoleAccesses(null); + setIsViewOpen(true); + setIsDetailLoading(true); + try { + const details = await rolesApi.getById(role.id); + setSelectedRoleAccesses(details.accesses || []); + } catch { + setSelectedRoleAccesses([]); + } finally { + setIsDetailLoading(false); + } + }, []); + + const openEdit = useCallback(async (role: Role) => { + setSelectedRole(role); + setEditForm({ role_name: role.role_name, is_default: role.is_default }); + setEditAccessIds([]); + setEditError(""); + setIsEditOpen(true); + try { + const details = await rolesApi.getById(role.id); + setEditAccessIds(details.accesses?.map((a) => a.id) || []); + } catch (err) { + setEditError(err instanceof Error ? err.message : t('messages.error')); + } + }, [t]); + + const openDelete = useCallback((role: Role) => { + setSelectedRole(role); + setDeleteError(""); + setIsDeleteOpen(true); + }, []); + + const closeView = useCallback(() => { + setIsViewOpen(false); + setSelectedRole(null); + setSelectedRoleAccesses(null); + }, []); + + const closeEdit = useCallback(() => { + setIsEditOpen(false); + setSelectedRole(null); + setEditError(""); + }, []); + + const closeDelete = useCallback(() => { + setIsDeleteOpen(false); + setSelectedRole(null); + setDeleteError(""); + }, []); + + const handleEditChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value, type, checked } = e.target; + setEditForm((prev) => ({ + ...prev, + [name]: type === "checkbox" ? checked : value, + })); + }, + [] + ); + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedRole) return; + + setIsSaving(true); + setEditError(""); + + try { + const payload: RoleUpdateRequest = { + role_name: editForm.role_name.trim(), + access_ids: editAccessIds.length ? editAccessIds : null, + is_default: editForm.is_default, + }; + + const updated = await rolesApi.update(selectedRole.id, payload); + setRoles((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + closeEdit(); + } catch (err) { + setEditError(err instanceof Error ? err.message : t('messages.error')); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (!selectedRole) return; + setIsDeleting(true); + try { + await rolesApi.remove(selectedRole.id); + setRoles((prev) => prev.filter((r) => r.id !== selectedRole.id)); + closeDelete(); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : t('messages.error')); + } finally { + setIsDeleting(false); + } + }; + + const columns = useMemo>>( + () => [ + { key: "role_name", header: t('columns.roleName') }, + { + key: "tenant_id", + header: t('columns.tenant'), + render: (row) => getTenantName(row.tenant_id), + }, + { + key: "created_at", + header: t('columns.created'), + render: (row) => ( +
+ {formatDate(row.created_at, i18n.language)} +
+ ), + }, + { + key: "updated_at", + header: t('columns.updated'), + render: (row) => ( +
+ {formatDate(row.updated_at, i18n.language)} +
+ ), + }, + { + key: "id", + header: t('columns.actions'), + searchable: false, + render: (row) => ( + + + openView(row)}> + {t('actions.view')} + + + + + {(!row.is_default || canReadAllTenants) && ( + openEdit(row)}> + {t('actions.edit')} + + )} + + + + {(!row.is_default || canReadAllTenants) && ( + openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50"> + {t('actions.delete')} + + )} + + + ), + }, + ], + [getTenantName, openView, openEdit, openDelete, t, i18n.language] + ); + + return ( +
+
+
+

{t('title')}

+
+ + + + {t('add')} + + +
+ + + + {isLoading ? ( +
+ +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : ( + row.id} + manualPagination + manualFiltering + totalRows={totalRows} + page={page} + pageSize={pageSize} + search={search} + onPageChange={setPage} + onPageSizeChange={setPageSize} + onSearchChange={setSearch} + searchInputRef={searchInputRef} + /> + )} + + {/* View Modal */} + {t('actions.close')}} + > + {selectedRole ? ( +
+
+
+

{t('columns.roleName')}

+

{selectedRole.role_name}

+
+
+

{t('columns.tenant')}

+

{getTenantName(selectedRole.tenant_id)}

+
+
+

{t('columns.created')}

+

{formatDate(selectedRole.created_at, i18n.language)}

+
+
+

{t('columns.updated')}

+

{formatDate(selectedRole.updated_at, i18n.language)}

+
+
+
+

Accesses

+ {isDetailLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ ) : ( +

{t('common.noData')}

+ )} +
+ + {/* Edit Modal */} + + {t('actions.cancel')} + {t('update')} + + } + > +
+ + + {canReadAllTenants && ( +
+ + +
+ )} + + + {editError && ( +
+ {editError} +
+ )} + +
+ + {/* Delete Modal */} + +
+ ); +}; + +export default AllRoles; \ No newline at end of file diff --git a/src/application/roles/components/GroupedAccessSelector.tsx b/src/application/roles/components/GroupedAccessSelector.tsx new file mode 100644 index 0000000..6d08f33 --- /dev/null +++ b/src/application/roles/components/GroupedAccessSelector.tsx @@ -0,0 +1,296 @@ +import { useMemo } from "react"; +import type { RoleAccess } from "../RolesTypes"; +import { CustomLoader } from "../../../components/custom"; + +interface GroupedAccessSelectorProps { + allAccesses: RoleAccess[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; + isLoading?: boolean; +} + +interface CategoryGroup { + category: string; + accesses: RoleAccess[]; + children: Record; +} + +export const GroupedAccessSelector = ({ + allAccesses, + selectedIds = [], + onChange, + isLoading = false, +}: GroupedAccessSelectorProps) => { + const hierarchicalGroups = useMemo(() => { + const parents: RoleAccess[] = []; + const children: Record = {}; + + allAccesses.forEach(access => { + if (!access.parent_id) { + parents.push(access); + } else { + if (!children[access.parent_id]) { + children[access.parent_id] = []; + } + children[access.parent_id].push(access); + } + }); + + const categoryGroups: Record = {}; + + parents.forEach(parent => { + if (!categoryGroups[parent.category]) { + categoryGroups[parent.category] = { + category: parent.category, + accesses: [], + children: {} + }; + } + + categoryGroups[parent.category].accesses.push(parent); + + const parentChildren = children[parent.id] || []; + parentChildren.forEach(child => { + if (!categoryGroups[parent.category].children[child.category]) { + categoryGroups[parent.category].children[child.category] = { + category: child.category, + accesses: [], + children: {} + }; + } + categoryGroups[parent.category].children[child.category].accesses.push(child); + }); + }); + + return categoryGroups; + }, [allAccesses]); + + const getCategoryAccesses = (group: CategoryGroup): RoleAccess[] => { + const accesses = [...group.accesses]; + Object.values(group.children).forEach(child => { + accesses.push(...child.accesses); + }); + return accesses; + }; + + const isCategorySelected = (group: CategoryGroup) => { + const categoryAccesses = getCategoryAccesses(group); + return ( + categoryAccesses.length > 0 && + categoryAccesses.every((access) => selectedIds.includes(access.id)) + ); + }; + + const isCategoryIndeterminate = (group: CategoryGroup) => { + const categoryAccesses = getCategoryAccesses(group); + const selectedCount = categoryAccesses.filter((access) => + selectedIds.includes(access.id) + ).length; + return selectedCount > 0 && selectedCount < categoryAccesses.length; + }; + + const toggleCategory = (group: CategoryGroup) => { + const categoryAccesses = getCategoryAccesses(group); + const allSelected = isCategorySelected(group); + const categoryIds = categoryAccesses.map((a) => a.id); + + let newIds: string[]; + if (allSelected) { + newIds = selectedIds.filter((id) => !categoryIds.includes(id)); + } else { + const uniqueIds = new Set([...selectedIds, ...categoryIds]); + newIds = Array.from(uniqueIds); + } + onChange(newIds); + }; + + // --- Subcategory Helpers --- + const isSubcategorySelected = (accesses: RoleAccess[]) => { + return ( + accesses.length > 0 && + accesses.every((access) => selectedIds.includes(access.id)) + ); + }; + + const isSubcategoryIndeterminate = (accesses: RoleAccess[]) => { + const selectedCount = accesses.filter((access) => + selectedIds.includes(access.id) + ).length; + return selectedCount > 0 && selectedCount < accesses.length; + }; + + const toggleSubcategory = (accesses: RoleAccess[]) => { + const allSelected = isSubcategorySelected(accesses); + const accessIds = accesses.map((a) => a.id); + + let newIds: string[]; + if (allSelected) { + newIds = selectedIds.filter((id) => !accessIds.includes(id)); + } else { + const uniqueIds = new Set([...selectedIds, ...accessIds]); + newIds = Array.from(uniqueIds); + } + onChange(newIds); + }; + + // --- Global Helpers --- + const isAllSelected = () => { + return ( + allAccesses.length > 0 && + allAccesses.every((access) => selectedIds.includes(access.id)) + ); + }; + + const isAllIndeterminate = () => { + const selectedCount = selectedIds.length; + return selectedCount > 0 && selectedCount < allAccesses.length; + }; + + const toggleAll = () => { + if (isAllSelected()) { + onChange([]); + } else { + const allIds = allAccesses.map((a) => a.id); + onChange(allIds); + } + }; + + // --- Individual Helper --- + const toggleAccess = (accessId: string) => { + const newIds = selectedIds.includes(accessId) + ? selectedIds.filter((id) => id !== accessId) + : [...selectedIds, accessId]; + onChange(newIds); + }; + + if (isLoading) { + return ; + } + + if (allAccesses.length === 0) { + return

No permissions available.

; + } + + return ( +
+
+

Permissions

+ + {/* Global Select All */} +
+ { + if (el) el.indeterminate = isAllIndeterminate(); + }} + onChange={toggleAll} + /> + +
+
+ +
+ {Object.entries(hierarchicalGroups).map(([category, group]) => ( +
+ {/* Category Header */} +
+ { + if (el) el.indeterminate = isCategoryIndeterminate(group); + }} + onChange={() => toggleCategory(group)} + /> + + {category} + +
+ + {/* Parent Accesses */} + {group.accesses.length > 0 && ( +
+ {group.accesses.map((access) => ( + + ))} +
+ )} + + {/* Child Categories */} + {Object.keys(group.children).length > 0 && ( +
+ {Object.entries(group.children).map(([childCategory, childGroup]) => ( +
+ {/* Subcategory Header */} +
+ { + if (el) el.indeterminate = isSubcategoryIndeterminate(childGroup.accesses); + }} + onChange={() => toggleSubcategory(childGroup.accesses)} + /> + + {childCategory} + +
+ + {/* Subcategory Accesses */} +
+ {childGroup.accesses.map((access) => ( + + ))} +
+
+ ))} +
+ )} +
+ ))} +
+
+ ); +}; \ No newline at end of file diff --git a/src/application/roles/components/GroupedAccessViewer.tsx b/src/application/roles/components/GroupedAccessViewer.tsx new file mode 100644 index 0000000..243fcc9 --- /dev/null +++ b/src/application/roles/components/GroupedAccessViewer.tsx @@ -0,0 +1,105 @@ +import { useMemo } from "react"; +import type { RoleAccess } from "../RolesTypes"; + +interface GroupedAccessViewerProps { + accesses: RoleAccess[]; +} + +interface CategoryGroup { + category: string; + accesses: RoleAccess[]; + children: Record; +} + +export const GroupedAccessViewer = ({ accesses }: GroupedAccessViewerProps) => { + const hierarchicalGroups = useMemo(() => { + const parents: RoleAccess[] = []; + const children: Record = {}; + + accesses.forEach(access => { + if (!access.parent_id) { + parents.push(access); + } else { + if (!children[access.parent_id]) { + children[access.parent_id] = []; + } + children[access.parent_id].push(access); + } + }); + + const categoryGroups: Record = {}; + + parents.forEach(parent => { + if (!categoryGroups[parent.category]) { + categoryGroups[parent.category] = { + category: parent.category, + accesses: [], + children: {} + }; + } + + categoryGroups[parent.category].accesses.push(parent); + + const parentChildren = children[parent.id] || []; + parentChildren.forEach(child => { + if (!categoryGroups[parent.category].children[child.category]) { + categoryGroups[parent.category].children[child.category] = { + category: child.category, + accesses: [], + children: {} + }; + } + categoryGroups[parent.category].children[child.category].accesses.push(child); + }); + }); + + return categoryGroups; + }, [accesses]); + + if (!accesses || accesses.length === 0) { + return

No permissions assigned.

; + } + + return ( +
+ {Object.entries(hierarchicalGroups).map(([category, group]) => ( +
+

+ {category} +

+ + {group.accesses.length > 0 && ( +
    + {group.accesses.map((access) => ( +
  • + + {access.name} +
  • + ))} +
+ )} + + {Object.keys(group.children).length > 0 && ( +
+ {Object.entries(group.children).map(([childCategory, childGroup]) => ( +
+
+ {childCategory} +
+
    + {childGroup.accesses.map((access) => ( +
  • + + {access.name} +
  • + ))} +
+
+ ))} +
+ )} +
+ ))} +
+ ); +}; diff --git a/src/application/roles/index.tsx b/src/application/roles/index.tsx new file mode 100644 index 0000000..b18ef52 --- /dev/null +++ b/src/application/roles/index.tsx @@ -0,0 +1,14 @@ +import { Route, Routes } from "react-router-dom"; +import AllRoles from "./components/AllRoles"; +import AddRoles from "./components/AddRoles"; + +const Roles: React.FC = () => { + return ( + + } /> + } /> + + ); +}; + +export default Roles; diff --git a/src/application/tenants/TenantsApi.ts b/src/application/tenants/TenantsApi.ts new file mode 100644 index 0000000..ae61a39 --- /dev/null +++ b/src/application/tenants/TenantsApi.ts @@ -0,0 +1,57 @@ +// src/features/Tenants/TenantsApi.ts +import { apiClient } from "../../lib/apiClient"; +import type { + ApiMessage, + Tenant, + TenantCreateRequest, + TenantPaginatedResponse, + TenantUpdateRequest, +} from "./TenantsTypes"; + +// Helper to build URL with query params +const buildQueryString = (params: Record): string => { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + const query = searchParams.toString(); + return query ? `?${query}` : ""; +}; + +export const tenantsApi = { + getAll: () => apiClient.get("/api/tenant/get"), + + getMine: () => apiClient.get("/api/tenant/me"), + + getById: (tenantId: string) => + apiClient.get(`/api/tenant/get/${tenantId}`), + + create: (payload: TenantCreateRequest) => + apiClient.post("/api/tenant/create", payload, { successMessage: "Tenant created successfully", errorMessage: "Failed to create tenant" }), + + update: (tenantId: string, payload: TenantUpdateRequest) => + apiClient.put(`/api/tenant/update/${tenantId}`, payload, { successMessage: "Tenant updated successfully", errorMessage: "Failed to update tenant" }), + + remove: (tenantId: string) => + apiClient.delete(`/api/tenant/delete/${tenantId}`, { successMessage: "Tenant deleted", errorMessage: "Failed to delete tenant" }), + + // Fixed: Manually append query params since apiClient doesn't support { params } + getPaginated: (params: { + page?: number; + page_size?: number; + search?: string; + is_active?: boolean | null; + }) => { + const queryString = buildQueryString({ + page: params.page, + page_size: params.page_size, + search: params.search, + is_active: params.is_active, + }); + return apiClient.get( + `/api/tenant/list${queryString}` + ); + }, +}; \ No newline at end of file diff --git a/src/application/tenants/TenantsTypes.ts b/src/application/tenants/TenantsTypes.ts new file mode 100644 index 0000000..16874bf --- /dev/null +++ b/src/application/tenants/TenantsTypes.ts @@ -0,0 +1,35 @@ +export type Tenant = { + id: string; + tenant_name: string; + tenant_domain: string; + tenant_logo_url?: string | null; + is_active: boolean; + created_at: string; + updated_at: string; +}; + +export type TenantCreateRequest = { + tenant_name: string; + tenant_domain: string; + tenant_logo_url?: string; + is_active?: boolean; +}; + +export type TenantUpdateRequest = { + tenant_name?: string; + tenant_domain?: string; + tenant_logo_url?: string | null; + is_active?: boolean; +}; + +export type TenantPaginatedResponse = { + items: Tenant[]; + total: number; + page: number; + page_size: number; + total_pages: number; +}; + +export type ApiMessage = { + message?: string; +}; diff --git a/src/application/tenants/components/AddTenants.tsx b/src/application/tenants/components/AddTenants.tsx new file mode 100644 index 0000000..2e578da --- /dev/null +++ b/src/application/tenants/components/AddTenants.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { CustomButton, CustomInput } from "../../../components/custom"; +import CustomBackButton from "../../../components/custom/CustomBackButton"; +import { CustomLoader } from "../../../components/custom"; +import { tenantsApi } from "../TenantsApi"; +import type { Tenant, TenantCreateRequest } from "../TenantsTypes"; +import { useAuth } from "../../../context/AuthContext"; + +const AddTenants = () => { + const { hasAccess, isLoading: isAuthLoading } = useAuth(); + const canCreateTenant = hasAccess("superadmin.tenant.create"); + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + tenant_name: "", + tenant_domain: "", + tenant_logo_url: "", + }); + const [currentTenant, setCurrentTenant] = useState(null); + const [isTenantLoading, setIsTenantLoading] = useState(true); + const [tenantError, setTenantError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + if (isAuthLoading || canCreateTenant) { + setIsTenantLoading(false); + return; + } + + let isMounted = true; + const loadTenant = async () => { + setTenantError(""); + setIsTenantLoading(true); + try { + const data = await tenantsApi.getMine(); + if (isMounted) { + setCurrentTenant(data); + } + } catch (error) { + if (isMounted) { + const message = + error instanceof Error + ? error.message + : "Unable to load tenant."; + setTenantError(message); + } + } finally { + if (isMounted) { + setIsTenantLoading(false); + } + } + }; + + loadTenant(); + + return () => { + isMounted = false; + }; + }, [canCreateTenant, isAuthLoading]); + + const handleChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setErrorMessage(""); + setIsLoading(true); + + try { + const payload: TenantCreateRequest = { + tenant_name: formData.tenant_name.trim(), + tenant_domain: formData.tenant_domain.trim(), + tenant_logo_url: formData.tenant_logo_url?.trim() || undefined, + }; + + await tenantsApi.create(payload); + navigate("/tenants"); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to add tenant."; + setErrorMessage(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ +
+

+ {canCreateTenant ? "Add Tenant" : "Tenant Details"} +

+

+ {canCreateTenant + ? "Create a new tenant with domain and logo details." + : "Your account is assigned to this tenant."} +

+
+
+
+ + {!canCreateTenant ? ( + isAuthLoading || isTenantLoading ? ( +
+ +
+ ) : tenantError ? ( +
+ {tenantError} +
+ ) : currentTenant ? ( +
+
+ + +
+ +
+ ) : ( +
+ No tenant assigned. +
+ ) + ) : ( +
+
+ + +
+ + + + {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+ + Create Tenant + +
+ + )} +
+ ); +}; + +export default AddTenants; diff --git a/src/application/tenants/components/AllTenants.tsx b/src/application/tenants/components/AllTenants.tsx new file mode 100644 index 0000000..cd163ab --- /dev/null +++ b/src/application/tenants/components/AllTenants.tsx @@ -0,0 +1,599 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { Edit2, Eye, Trash2 } from "lucide-react"; +import { + CustomButton, + CustomCheckBox, + CustomConfirmationModal, + CustomInput, + CustomModal, + CustomTable, + CustomStatus, + CustomLoader, + CustomActionMenu, + CustomActionItem, +} from "../../../components/custom"; +import type { ColumnDef } from "../../../components/custom/CustomTable"; +import type { Tenant, TenantUpdateRequest } from "../TenantsTypes"; +import { tenantsApi } from "../TenantsApi"; +import { useAuth } from "../../../context/AuthContext"; + +// Local implementation of useDebounce +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + return debouncedValue; +} + +// Local implementation of ProtectedComponent +const ProtectedComponent: React.FC<{ + requiredAccess: string; + children: React.ReactNode; +}> = ({ requiredAccess, children }) => { + const { hasAccess } = useAuth(); + if (!hasAccess(requiredAccess)) { + return null; + } + return <>{children}; +}; + +const formatDate = (dateString?: string | null) => { + if (!dateString) return ""; + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) return dateString; + + // Check if the input likely contains specific time (ISO with T or explicit time chars) + const hasTime = dateString.includes("T") || dateString.includes(":"); + + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", + }; + + if (hasTime) { + options.hour = "2-digit", + options.minute = "2-digit", + options.hour12 = false; + } + + return date.toLocaleString("en-GB", options); + } catch { + return dateString; + } +}; + +const AllTenants = () => { + const { hasAccess, isLoading: isAuthLoading } = useAuth(); + const canReadAll = hasAccess("superadmin.tenant.read"); + const [tenants, setTenants] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + const [selectedTenant, setSelectedTenant] = useState(null); + const [isViewOpen, setIsViewOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [editForm, setEditForm] = useState({ + tenant_name: "", + tenant_domain: "", + tenant_logo_url: "", + is_active: true, + }); + const [editError, setEditError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Pagination state + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [search, setSearch] = useState(""); + const [totalRows, setTotalRows] = useState(0); + const [, setTotalPages] = useState(0); + + // Status filter state + const [statusFilter, setStatusFilter] = useState(null); + + // Ref for the search input to restore focus + const searchInputRef = useRef(null); + + // Track previous loading state to detect transition from loading → idle + const prevLoadingRef = useRef(isLoading); + + // Debounce search to avoid excessive API calls + const debouncedSearch = useDebounce(search, 500); + + useEffect(() => { + let isMounted = true; + + const loadTenants = async () => { + if (isAuthLoading) { + return; + } + + setIsLoading(true); + setErrorMessage(""); + + try { + if (canReadAll) { + // Use paginated API for server-side pagination + const response = await tenantsApi.getPaginated({ + page, + page_size: pageSize, + search: debouncedSearch || undefined, + is_active: statusFilter, + }); + + if (isMounted) { + setTenants(response.items); + setTotalRows(response.total); + setTotalPages(response.total_pages); + } + } else { + // For non-superadmin, show only their tenant + const data = await tenantsApi.getMine(); + if (isMounted) { + setTenants([data]); + setTotalRows(1); + setTotalPages(1); + } + } + } catch (error) { + if (isMounted) { + const message = + error instanceof Error + ? error.message + : "Unable to load tenants."; + setErrorMessage(message); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + loadTenants(); + + return () => { + isMounted = false; + }; + }, [canReadAll, isAuthLoading, page, pageSize, debouncedSearch, statusFilter]); + + // Restore focus to search input after loading completes (only if user was searching) + useEffect(() => { + if (prevLoadingRef.current && !isLoading && search.trim() !== "") { + searchInputRef.current?.focus({ preventScroll: true }); + } + prevLoadingRef.current = isLoading; + }, [isLoading, search]); + + // Reset to page 1 when search or status filter changes + useEffect(() => { + setPage(1); + }, [debouncedSearch, statusFilter]); + + const openView = useCallback((tenant: Tenant) => { + setSelectedTenant(tenant); + setIsViewOpen(true); + }, []); + + const openEdit = useCallback((tenant: Tenant) => { + setSelectedTenant(tenant); + setEditForm({ + tenant_name: tenant.tenant_name, + tenant_domain: tenant.tenant_domain, + tenant_logo_url: tenant.tenant_logo_url ?? "", + is_active: tenant.is_active, + }); + setEditError(""); + setIsEditOpen(true); + }, []); + + const openDelete = useCallback((tenant: Tenant) => { + setSelectedTenant(tenant); + setDeleteError(""); + setIsDeleteOpen(true); + }, []); + + const closeView = useCallback(() => { + setIsViewOpen(false); + setSelectedTenant(null); + }, []); + + const closeEdit = useCallback(() => { + setIsEditOpen(false); + setSelectedTenant(null); + setEditError(""); + }, []); + + const closeDelete = useCallback(() => { + setIsDeleteOpen(false); + setSelectedTenant(null); + setDeleteError(""); + }, []); + + const handleEditChange = useCallback( + (event: React.ChangeEvent) => { + const { name, value } = event.target; + setEditForm((prev) => ({ ...prev, [name]: value })); + }, + [] + ); + + const handleStatusChange = useCallback( + (event: React.ChangeEvent) => { + setEditForm((prev) => ({ ...prev, is_active: event.target.checked })); + }, + [] + ); + + const handleUpdate = async (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedTenant) return; + + setEditError(""); + setIsSaving(true); + + try { + const payload: TenantUpdateRequest = { + tenant_name: editForm.tenant_name.trim(), + tenant_domain: editForm.tenant_domain.trim(), + tenant_logo_url: editForm.tenant_logo_url.trim() || null, + is_active: editForm.is_active, + }; + + const updatedTenant = await tenantsApi.update(selectedTenant.id, payload); + setTenants((prev) => + prev.map((tenant) => + tenant.id === updatedTenant.id ? updatedTenant : tenant + ) + ); + setIsEditOpen(false); + setSelectedTenant(null); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to update tenant."; + setEditError(message); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (!selectedTenant) return; + + setDeleteError(""); + setIsDeleting(true); + + try { + await tenantsApi.remove(selectedTenant.id); + setTenants((prev) => + prev.filter((tenant) => tenant.id !== selectedTenant.id) + ); + setIsDeleteOpen(false); + setSelectedTenant(null); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to delete tenant."; + setDeleteError(message); + } finally { + setIsDeleting(false); + } + }; + + const columns = useMemo>>( + () => [ + { key: "tenant_name", header: "Tenant Name" }, + { key: "tenant_domain", header: "Domain" }, + { + key: "tenant_logo_url", + header: "Logo", + searchable: false, + render: (row) => + row.tenant_logo_url ? ( + {row.tenant_name} + ) : ( + + ), + }, + { + key: "is_active", + header: "Status", + searchable: false, + render: (row) => ( + + ), + }, + { + key: "created_at", + header: "Created", + render: (row) => ( +
+ {formatDate(row.created_at)} +
+ ), + }, + { + key: "updated_at", + header: "Updated", + render: (row) => ( +
+ {formatDate(row.updated_at)} +
+ ), + }, + { + key: "id", + header: "Action", + searchable: false, + render: (row) => ( + + + openView(row)}> + View Details + + + + + openEdit(row)}> + Edit Tenant + + + + + openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50"> + Delete Tenant + + + + ), + }, + ], + [openDelete, openEdit, openView] + ); + + // Status filter control + const filterControls = canReadAll ? ( + + ) : null; + + return ( +
+
+
+

Tenants

+ {/*

+ {totalRows} Tenant{totalRows === 1 ? "" : "s"} in total +

*/} +
+ + + + Add Tenant + + +
+ + {isLoading ? ( +
+ +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : ( + row.id} + manualPagination={canReadAll} + manualFiltering={canReadAll} + totalRows={totalRows} + page={page} + pageSize={pageSize} + search={search} + onPageChange={setPage} + onPageSizeChange={setPageSize} + onSearchChange={setSearch} + searchInputRef={searchInputRef} + filterControls={filterControls} + /> + )} + + + Close + + } + > + {selectedTenant ? ( +
+
+

+ Name +

+

+ {selectedTenant.tenant_name} +

+
+
+

+ Domain +

+

+ {selectedTenant.tenant_domain} +

+
+
+

+ Logo +

+ {selectedTenant.tenant_logo_url ? ( + {selectedTenant.tenant_name} + ) : ( +

+ )} +
+
+

+ Status +

+

+ {selectedTenant.is_active ? "Active" : "Inactive"} +

+
+
+

+ Created +

+

+ {formatDate(selectedTenant.created_at)} +

+
+
+

+ Updated +

+

+ {formatDate(selectedTenant.updated_at)} +

+
+
+ ) : ( +

No tenant selected.

+ )} +
+ + + + Cancel + + + Save Changes + + + } + > +
+
+ + +
+ + + +
+ {/* Checkbox component not found in standard custom, using explicit input or different component if needed, + but keeping CustomSwitch or similar logic is safer. User had CustomCheckBox. + I'll assume I need to replace it with simple input or use CustomSwitch if I saw it. + I saw CustomSwitch in my files but not necessarily in the user's previous code unless I missed it. + Actually, CustomCheckBox was imported from "Custom". I have "CustomSwitch". + Let's use CustomSwitch for "is_active" as it's cleaner. */} + +
+ + {editError && ( +
+ {editError} +
+ )} + +
+ + +
+ ); +}; + +export default AllTenants; \ No newline at end of file diff --git a/src/application/tenants/index.tsx b/src/application/tenants/index.tsx new file mode 100644 index 0000000..692ded4 --- /dev/null +++ b/src/application/tenants/index.tsx @@ -0,0 +1,13 @@ +import { Route, Routes } from "react-router-dom"; +import AllTenants from "./components/AllTenants"; +import AddTenants from "./components/AddTenants"; + +const TenantsRoutes: React.FC = () => { + return ( + + } /> + } /> + + ); +}; +export default TenantsRoutes; diff --git a/src/application/theme/components/AllPalettes.tsx b/src/application/theme/components/AllPalettes.tsx index 4e2c72f..c5fba33 100644 --- a/src/application/theme/components/AllPalettes.tsx +++ b/src/application/theme/components/AllPalettes.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useMemo } from "react"; import { Plus, Edit2, Trash2 } from "lucide-react"; import { toast } from "react-toastify"; -import {CustomButton} from "../../../components/custom/CustomButton"; +import {CustomButton} from "../../../components/custom"; import DataTable, { type ColumnDef } from "../../../components/custom/CustomTable"; import { CustomActionMenu, CustomActionItem, CustomStatus } from "../../../components/custom"; import { Loader } from "../../../components/custom/CustomLoader"; diff --git a/src/application/theme/components/PaletteForm.tsx b/src/application/theme/components/PaletteForm.tsx index 1612704..84eb781 100644 --- a/src/application/theme/components/PaletteForm.tsx +++ b/src/application/theme/components/PaletteForm.tsx @@ -3,7 +3,7 @@ import { useForm } from "react-hook-form"; import { Check } from "lucide-react"; import CustomModal from "../../../components/custom/CustomModal"; import CustomInput from "../../../components/custom/CustomInput"; -import { CustomButton } from "../../../components/custom/CustomButton"; +import {CustomButton} from "../../../components/custom"; import type { ColorPalette } from "../ThemeTypes"; interface PaletteFormProps { diff --git a/src/components/auth/ProtectedComponent.tsx b/src/components/auth/ProtectedComponent.tsx new file mode 100644 index 0000000..a79c74d --- /dev/null +++ b/src/components/auth/ProtectedComponent.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import type { ReactNode } from "react"; +import { useAuth } from "../../context/AuthContext"; + +interface ProtectedComponentProps { + children: ReactNode; + requiredAccess?: string; + fallback?: ReactNode; +} + +export const ProtectedComponent: React.FC = ({ + children, + requiredAccess, + fallback = null, +}) => { + const { hasAccess, isLoading } = useAuth(); + + if (isLoading) return null; + + if (requiredAccess && !hasAccess(requiredAccess)) { + return <>{fallback}; + } + + return <>{children}; +}; diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..ee0e20b --- /dev/null +++ b/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Navigate } from "react-router-dom"; +import { useAuth } from "../../context/AuthContext"; +import { CustomLoader } from "../custom"; +interface ProtectedRouteProps { + children: React.ReactNode; + requiredAccess?: string; +} + +export const ProtectedRoute: React.FC = ({ + children, + requiredAccess, +}) => { + const { isAuthenticated, isLoading, hasAccess } = useAuth(); + + if (isLoading) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (requiredAccess && !hasAccess(requiredAccess)) { + return ; + } + + return <>{children}; +}; diff --git a/src/components/hooks/useCountriesNow.ts b/src/components/hooks/useCountriesNow.ts new file mode 100644 index 0000000..60c16d0 --- /dev/null +++ b/src/components/hooks/useCountriesNow.ts @@ -0,0 +1,90 @@ +import { useState, useCallback } from 'react'; + +interface StateData { + name: string; + state_code?: string; +} + +export const useCountriesNow = () => { + const [statesLoading, setStatesLoading] = useState(false); + const [citiesLoading, setCitiesLoading] = useState(false); + const [states, setStates] = useState([]); + const [cities, setCities] = useState([]); + + const fetchStates = useCallback(async (countryName: string): Promise => { + if (!countryName) { + setStates([]); + return []; + } + + setStatesLoading(true); + try { + const response = await fetch('https://countriesnow.space/api/v0.1/countries/states', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ country: countryName }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.data?.states) { + const stateNames = data.data.states.map((s: StateData) => s.name); + setStates(stateNames); + return stateNames; + } + } + setStates([]); + return []; + } catch (error) { + setStates([]); + return []; + } finally { + setStatesLoading(false); + } + }, []); + + const fetchCities = useCallback(async (countryName: string, stateName: string): Promise => { + if (!countryName || !stateName) { + setCities([]); + return []; + } + + setCitiesLoading(true); + try { + const response = await fetch('https://countriesnow.space/api/v0.1/countries/state/cities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ country: countryName, state: stateName }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.data) { + setCities(data.data); + return data.data; + } + } + setCities([]); + return []; + } catch (error) { + setCities([]); + return []; + } finally { + setCitiesLoading(false); + } + }, []); + + const clearStates = useCallback(() => setStates([]), []); + const clearCities = useCallback(() => setCities([]), []); + + return { + fetchStates, + fetchCities, + clearStates, + clearCities, + states, + cities, + statesLoading, + citiesLoading, + }; +}; diff --git a/src/components/hooks/useDebounce.ts b/src/components/hooks/useDebounce.ts new file mode 100644 index 0000000..069ee37 --- /dev/null +++ b/src/components/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay: number = 500): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} \ No newline at end of file diff --git a/src/components/hooks/usePostalCodeLookup.ts b/src/components/hooks/usePostalCodeLookup.ts new file mode 100644 index 0000000..3090a5c --- /dev/null +++ b/src/components/hooks/usePostalCodeLookup.ts @@ -0,0 +1,84 @@ +import { useState } from 'react'; + +export interface PostalCodeResult { + city: string; + state: string; + success: boolean; +} + +export const usePostalCodeLookup = () => { + const [loading, setLoading] = useState(false); + + const lookupPostalCode = async ( + postalCode: string, + countryCode: string + ): Promise => { + if (!postalCode || postalCode.length < 3) { + return { city: '', state: '', success: false }; + } + + setLoading(true); + + try { + if (countryCode.toUpperCase() === 'IN' && postalCode.length === 6) { + const response = await fetch( + `https://api.postalpincode.in/pincode/${postalCode}` + ); + const data = await response.json(); + + if (data[0]?.Status === 'Success' && data[0]?.PostOffice?.length > 0) { + const { District, State } = data[0].PostOffice[0]; + return { + city: District, + state: State, + success: true, + }; + } + } + + const zipResponse = await fetch( + `https://api.zippopotam.us/${countryCode.toLowerCase()}/${postalCode}` + ); + + if (zipResponse.ok) { + const data = await zipResponse.json(); + + if (data && data.places && data.places.length > 0) { + let selectedPlace = data.places[0]; + + if (data.places.length > 1) { + const cityPlace = data.places.find((place: any) => { + const placeName = place['place name'] || ''; + return placeName.split(' ').length <= 2; + }); + if (cityPlace) { + selectedPlace = cityPlace; + } + } + + const city = selectedPlace['place name'] || ''; + const state = + selectedPlace['state'] || selectedPlace['state abbreviation'] || ''; + + return { + city, + state, + success: true, + }; + } + } + + return { city: '', state: '', success: false }; + + } catch (error) { + return { city: '', state: '', success: false }; + } finally { + setLoading(false); + } + }; + + return { + lookupPostalCode, + loading, + }; +}; diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index dc31fcb..e925ab0 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { useSidebar } from "../../context/SidebarContext"; import { useAuth } from "../../context/AuthContext"; -import { authApi } from "../../application/Authentication/AuthApi"; +import { authApi } from "../../application/authentication/AuthApi"; import { clearAuthCookies } from "../../lib/authCookies"; import { useTranslation } from "react-i18next"; @@ -76,43 +76,43 @@ const AppHeader: React.FC = () => { - + {/* Multiple route paths */} - + {/* Hub nodes with glow effect */} - + - + - + - + {/* Package icons */} - + - - - + + + - + - + {/* Connecting dots */} diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 413b171..4a4ee44 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -19,7 +19,7 @@ import { import { useSidebar } from "../../context/SidebarContext"; import { usePermission } from "../../lib/usePermission"; import { useAuth } from "../../context/AuthContext"; -import { authApi } from "../../application/Authentication/AuthApi"; +import { authApi } from "../../application/authentication/AuthApi"; import { clearAuthCookies } from "../../lib/authCookies"; @@ -66,11 +66,9 @@ const navItems: NavItem[] = [ { icon: , name: "Themes", - path: "/themes", + path: "/theme", access: "superadmin.palette.read", }, - - { icon: , name: "Settings", diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index ba67553..817bd65 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,10 +1,10 @@ import React, { createContext, useContext, useState, useEffect } from "react"; import type { ReactNode } from "react"; -import { authApi } from "../application/Authentication/AuthApi"; +import { authApi } from "../application/authentication/AuthApi"; import type { AuthUser, SigninRequest, -} from "../application/Authentication/AuthTypes"; +} from "../application/authentication/AuthTypes"; import { setAuthCookies, getAccessToken, @@ -43,7 +43,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ try { const userData = await authApi.me(); setUser(userData); - + if (userData.preferred_language) { i18n.changeLanguage(userData.preferred_language); } @@ -69,11 +69,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ } }; -const logout = async () => { - clearAuthCookies(); - setUser(null); - window.location.href = "/signin"; -}; + const logout = async () => { + clearAuthCookies(); + setUser(null); + window.location.href = "/signin"; + }; const hasAccess = (accessCode: string): boolean => { @@ -85,7 +85,7 @@ const logout = async () => { try { const userData = await authApi.me(); setUser(userData); - + if (userData.preferred_language) { i18n.changeLanguage(userData.preferred_language); } @@ -95,7 +95,7 @@ const logout = async () => { const updateLanguage = async (language: string) => { if (!user) throw new Error('No user logged in'); - + try { const updatedUser = await authApi.updateLanguage(user.id, language); setUser(updatedUser); diff --git a/src/lib/authCookies.ts b/src/lib/authCookies.ts index 20cd105..8786cf5 100644 --- a/src/lib/authCookies.ts +++ b/src/lib/authCookies.ts @@ -1,4 +1,4 @@ -import {type TokenResponse } from "../application/Authentication/AuthTypes"; +import { type TokenResponse } from "../application/authentication/AuthTypes"; export const AUTH_COOKIE_KEYS = { access: "access_token", diff --git a/src/routes/index.tsx b/src/routes/index.tsx index a443891..c69f7b1 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,12 +1,14 @@ import { Route, Routes, Navigate } from "react-router-dom"; -import SignInPage from "../application/Authentication/SignInPage"; -import SignUpPage from "../application/Authentication/SignUpPage"; -import ResetPasswordPage from "../application/Authentication/ResetPasswordPage"; +import SignInPage from "../application/authentication/SignInPage"; +import SignUpPage from "../application/authentication/SignUpPage"; +import ResetPasswordPage from "../application/authentication/ResetPasswordPage"; +import Roles from "../application/roles"; import Dashboard from "../application/dashboard/Dashboard"; import ProfilePage from "../application/profile/ProfilePage"; +import Tenants from "../application/tenants"; import ProtectedRoutes from "./ProtectedRoutes"; - +import Theme from "../application/theme"; const AppRoutes = () => { return ( @@ -19,6 +21,9 @@ const AppRoutes = () => { }> } /> } /> + } /> + } /> + } /> {/* Redirect root to dashboard if logged in (ProtectedRoutes handles auth check usually, or we redirect to signin if not) */} } />