users added
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Development environment
|
||||
VITE_API_BASE_URL=https://dev-api.yourdomain.com
|
||||
@@ -0,0 +1,2 @@
|
||||
# Local environment
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
@@ -0,0 +1,2 @@
|
||||
# Production environment
|
||||
VITE_API_BASE_URL=https://api.yourdomain.com
|
||||
Generated
+96
-17
@@ -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"
|
||||
}
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
@@ -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, any>): 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<UserApiResponse[]>("/api/user/get");
|
||||
return data.map(toUser);
|
||||
},
|
||||
|
||||
getById: async (userId: string) => {
|
||||
const data = await apiClient.get<UserApiResponse>(`/api/user/get/${userId}`);
|
||||
return toUser(data);
|
||||
},
|
||||
|
||||
create: async (payload: UserCreateRequest) => {
|
||||
const data = await apiClient.post<UserApiResponse>(
|
||||
"/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<UserApiResponse>(
|
||||
`/api/user/update/${userId}`,
|
||||
toUpdatePayload(payload),
|
||||
{ successMessage: "User updated successfully", errorMessage: "Failed to update user" }
|
||||
);
|
||||
return toUser(data);
|
||||
},
|
||||
|
||||
remove: async (userId: string): Promise<ApiMessage> =>
|
||||
apiClient.delete<ApiMessage>(`/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<UserPaginatedResponse>(`/api/user/list${queryString}`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<UserCreateRequest>({
|
||||
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<Tenant[]>([]);
|
||||
const [currentTenantName, setCurrentTenantName] = useState("");
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
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<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
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<HTMLSelectElement>) => {
|
||||
const { value } = event.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
role_id: value || "",
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTenantChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
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<HTMLInputElement>
|
||||
) => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomBackButton to="/users" tooltip={t('backToUsers')} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-(--text-primary)">{t('add')}</h1>
|
||||
<p className="text-sm text-(--text-secondary)">
|
||||
{t('subTitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-lg border border-(--card-border) bg-(--card-bg) p-6 shadow-sm"
|
||||
>
|
||||
{canReadAllTenants && (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<CustomCheckBox
|
||||
label={t('fields.superAdminUser')}
|
||||
checked={isSuperAdminUser}
|
||||
onChange={handleSuperAdminToggle}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput
|
||||
label={t('fields.firstName')}
|
||||
name="first_name"
|
||||
placeholder={t('fields.firstNamePlaceholder')}
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('fields.lastName')}
|
||||
name="last_name"
|
||||
placeholder={t('fields.lastNamePlaceholder')}
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('fields.email')}
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t('fields.emailPlaceholder')}
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<CustomPhoneInput
|
||||
label={t('fields.phone')}
|
||||
name="phone_number"
|
||||
placeholder={t('fields.phonePlaceholder')}
|
||||
value={formData.phone_number}
|
||||
onChange={handlePhoneChange}
|
||||
required
|
||||
defaultCountry="IN"
|
||||
/>
|
||||
{canReadAllTenants ? (
|
||||
isSuperAdminUser ? (
|
||||
<CustomInput
|
||||
label={t('fields.tenant')}
|
||||
name="tenant_name"
|
||||
value={t('fields.superAdminNoTenant')}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<CustomDropdown
|
||||
label={t('fields.tenant')}
|
||||
name="tenant_id"
|
||||
placeholder={
|
||||
isTenantLoading ? t('fields.tenantLoading') : t('fields.tenantPlaceholder')
|
||||
}
|
||||
value={formData.tenant_id ?? ""}
|
||||
onChange={handleTenantChange}
|
||||
options={tenants.map((tenant) => ({
|
||||
label: tenant.tenant_name,
|
||||
value: tenant.id,
|
||||
}))}
|
||||
disabled={isTenantLoading}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<CustomInput
|
||||
label={t('fields.tenant')}
|
||||
name="tenant_name"
|
||||
value={currentTenantName || user?.tenant_name || user?.tenant_id || ""}
|
||||
placeholder={
|
||||
isTenantLoading ? t('fields.tenantLoading') : t('fields.assignedTenant')
|
||||
}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
<CustomDropdown
|
||||
label={t('fields.role')}
|
||||
name="role_id"
|
||||
placeholder={
|
||||
isSuperAdminUser
|
||||
? isRoleLoading
|
||||
? t('fields.roleLoading')
|
||||
: t('fields.roleSuperAdminPlaceholder')
|
||||
: formData.tenant_id
|
||||
? isRoleLoading
|
||||
? t('fields.roleLoading')
|
||||
: t('fields.rolePlaceholder')
|
||||
: t('fields.roleFilterPlaceholder')
|
||||
}
|
||||
value={formData.role_id ?? ""}
|
||||
onChange={handleRoleChange}
|
||||
options={filteredRoles.map((role) => ({
|
||||
label: role.role_name,
|
||||
value: role.id,
|
||||
}))}
|
||||
disabled={isRoleLoading || (!isRoleLoading && filteredRoles.length === 0)}
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('fields.password')}
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t('fields.passwordPlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{(errorMessage || loadError) && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 dark:bg-red-900/20 px-4 py-2 text-sm text-red-600 dark:text-red-400">
|
||||
{errorMessage || loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<CustomButton type="submit" variant="primary" loading={isLoading}>
|
||||
{t('actions.create')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddUsers;
|
||||
@@ -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<User[]>([]);
|
||||
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<UserStatus | null>(null);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [, setTotalPages] = useState(0);
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
|
||||
// Modals
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<EditFormState>(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<Role[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(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<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setEditForm((prev) => ({ ...prev, [name]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<Array<ColumnDef<User>>>(
|
||||
() => [
|
||||
{
|
||||
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) => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<CustomActionMenu>
|
||||
<ProtectedComponent requiredAccess="admin.user.read">
|
||||
<CustomActionItem onClick={() => openView(row)}>
|
||||
<Eye size={16} className="mr-2" /> {t('actions.view')}
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="admin.user.update">
|
||||
<CustomActionItem onClick={() => openEdit(row)}>
|
||||
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="admin.user.delete">
|
||||
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
||||
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[getTenantName, getRoleName, openView, openEdit, openDelete, t]
|
||||
);
|
||||
|
||||
const filterControls = (
|
||||
<select
|
||||
value={statusFilter === null ? "all" : statusFilter}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as "all" | "active" | "inactive";
|
||||
setStatusFilter(val === "all" ? null : val);
|
||||
}}
|
||||
className="rounded-md border border-(--card-border) bg-(--card-bg) text-(--text-primary) text-sm py-1.5 px-2 focus:outline-none focus:ring focus:ring-blue-600"
|
||||
>
|
||||
<option value="all" className="bg-(--card-bg) text-(--text-primary)">
|
||||
{t('common:common.allStatuses', { defaultValue: 'All Statuses' })}
|
||||
</option>
|
||||
<option value="active" className="bg-(--card-bg) text-(--text-primary)">
|
||||
{t('common:status.active')}
|
||||
</option>
|
||||
<option value="inactive" className="bg-(--card-bg) text-(--text-primary)">
|
||||
{t('common:status.inactive')}
|
||||
</option>
|
||||
</select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="admin.user.create">
|
||||
<Link to="/users/add">
|
||||
<CustomButton variant="primary">+ {t('add')}</CustomButton>
|
||||
</Link>
|
||||
</ProtectedComponent>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<CustomTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
totalRows={totalRows}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
filterControls={filterControls}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Modal */}
|
||||
<CustomModal
|
||||
isOpen={isViewOpen}
|
||||
onClose={closeView}
|
||||
title={t('details')}
|
||||
size="lg"
|
||||
footer={<CustomButton variant="outlined" onClick={closeView}>{t('actions.close')}</CustomButton>}
|
||||
>
|
||||
{selectedUser ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.name')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{formatName(selectedUser.first_name, selectedUser.last_name)}</p></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.email')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{selectedUser.email}</p></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.phone')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{selectedUser.phone_number || "--"}</p></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.status')}</p><CustomStatus status={selectedUser.status} /></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.tenant')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{getTenantName(selectedUser.tenant_id)}</p></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.role')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{getRoleName(selectedUser.role_id)}</p></div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.created')}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(selectedUser.created_at, i18n.language)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.updated')}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(selectedUser.updated_at, i18n.language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('common:common.noData')}</p>
|
||||
)}
|
||||
</CustomModal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<CustomModal
|
||||
isOpen={isEditOpen}
|
||||
onClose={closeEdit}
|
||||
title={t('edit')}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<CustomButton variant="outlined" onClick={closeEdit} disabled={isSaving}>{t('actions.cancel')}</CustomButton>
|
||||
<CustomButton type="submit" form="edit-user-form" variant="primary" loading={isSaving}>{t('actions.update')}</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-user-form" onSubmit={handleUpdate} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput label={t('fields.firstName')} name="first_name" value={editForm.first_name} onChange={handleEditChange} required />
|
||||
<CustomInput label={t('fields.lastName')} name="last_name" value={editForm.last_name} onChange={handleEditChange} />
|
||||
<CustomInput label={t('fields.email')} name="email" type="email" value={editForm.email} onChange={handleEditChange} required />
|
||||
<CustomInput label={t('fields.phone')} name="phone_number" type="tel" maxLength={10} phonePrefix="+91" value={editForm.phone_number} onChange={handleEditChange} />
|
||||
<CustomInput label={t('fields.tenant')} value={getTenantName(currentUser?.tenant_id ?? editForm.tenant_id) || "--"} disabled readOnly />
|
||||
<CustomDropdown
|
||||
label={t('fields.role')}
|
||||
name="role_id"
|
||||
value={editForm.role_id}
|
||||
onChange={handleEditChange}
|
||||
options={filteredRoles.map((r) => ({ label: r.role_name, value: r.id }))}
|
||||
placeholder={isRoleLoading ? t('fields.roleLoading') : t('fields.rolePlaceholder')}
|
||||
disabled={isRoleLoading || filteredRoles.length === 0}
|
||||
/>
|
||||
<CustomInput label={t('fields.password')} name="password" type="password" placeholder={t('fields.passwordEditPlaceholder')} value={editForm.password} onChange={handleEditChange} />
|
||||
<div className="flex items-center">
|
||||
<CustomCheckBox label={t('fields.active')} checked={editForm.status === "active"} onChange={handleStatusChange} />
|
||||
</div>
|
||||
</div>
|
||||
{editError && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">{editError}</div>}
|
||||
</form>
|
||||
</CustomModal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={closeDelete}
|
||||
onConfirm={handleDelete}
|
||||
title={t('messages.deleteTitle')}
|
||||
description={deleteError || t('messages.deleteConfirm')}
|
||||
confirmText={t('actions.delete')}
|
||||
variant="danger"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllUsers;
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route index element={<AllUsers />} />
|
||||
<Route path="add" element={<AddUsers />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
@@ -26,6 +27,7 @@ const AppRoutes = () => {
|
||||
<Route path="/roles/*" element={<Roles />} />
|
||||
<Route path="/theme/*" element={<Theme />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/users/*" element={<Users />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
+7
-2
@@ -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({
|
||||
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()],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user