From 0df736d3bc2787760b76a13dec6fc3a1e1aa524c Mon Sep 17 00:00:00 2001 From: azeeee05 Date: Mon, 19 Jan 2026 14:39:07 +0530 Subject: [PATCH] users added --- .env.development | 2 + .env.localhost | 2 + .env.production | 2 + package-lock.json | 113 +++- package.json | 5 +- src/application/users/UserApi.ts | 124 ++++ src/application/users/UserTypes.ts | 66 +++ src/application/users/components/AddUsers.tsx | 416 +++++++++++++ src/application/users/components/AllUsers.tsx | 561 ++++++++++++++++++ src/application/users/index.tsx | 14 + src/routes/index.tsx | 2 + vite.config.ts | 11 +- 12 files changed, 1297 insertions(+), 21 deletions(-) create mode 100644 .env.development create mode 100644 .env.localhost create mode 100644 .env.production create mode 100644 src/application/users/UserApi.ts create mode 100644 src/application/users/UserTypes.ts create mode 100644 src/application/users/components/AddUsers.tsx create mode 100644 src/application/users/components/AllUsers.tsx create mode 100644 src/application/users/index.tsx diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..a6c4437 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +# Development environment +VITE_API_BASE_URL=https://dev-api.yourdomain.com diff --git a/.env.localhost b/.env.localhost new file mode 100644 index 0000000..c7f5d22 --- /dev/null +++ b/.env.localhost @@ -0,0 +1,2 @@ +# Local environment +VITE_API_BASE_URL=http://localhost:8000 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..cb37462 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +# Production environment +VITE_API_BASE_URL=https://api.yourdomain.com diff --git a/package-lock.json b/package-lock.json index 9f91da9..4ec5556 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react-dom": "^19.2.0", "react-hook-form": "^7.50.0", "react-i18next": "^16.5.3", + "react-phone-number-input": "^3.4.14", "react-router-dom": "^7.12.0", "react-toastify": "^11.0.5", "tailwindcss": "^4.1.18" @@ -70,7 +71,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1793,7 +1793,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1804,7 +1803,6 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1864,7 +1862,6 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -2112,7 +2109,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2235,7 +2231,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2311,6 +2306,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2379,6 +2380,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/country-flag-icons": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.4.tgz", + "integrity": "sha512-Z3Zi419FI889tlElMsVhCIS5eRkiLDWixr576J5DPiTe5RGxpbRi+enMpHdYVp5iK5WFjr8P/RgyIFAGhFsiFg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2593,7 +2600,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3105,7 +3111,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3164,6 +3169,27 @@ "node": ">=0.8.19" } }, + "node_modules/input-format": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz", + "integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=18.1.0", + "react-dom": ">=18.1.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3207,7 +3233,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3284,8 +3309,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/leaflet-control-geocoder": { "version": "3.3.1", @@ -3322,6 +3346,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.34", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.34.tgz", + "integrity": "sha512-v/Ip8k8eYdp7bINpzqDh46V/PaQ8sK+qi97nMQgjZzFlb166YFqlR/HVI+MzsI9JqcyyVWCOipmmretiaSyQyw==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -3594,6 +3624,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3704,6 +3746,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/open-location-code": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/open-location-code/-/open-location-code-1.0.3.tgz", @@ -3805,7 +3856,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3851,6 +3901,17 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3872,7 +3933,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3882,7 +3942,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3933,6 +3992,29 @@ } } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-phone-number-input": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz", + "integrity": "sha512-T9MziNuvthzv6+JAhKD71ab/jVXW5U20nQZRBJd6+q+ujmkC+/ISOf2GYo8pIi4VGjdIYRIHDftMAYn3WKZT3w==", + "license": "MIT", + "dependencies": { + "classnames": "^2.5.1", + "country-flag-icons": "^1.5.17", + "input-format": "^0.3.14", + "libphonenumber-js": "^1.12.27", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-router": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", @@ -4185,7 +4267,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4280,7 +4361,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4411,7 +4491,6 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 203809b..5b60508 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "local": "vite --mode localhost", + "dev": "vite --mode development", + "prod": "vite --mode production", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" @@ -22,6 +24,7 @@ "react-dom": "^19.2.0", "react-hook-form": "^7.50.0", "react-i18next": "^16.5.3", + "react-phone-number-input": "^3.4.14", "react-router-dom": "^7.12.0", "react-toastify": "^11.0.5", "tailwindcss": "^4.1.18" diff --git a/src/application/users/UserApi.ts b/src/application/users/UserApi.ts new file mode 100644 index 0000000..a3f1a81 --- /dev/null +++ b/src/application/users/UserApi.ts @@ -0,0 +1,124 @@ +import { apiClient } from "../../lib/apiClient"; +import type { + ApiMessage, + User, + UserApiResponse, + UserCreateRequest, + UserStatus, + UserUpdateRequest, + UserPaginatedResponse, +} from "./UserTypes"; + +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}` : ""; +}; + +const normalizeStatus = (status?: string): UserStatus => + status?.toLowerCase() === "inactive" ? "inactive" : "active"; + +const normalizeId = (value?: string | null) => { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +const toUser = (user: UserApiResponse): User => ({ + id: user.id, + email: user.email, + password: "", + first_name: user.first_name, + last_name: user.last_name ?? null, + phone_number: user.phone_number ?? null, + status: normalizeStatus(user.status), + tenant_id: user.tenant_id ?? null, + tenant_name: null, + role_id: user.role_id ?? null, + role_name: null, + created_at: user.created_at, + updated_at: user.updated_at, + password_updated_at: user.password_updated_at ?? user.updated_at, +}); + +const toCreatePayload = (payload: UserCreateRequest) => ({ + email: payload.email, + password: payload.password, + first_name: payload.first_name, + last_name: payload.last_name || undefined, + phone_number: payload.phone_number || undefined, + status: normalizeStatus(payload.status), + tenant_id: normalizeId(payload.tenant_id), + role_id: normalizeId(payload.role_id), +}); + +const toUpdatePayload = (payload: UserUpdateRequest) => ({ + email: payload.email?.trim() || undefined, + password: payload.password, + first_name: payload.first_name?.trim() || undefined, + last_name: + payload.last_name !== undefined ? payload.last_name?.trim() || null : undefined, + phone_number: + payload.phone_number !== undefined + ? payload.phone_number?.trim() || null + : undefined, + status: payload.status ? normalizeStatus(payload.status) : undefined, + tenant_id: + payload.tenant_id !== undefined ? normalizeId(payload.tenant_id) : undefined, + role_id: + payload.role_id !== undefined ? normalizeId(payload.role_id) : undefined, +}); + +export const usersApi = { + getAll: async () => { + const data = await apiClient.get("/api/user/get"); + return data.map(toUser); + }, + + getById: async (userId: string) => { + const data = await apiClient.get(`/api/user/get/${userId}`); + return toUser(data); + }, + + create: async (payload: UserCreateRequest) => { + const data = await apiClient.post( + "/api/user/create", + toCreatePayload(payload), + { successMessage: "User created successfully", errorMessage: "Failed to create user" } + ); + return toUser(data); + }, + + update: async (userId: string, payload: UserUpdateRequest) => { + const data = await apiClient.put( + `/api/user/update/${userId}`, + toUpdatePayload(payload), + { successMessage: "User updated successfully", errorMessage: "Failed to update user" } + ); + return toUser(data); + }, + + remove: async (userId: string): Promise => + apiClient.delete(`/api/user/delete/${userId}`, { successMessage: "User deleted", errorMessage: "Failed to delete user" }), + + getPaginated: (params: { + page?: number; + page_size?: number; + search?: string; + status?: UserStatus | null; + }) => { + const queryString = buildQueryString({ + page: params.page, + page_size: params.page_size, + search: params.search, + status: params.status ?? undefined, + }); + return apiClient.get(`/api/user/list${queryString}`); + }, +}; \ No newline at end of file diff --git a/src/application/users/UserTypes.ts b/src/application/users/UserTypes.ts new file mode 100644 index 0000000..a94e92d --- /dev/null +++ b/src/application/users/UserTypes.ts @@ -0,0 +1,66 @@ +export type UserStatus = "active" | "inactive"; + +export type User = { + id: string; + email: string; + password: string; + first_name: string; + last_name?: string | null; + phone_number?: string | null; + status: UserStatus; + tenant_id?: string | null; + tenant_name?: string | null; + role_id?: string | null; + role_name?: string | null; + created_at: string; + updated_at: string; + password_updated_at: string; +}; + +export type UserCreateRequest = { + email: string; + password: string; + first_name: string; + last_name?: string; + phone_number?: string; + status?: UserStatus; + tenant_id?: string; + role_id?: string; +}; + +export type UserUpdateRequest = { + email?: string; + password?: string; + first_name?: string; + last_name?: string | null; + phone_number?: string | null; + status?: UserStatus; + tenant_id?: string | null; + role_id?: string | null; +}; + +export type ApiMessage = { + message?: string; +}; + +export type UserApiResponse = { + id: string; + email: string; + first_name: string; + last_name?: string | null; + phone_number?: string | null; + status?: string; + tenant_id?: string | null; + role_id?: string | null; + created_at: string; + updated_at: string; + password_updated_at?: string | null; +}; + +export type UserPaginatedResponse = { + items: User[]; + total: number; + page: number; + page_size: number; + total_pages: number; +}; \ No newline at end of file diff --git a/src/application/users/components/AddUsers.tsx b/src/application/users/components/AddUsers.tsx new file mode 100644 index 0000000..6f1a63c --- /dev/null +++ b/src/application/users/components/AddUsers.tsx @@ -0,0 +1,416 @@ +import { useEffect, useMemo, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + CustomButton, + CustomBackButton, + CustomCheckBox, + CustomDropdown, + CustomInput, +} from "../../../components/custom"; +import { CustomPhoneInput } from "../../../components/custom/CustomPhoneInput"; +import { usersApi } from "../UserApi"; +import type { UserCreateRequest, UserStatus } from "../UserTypes"; +import { tenantsApi } from "../../tenants/TenantsApi"; +import type { Tenant } from "../../tenants/TenantsTypes"; +import { rolesApi } from "../../roles/RolesApi"; +import type { Role } from "../../roles/RolesTypes"; +import { useAuth } from "../../../context/AuthContext"; + +const normalizeStatus = (value: string): UserStatus => + value.toLowerCase() === "inactive" ? "inactive" : "active"; + +const AddUsers = () => { + const { t } = useTranslation(['users', 'common']); + const { user, hasAccess, isLoading: isAuthLoading } = useAuth(); + const canReadAllTenants = hasAccess("superadmin.tenant.read"); + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + first_name: "", + last_name: "", + email: "", + password: "", + phone_number: "", + tenant_id: "", + role_id: "", + status: "active", + }); + const [isSuperAdminUser, setIsSuperAdminUser] = useState(false); + const [lastTenantId, setLastTenantId] = useState(""); + const [tenants, setTenants] = useState([]); + const [currentTenantName, setCurrentTenantName] = useState(""); + const [roles, setRoles] = useState([]); + const [isTenantLoading, setIsTenantLoading] = useState(false); + const [isRoleLoading, setIsRoleLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [loadError, setLoadError] = useState(""); + + useEffect(() => { + if (isAuthLoading) { + return; + } + + let isMounted = true; + + 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."; + setLoadError((prev) => prev || 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."; + setLoadError((prev) => prev || message); + } + } finally { + if (isMounted) { + setIsTenantLoading(false); + } + } + }; + + const loadRoles = async () => { + setIsRoleLoading(true); + try { + const data = await rolesApi.getAll(); + if (isMounted) { + setRoles(data); + } + } catch (error) { + if (isMounted) { + const message = + error instanceof Error ? error.message : "Unable to load roles."; + setLoadError((prev) => prev || message); + } + } finally { + if (isMounted) { + setIsRoleLoading(false); + } + } + }; + + loadTenants(); + loadRoles(); + + return () => { + isMounted = false; + }; + }, [canReadAllTenants, isAuthLoading, user?.tenant_id, user?.tenant_name]); + + const isRoleAllowedForTenant = useCallback( + (role: Role, tenantId?: string, forceSuperAdmin?: boolean) => { + const isSuperAdminMode = forceSuperAdmin ?? isSuperAdminUser; + if (isSuperAdminMode) { + return !role.tenant_id; + } + if (canReadAllTenants) { + if (!tenantId) { + return false; + } + return role.tenant_id === tenantId; + } + if (tenantId) { + return !role.tenant_id || role.tenant_id === tenantId; + } + return !role.tenant_id; + }, + [canReadAllTenants, isSuperAdminUser] + ); + + const filteredRoles = useMemo( + () => + roles.filter((role) => + isRoleAllowedForTenant(role, formData.tenant_id) + ), + [isRoleAllowedForTenant, roles, formData.tenant_id] + ); + + const handleChange = ( + event: React.ChangeEvent + ) => { + const { name, value } = event.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handlePhoneChange = (value: string | undefined) => { + setFormData((prev) => ({ ...prev, phone_number: value || "" })); + }; + + const handleRoleChange = (event: React.ChangeEvent) => { + const { value } = event.target; + setFormData((prev) => ({ + ...prev, + role_id: value || "", + })); + }; + + const handleTenantChange = (event: React.ChangeEvent) => { + const tenantId = event.target.value; + setLastTenantId(tenantId); + setFormData((prev) => { + const roleStillValid = roles.some( + (role) => + role.id === prev.role_id && isRoleAllowedForTenant(role, tenantId) + ); + + return { + ...prev, + tenant_id: tenantId || "", + role_id: roleStillValid ? prev.role_id : "", + }; + }); + }; + + const handleSuperAdminToggle = ( + event: React.ChangeEvent + ) => { + const checked = event.target.checked; + setIsSuperAdminUser(checked); + if (checked) { + setLastTenantId(formData.tenant_id || ""); + } + setFormData((prev) => { + const nextTenantId = checked ? "" : lastTenantId || ""; + const roleStillValid = roles.some( + (role) => + role.id === prev.role_id && + isRoleAllowedForTenant(role, nextTenantId, checked) + ); + return { + ...prev, + tenant_id: nextTenantId, + role_id: roleStillValid ? prev.role_id : "", + }; + }); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setErrorMessage(""); + setIsLoading(true); + + try { + const statusValue = formData.status?.trim() || "active"; + const payload: UserCreateRequest = { + first_name: formData.first_name.trim(), + last_name: formData.last_name?.trim() || undefined, + email: formData.email.trim(), + password: formData.password, + phone_number: formData.phone_number?.trim() || undefined, + tenant_id: formData.tenant_id?.trim() || undefined, + role_id: formData.role_id?.trim() || undefined, + status: normalizeStatus(statusValue), + }; + + await usersApi.create(payload); + navigate("/users"); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to add user."; + setErrorMessage(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ +
+

{t('add')}

+

+ {t('subTitle')} +

+
+
+
+ +
+ {canReadAllTenants && ( +
+ + +
+ )} +
+ + + + + + {canReadAllTenants ? ( + isSuperAdminUser ? ( + + ) : ( + ({ + label: tenant.tenant_name, + value: tenant.id, + }))} + disabled={isTenantLoading} + /> + ) + ) : ( + + )} + ({ + label: role.role_name, + value: role.id, + }))} + disabled={isRoleLoading || (!isRoleLoading && filteredRoles.length === 0)} + /> + + +
+ + {(errorMessage || loadError) && ( +
+ {errorMessage || loadError} +
+ )} + +
+ + {t('actions.create')} + +
+
+
+ ); +}; + +export default AddUsers; diff --git a/src/application/users/components/AllUsers.tsx b/src/application/users/components/AllUsers.tsx new file mode 100644 index 0000000..c0e22c0 --- /dev/null +++ b/src/application/users/components/AllUsers.tsx @@ -0,0 +1,561 @@ +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, + CustomCheckBox, + CustomConfirmationModal, + CustomDropdown, + CustomInput, + CustomModal, + CustomTable, + CustomLoader, + CustomActionMenu, + CustomActionItem, + CustomStatus, +} from "../../../components/custom"; +import type { ColumnDef } from "../../../components/custom/CustomTable"; +import type { + User, + UserStatus, + UserUpdateRequest, + UserPaginatedResponse, +} from "../UserTypes"; +import { usersApi } from "../UserApi"; +import { rolesApi } from "../../roles/RolesApi"; +import type { Role } from "../../roles/RolesTypes"; +import { useAuth } from "../../../context/AuthContext"; +import { tenantsApi } from "../../tenants/TenantsApi"; +import type { Tenant } from "../../tenants/TenantsTypes"; +import { useDebounce } from "../../../components/hooks/useDebounce"; +import { ProtectedComponent } from "../../../components/auth/ProtectedComponent"; + + + +const formatName = (firstName: string, lastName?: string | null) => + [firstName, lastName].filter(Boolean).join(" "); + +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; + } +}; + + + +type EditFormState = { + first_name: string; + last_name: string; + email: string; + phone_number: string; + tenant_id: string; + role_id: string; + status: UserStatus; + password: string; +}; + +const emptyEditForm: EditFormState = { + first_name: "", + last_name: "", + email: "", + phone_number: "", + tenant_id: "", + role_id: "", + status: "active", + password: "", +}; + +const AllUsers = () => { + const { t, i18n } = useTranslation(['users', 'common']); + const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth(); + + // State + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + + + // Pagination & filters + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(null); + const [totalRows, setTotalRows] = useState(0); + const [, setTotalPages] = useState(0); + + const debouncedSearch = useDebounce(search, 500); + const searchInputRef = useRef(null); + const prevLoadingRef = useRef(isLoading); + + // Modals + const [selectedUser, setSelectedUser] = useState(null); + const [isViewOpen, setIsViewOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [editForm, setEditForm] = useState(emptyEditForm); + const [editError, setEditError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Supporting data + const [roles, setRoles] = useState([]); + const [tenants, setTenants] = useState([]); + const [currentTenant, setCurrentTenant] = useState(null); + const [isRoleLoading, setIsRoleLoading] = useState(false); + + useEffect(() => { + let isMounted = true; + + const loadSupportingData = async () => { + setIsRoleLoading(true); + try { + const rolesData = await rolesApi.getAll(); + if (isMounted) setRoles(rolesData); + + if (hasAccess("superadmin.tenant.read")) { + 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 roles/tenants:", err); + } finally { + if (isMounted) setIsRoleLoading(false); + } + }; + + const loadUsers = async () => { + setIsLoading(true); + setErrorMessage(""); + + try { + const response: UserPaginatedResponse = await usersApi.getPaginated({ + page, + page_size: pageSize, + search: debouncedSearch || undefined, + status: statusFilter, + }); + + if (isMounted) { + setUsers(response.items); + setTotalRows(response.total); + setTotalPages(response.total_pages); + } + } catch (err) { + if (isMounted) { + const msg = err instanceof Error ? err.message : "Unable to load users."; + setErrorMessage(msg); + } + } finally { + if (isMounted) setIsLoading(false); + } + }; + + if (!isAuthLoading) { + loadSupportingData(); + loadUsers(); + } + + return () => { + isMounted = false; + }; + }, [ + isAuthLoading, + hasAccess, + currentUser?.tenant_id, + page, + pageSize, + debouncedSearch, + statusFilter, + ]); + + // Reset page on search/filter change + useEffect(() => { + setPage(1); + }, [debouncedSearch, statusFilter]); + + // Restore search focus + 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; + return tenantId; + }, + [tenants, currentTenant] + ); + + const getRoleName = useCallback( + (roleId?: string | null) => { + if (!roleId) return ""; + return roles.find((r) => r.id === roleId)?.role_name || roleId; + }, + [roles] + ); + + const openView = useCallback((user: User) => { + setSelectedUser(user); + setIsViewOpen(true); + }, []); + + const openEdit = useCallback( + (user: User) => { + const resolvedTenantId = currentUser?.tenant_id ?? user.tenant_id ?? ""; + setSelectedUser(user); + setEditForm({ + first_name: user.first_name, + last_name: user.last_name ?? "", + email: user.email, + phone_number: user.phone_number ?? "", + tenant_id: resolvedTenantId, + role_id: user.role_id ?? "", + status: user.status, + password: "", + }); + setEditError(""); + setIsEditOpen(true); + }, + [currentUser?.tenant_id] + ); + + const openDelete = useCallback((user: User) => { + setSelectedUser(user); + setDeleteError(""); + setIsDeleteOpen(true); + }, []); + + const closeView = useCallback(() => { + setIsViewOpen(false); + setSelectedUser(null); + }, []); + + const closeEdit = useCallback(() => { + setIsEditOpen(false); + setSelectedUser(null); + setEditError(""); + }, []); + + const closeDelete = useCallback(() => { + setIsDeleteOpen(false); + setSelectedUser(null); + setDeleteError(""); + }, []); + + const handleEditChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.target; + setEditForm((prev) => ({ ...prev, [name]: value })); + }, + [] + ); + + const handleStatusChange = useCallback( + (e: React.ChangeEvent) => { + setEditForm((prev) => ({ + ...prev, + status: e.target.checked ? "active" : "inactive", + })); + }, + [] + ); + + const filteredRoles = useMemo( + () => + roles.filter((role) => { + if (editForm.tenant_id) { + return !role.tenant_id || role.tenant_id === editForm.tenant_id; + } + return true; + }), + [roles, editForm.tenant_id] + ); + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedUser) return; + + setIsSaving(true); + setEditError(""); + + try { + const payload: UserUpdateRequest = { + first_name: editForm.first_name.trim(), + last_name: editForm.last_name.trim() || null, + email: editForm.email.trim(), + phone_number: editForm.phone_number.trim() || null, + status: editForm.status, + role_id: editForm.role_id || null, + }; + + if (editForm.password.trim()) { + payload.password = editForm.password.trim(); + } + + const updated = await usersApi.update(selectedUser.id, payload); + setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u))); + closeEdit(); + } catch (err) { + setEditError(err instanceof Error ? err.message : "Failed to update user"); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (!selectedUser) return; + setIsDeleting(true); + try { + await usersApi.remove(selectedUser.id); + setUsers((prev) => prev.filter((u) => u.id !== selectedUser.id)); + closeDelete(); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Failed to delete user"); + } finally { + setIsDeleting(false); + } + }; + + const columns = useMemo>>( + () => [ + { + key: "first_name", + header: t('columns.name'), + render: (row) => formatName(row.first_name, row.last_name), + }, + { key: "email", header: t('columns.email') }, + { + key: "phone_number", + header: t('columns.phone'), + render: (row) => row.phone_number || "", + }, + { + key: "status", + header: t('columns.status'), + searchable: false, + render: (row) => , + }, + { + key: "tenant_id", + header: t('columns.tenant'), + render: (row) => getTenantName(row.tenant_id), + }, + { + key: "role_id", + header: t('columns.role'), + render: (row) => getRoleName(row.role_id), + }, + + { + key: "id", + header: t('columns.actions'), + searchable: false, + render: (row) => ( + + + openView(row)}> + {t('actions.view')} + + + + + openEdit(row)}> + {t('actions.edit')} + + + + + openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50"> + {t('actions.delete')} + + + + ), + }, + ], + [getTenantName, getRoleName, openView, openEdit, openDelete, t] + ); + + const filterControls = ( + + ); + + 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} + filterControls={filterControls} + /> + )} + + {/* View Modal */} + {t('actions.close')}} + > + {selectedUser ? ( +
+

{t('columns.name')}

{formatName(selectedUser.first_name, selectedUser.last_name)}

+

{t('columns.email')}

{selectedUser.email}

+

{t('columns.phone')}

{selectedUser.phone_number || "--"}

+

{t('columns.status')}

+

{t('columns.tenant')}

{getTenantName(selectedUser.tenant_id)}

+

{t('columns.role')}

{getRoleName(selectedUser.role_id)}

+
+

{t('columns.created')}

+

+ {formatDate(selectedUser.created_at, i18n.language)} +

+
+
+

{t('columns.updated')}

+

+ {formatDate(selectedUser.updated_at, i18n.language)} +

+
+ +
+ ) : ( +

{t('common:common.noData')}

+ )} +
+ + {/* Edit Modal */} + + {t('actions.cancel')} + {t('actions.update')} + + } + > +
+
+ + + + + + ({ label: r.role_name, value: r.id }))} + placeholder={isRoleLoading ? t('fields.roleLoading') : t('fields.rolePlaceholder')} + disabled={isRoleLoading || filteredRoles.length === 0} + /> + +
+ +
+
+ {editError &&
{editError}
} +
+
+ + {/* Delete Modal */} + +
+ ); +}; + +export default AllUsers; \ No newline at end of file diff --git a/src/application/users/index.tsx b/src/application/users/index.tsx new file mode 100644 index 0000000..067e492 --- /dev/null +++ b/src/application/users/index.tsx @@ -0,0 +1,14 @@ +import { Route, Routes } from "react-router-dom"; +import AllUsers from "./components/AllUsers"; +import AddUsers from "./components/AddUsers"; + +const Users: React.FC = () => { + return ( + + } /> + } /> + + ); +}; + +export default Users; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index a212ccb..af32692 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -10,6 +10,7 @@ import Tenants from "../application/tenants"; import ProtectedRoutes from "./ProtectedRoutes"; import Theme from "../application/theme"; import SettingsPage from "../application/settings/SettingsPage"; +import Users from "../application/users"; const AppRoutes = () => { return ( @@ -26,6 +27,7 @@ const AppRoutes = () => { } /> } /> } /> + } /> } /> diff --git a/vite.config.ts b/vite.config.ts index ff004d6..37fadb7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,13 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react-swc' import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ -export default defineConfig({ - plugins: [react(),tailwindcss()], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + console.log(`\n 🔌 Backend API URL: \x1b[36m${env.VITE_API_BASE_URL}\x1b[0m\n`); + + return { + plugins: [react(), tailwindcss()], + } })