feat: Implement Department and Designation management for tenants and superadmins, including UI, services, and user association.
This commit is contained in:
parent
e17af04b46
commit
b1ac65b345
117
SETUP.md
Normal file
117
SETUP.md
Normal file
@ -0,0 +1,117 @@
|
||||
# 🛠️ Project Setup Guide
|
||||
|
||||
Welcome to the **QAssure.ai Management Console** setup guide. This document provides step-by-step instructions to get the project running on your local machine.
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
- **Git**: [Download Git](https://git-scm.com/downloads)
|
||||
- **Node.js**: version 18.x or higher (LTS recommended) [Download Node.js](https://nodejs.org/)
|
||||
- **npm**: version 9.x or higher (comes with Node.js)
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Windows Setup
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
If you don't have Node.js installed, download the `.msi` installer from the official website and follow the installation wizard.
|
||||
|
||||
### 2. Configure Git (Optional but Recommended)
|
||||
|
||||
Open **Git Bash** or **PowerShell** and run:
|
||||
|
||||
```bash
|
||||
git config --global core.autocrlf true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐧 Ubuntu / Linux Setup
|
||||
|
||||
### 1. Install Node.js & npm
|
||||
|
||||
We recommend using **nvm** (Node Version Manager) to install Node.js:
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
source ~/.bashrc
|
||||
nvm install 18
|
||||
nvm use 18
|
||||
```
|
||||
|
||||
### 2. Configure Git
|
||||
|
||||
```bash
|
||||
git config --global core.autocrlf input
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Common Implementation Steps (All OS)
|
||||
|
||||
Once the prerequisites are met, follow these steps to set up the project:
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
Open your terminal (Command Prompt, PowerShell, or Bash) and run:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-repo/qassure-frontend.git
|
||||
cd qassure-frontend
|
||||
```
|
||||
|
||||
_(Replace the URL with the actual GitHub repository link)_
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
|
||||
Create a `.env` file from the example template:
|
||||
|
||||
**Windows (PowerShell) / Ubuntu / Mac:**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
|
||||
```cmd
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
Then, open the `.env` file and configure the variables:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||
VITE_FRONTEND_BASE_URL=http://localhost:5173
|
||||
```
|
||||
|
||||
### 4. Start Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be available at [http://localhost:5173](http://localhost:5173).
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| :-------------------------- | :----------------------------------------------------------------------------------------------------------------- |
|
||||
| `npm install` fails | Try deleting `node_modules` and `package-lock.json`, then run `npm install` again. |
|
||||
| Port 5173 is already in use | Vite will automatically try the next available port (e.g., 5174). Look at the terminal output for the correct URL. |
|
||||
| API Connection Errors | Ensure the `VITE_API_BASE_URL` in your `.env` matches the running backend service. |
|
||||
| Node Version Conflict | Ensure you are using Node 18+. Use `node -v` to check your version. |
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ by the QAssure Team**
|
||||
@ -8,7 +8,8 @@ import {
|
||||
Settings,
|
||||
HelpCircle,
|
||||
X,
|
||||
Shield
|
||||
Shield,
|
||||
BadgeCheck
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
@ -49,6 +50,8 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' },
|
||||
{ icon: Shield, label: 'Roles', path: '/tenant/roles', requiredPermission: { resource: 'roles' } },
|
||||
{ icon: Users, label: 'Users', path: '/tenant/users', requiredPermission: { resource: 'users' } },
|
||||
{ icon: Building2, label: 'Departments', path: '/tenant/departments', requiredPermission: { resource: 'departments' } },
|
||||
{ icon: BadgeCheck, label: 'Designations', path: '/tenant/designations', requiredPermission: { resource: 'designations' } },
|
||||
{ icon: Package, label: 'Modules', path: '/tenant/modules' },
|
||||
];
|
||||
|
||||
|
||||
482
src/components/shared/DepartmentModals.tsx
Normal file
482
src/components/shared/DepartmentModals.tsx
Normal file
@ -0,0 +1,482 @@
|
||||
import { useEffect, type ReactElement, useState } from "react";
|
||||
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
FormSelect,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
PaginatedSelect,
|
||||
} from "@/components/shared";
|
||||
import type {
|
||||
Department,
|
||||
CreateDepartmentRequest,
|
||||
UpdateDepartmentRequest,
|
||||
} from "@/types/department";
|
||||
import { departmentService } from "@/services/department-service";
|
||||
|
||||
// Validation schema
|
||||
const departmentSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
code: z.string().min(1, "Code is required"),
|
||||
description: z.string().optional(),
|
||||
is_active: z.boolean(),
|
||||
parent_id: z.string().nullable().optional(),
|
||||
sort_order: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
type DepartmentFormData = z.infer<typeof departmentSchema>;
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "true", label: "Active" },
|
||||
{ value: "false", label: "Inactive" },
|
||||
];
|
||||
|
||||
interface NewDepartmentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateDepartmentRequest) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
|
||||
export const NewDepartmentModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
tenantId,
|
||||
}: NewDepartmentModalProps): ReactElement | null => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<DepartmentFormData>({
|
||||
resolver: zodResolver(departmentSchema),
|
||||
defaultValues: {
|
||||
is_active: true,
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const statusValue = watch("is_active");
|
||||
const parentIdValue = watch("parent_id");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset();
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, reset, clearErrors]);
|
||||
|
||||
const loadDepartments = async () => {
|
||||
const response = await departmentService.list(tenantId, {
|
||||
active_only: true,
|
||||
});
|
||||
return {
|
||||
options: response.data.map((dept) => ({
|
||||
value: dept.id,
|
||||
label: dept.name,
|
||||
})),
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: response.data.length,
|
||||
total: response.data.length,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleFormSubmit: SubmitHandler<DepartmentFormData> = async (data) => {
|
||||
await onSubmit(data as CreateDepartmentRequest);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create New Department"
|
||||
description="Add a new department to the system"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Department"}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(handleFormSubmit)}
|
||||
className="p-5 flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Department Name"
|
||||
required
|
||||
placeholder="e.g. Engineering"
|
||||
error={errors.name?.message}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FormField
|
||||
label="Department Code"
|
||||
required
|
||||
placeholder="e.g. ENG"
|
||||
error={errors.code?.message}
|
||||
{...register("code")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Description"
|
||||
placeholder="Enter department description"
|
||||
error={errors.description?.message}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<PaginatedSelect
|
||||
label="Parent Department"
|
||||
placeholder="Select parent (optional)"
|
||||
value={parentIdValue || ""}
|
||||
onValueChange={(value) => setValue("parent_id", value || null)}
|
||||
onLoadOptions={loadDepartments}
|
||||
error={errors.parent_id?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Sort Order"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
error={errors.sort_order?.message}
|
||||
{...register("sort_order", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
options={statusOptions}
|
||||
value={String(statusValue)}
|
||||
onValueChange={(value) => setValue("is_active", value === "true")}
|
||||
error={errors.is_active?.message}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface EditDepartmentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (id: string, data: UpdateDepartmentRequest) => Promise<void>;
|
||||
department: Department | null;
|
||||
isLoading?: boolean;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
|
||||
export const EditDepartmentModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
department,
|
||||
isLoading = false,
|
||||
tenantId,
|
||||
}: EditDepartmentModalProps): ReactElement | null => {
|
||||
const [initialParentOption, setInitialParentOption] = useState<{
|
||||
value: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<DepartmentFormData>({
|
||||
resolver: zodResolver(departmentSchema),
|
||||
});
|
||||
|
||||
const statusValue = watch("is_active");
|
||||
const parentIdValue = watch("parent_id");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && department) {
|
||||
reset({
|
||||
name: department.name,
|
||||
code: department.code,
|
||||
description: department.description || "",
|
||||
is_active: department.is_active,
|
||||
parent_id: department.parent_id,
|
||||
sort_order: department.sort_order || 0,
|
||||
});
|
||||
|
||||
if (department.parent_id && department.parent_name) {
|
||||
setInitialParentOption({
|
||||
value: department.parent_id,
|
||||
label: department.parent_name,
|
||||
});
|
||||
} else {
|
||||
setInitialParentOption(null);
|
||||
}
|
||||
} else if (!isOpen) {
|
||||
reset();
|
||||
clearErrors();
|
||||
setInitialParentOption(null);
|
||||
}
|
||||
}, [isOpen, department, reset, clearErrors]);
|
||||
|
||||
const loadDepartments = async () => {
|
||||
const response = await departmentService.list(tenantId, {
|
||||
active_only: true,
|
||||
});
|
||||
// Filter out current department to prevent self-referencing
|
||||
let options = response.data
|
||||
.filter((d) => d.id !== department?.id)
|
||||
.map((dept) => ({
|
||||
value: dept.id,
|
||||
label: dept.name,
|
||||
}));
|
||||
|
||||
// Ensure initial parent is in options if not already there
|
||||
if (initialParentOption) {
|
||||
if (!options.find((o) => o.value === initialParentOption.value)) {
|
||||
options = [initialParentOption, ...options];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: options.length,
|
||||
total: options.length,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleFormSubmit: SubmitHandler<DepartmentFormData> = async (data) => {
|
||||
if (department) {
|
||||
await onSubmit(department.id, data as UpdateDepartmentRequest);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Edit Department"
|
||||
description="Update department details"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Updating..." : "Update Department"}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(handleFormSubmit)}
|
||||
className="p-5 flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Department Name"
|
||||
required
|
||||
placeholder="e.g. Engineering"
|
||||
error={errors.name?.message}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FormField
|
||||
label="Department Code"
|
||||
required
|
||||
placeholder="e.g. ENG"
|
||||
error={errors.code?.message}
|
||||
{...register("code")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Description"
|
||||
placeholder="Enter department description"
|
||||
error={errors.description?.message}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<PaginatedSelect
|
||||
label="Parent Department"
|
||||
placeholder="Select parent (optional)"
|
||||
value={parentIdValue || ""}
|
||||
onValueChange={(value) => setValue("parent_id", value || null)}
|
||||
onLoadOptions={loadDepartments}
|
||||
error={errors.parent_id?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Sort Order"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
error={errors.sort_order?.message}
|
||||
{...register("sort_order", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
options={statusOptions}
|
||||
value={String(statusValue)}
|
||||
onValueChange={(value) => setValue("is_active", value === "true")}
|
||||
error={errors.is_active?.message}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ViewDepartmentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
department: Department | null;
|
||||
}
|
||||
|
||||
export const ViewDepartmentModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
department,
|
||||
}: ViewDepartmentModalProps): ReactElement | null => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Department Details"
|
||||
description="View full information about this department"
|
||||
maxWidth="md"
|
||||
footer={<SecondaryButton onClick={onClose}>Close</SecondaryButton>}
|
||||
>
|
||||
{department && (
|
||||
<div className="p-5 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Name
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">{department.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Code
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">{department.code}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Status
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1 capitalize">
|
||||
{department.is_active ? "Active" : "Inactive"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||
Hierarchy & Stats
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Parent Department
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{department.parent_name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Level
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{department.level}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Sort Order
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{department.sort_order}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Sub-departments
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{department.child_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Users
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{department.user_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1 bg-[#f5f7fa] p-3 rounded-md border border-[rgba(0,0,0,0.05)]">
|
||||
{department.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 md:col-span-2 text-xs text-[#9aa6b2]">
|
||||
<span>
|
||||
Created: {new Date(department.created_at).toLocaleString()}
|
||||
</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>
|
||||
Updated: {new Date(department.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
395
src/components/shared/DesignationModals.tsx
Normal file
395
src/components/shared/DesignationModals.tsx
Normal file
@ -0,0 +1,395 @@
|
||||
import { useEffect, type ReactElement } from "react";
|
||||
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
FormSelect,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from "@/components/shared";
|
||||
import type {
|
||||
Designation,
|
||||
CreateDesignationRequest,
|
||||
UpdateDesignationRequest,
|
||||
} from "@/types/designation";
|
||||
|
||||
// Validation schema
|
||||
const designationSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
code: z.string().min(1, "Code is required"),
|
||||
description: z.string().optional(),
|
||||
is_active: z.boolean(),
|
||||
level: z.number().int().min(0).optional(),
|
||||
sort_order: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
type DesignationFormData = z.infer<typeof designationSchema>;
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "true", label: "Active" },
|
||||
{ value: "false", label: "Inactive" },
|
||||
];
|
||||
|
||||
interface NewDesignationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateDesignationRequest) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const NewDesignationModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: NewDesignationModalProps): ReactElement | null => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<DesignationFormData>({
|
||||
resolver: zodResolver(designationSchema),
|
||||
defaultValues: {
|
||||
is_active: true,
|
||||
level: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const statusValue = watch("is_active");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset();
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, reset, clearErrors]);
|
||||
|
||||
const handleFormSubmit: SubmitHandler<DesignationFormData> = async (data) => {
|
||||
await onSubmit(data as CreateDesignationRequest);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create New Designation"
|
||||
description="Add a new designation to the system"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Designation"}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(handleFormSubmit)}
|
||||
className="p-5 flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Designation Name"
|
||||
required
|
||||
placeholder="e.g. Software Engineer"
|
||||
error={errors.name?.message}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FormField
|
||||
label="Designation Code"
|
||||
required
|
||||
placeholder="e.g. SWE"
|
||||
error={errors.code?.message}
|
||||
{...register("code")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Description"
|
||||
placeholder="Enter designation description"
|
||||
error={errors.description?.message}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Hierarchy Level"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
error={errors.level?.message}
|
||||
{...register("level", { valueAsNumber: true })}
|
||||
/>
|
||||
<FormField
|
||||
label="Sort Order"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
error={errors.sort_order?.message}
|
||||
{...register("sort_order", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
options={statusOptions}
|
||||
value={String(statusValue)}
|
||||
onValueChange={(value) => setValue("is_active", value === "true")}
|
||||
error={errors.is_active?.message}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface EditDesignationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (id: string, data: UpdateDesignationRequest) => Promise<void>;
|
||||
designation: Designation | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const EditDesignationModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
designation,
|
||||
isLoading = false,
|
||||
}: EditDesignationModalProps): ReactElement | null => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<DesignationFormData>({
|
||||
resolver: zodResolver(designationSchema),
|
||||
});
|
||||
|
||||
const statusValue = watch("is_active");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && designation) {
|
||||
reset({
|
||||
name: designation.name,
|
||||
code: designation.code,
|
||||
description: designation.description || "",
|
||||
is_active: designation.is_active,
|
||||
level: designation.level || 0,
|
||||
sort_order: designation.sort_order || 0,
|
||||
});
|
||||
} else if (!isOpen) {
|
||||
reset();
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, designation, reset, clearErrors]);
|
||||
|
||||
const handleFormSubmit: SubmitHandler<DesignationFormData> = async (data) => {
|
||||
if (designation) {
|
||||
await onSubmit(designation.id, data as UpdateDesignationRequest);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Edit Designation"
|
||||
description="Update designation details"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Updating..." : "Update Designation"}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(handleFormSubmit)}
|
||||
className="p-5 flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Designation Name"
|
||||
required
|
||||
placeholder="e.g. Software Engineer"
|
||||
error={errors.name?.message}
|
||||
{...register("name")}
|
||||
/>
|
||||
<FormField
|
||||
label="Designation Code"
|
||||
required
|
||||
placeholder="e.g. SWE"
|
||||
error={errors.code?.message}
|
||||
{...register("code")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Description"
|
||||
placeholder="Enter designation description"
|
||||
error={errors.description?.message}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Hierarchy Level"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
error={errors.level?.message}
|
||||
{...register("level", { valueAsNumber: true })}
|
||||
/>
|
||||
<FormField
|
||||
label="Sort Order"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
error={errors.sort_order?.message}
|
||||
{...register("sort_order", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
options={statusOptions}
|
||||
value={String(statusValue)}
|
||||
onValueChange={(value) => setValue("is_active", value === "true")}
|
||||
error={errors.is_active?.message}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ViewDesignationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
designation: Designation | null;
|
||||
}
|
||||
|
||||
export const ViewDesignationModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
designation,
|
||||
}: ViewDesignationModalProps): ReactElement | null => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Designation Details"
|
||||
description="View full information about this designation"
|
||||
maxWidth="md"
|
||||
footer={<SecondaryButton onClick={onClose}>Close</SecondaryButton>}
|
||||
>
|
||||
{designation && (
|
||||
<div className="p-5 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Name
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{designation.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Code
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{designation.code}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Status
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1 capitalize">
|
||||
{designation.is_active ? "Active" : "Inactive"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||
Hierarchy & Stats
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Hierarchy Level
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{designation.level}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Sort Order
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{designation.sort_order}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Assigned Users
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1">
|
||||
{designation.user_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<p className="text-xs font-medium text-[#9aa6b2] uppercase">
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm text-[#0f1724] mt-1 bg-[#f5f7fa] p-3 rounded-md border border-[rgba(0,0,0,0.05)]">
|
||||
{designation.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 md:col-span-2 text-xs text-[#9aa6b2]">
|
||||
<span>
|
||||
Created: {new Date(designation.created_at).toLocaleString()}
|
||||
</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>
|
||||
Updated: {new Date(designation.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
@ -11,21 +11,24 @@ import {
|
||||
PaginatedSelect,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/components/shared';
|
||||
// import { tenantService } from '@/services/tenant-service';
|
||||
import { roleService } from '@/services/role-service';
|
||||
import type { User } from '@/types/user';
|
||||
} from "@/components/shared";
|
||||
import { roleService } from "@/services/role-service";
|
||||
import { departmentService } from "@/services/department-service";
|
||||
import { designationService } from "@/services/designation-service";
|
||||
import type { User } from "@/types/user";
|
||||
|
||||
// Validation schema
|
||||
const editUserSchema = z.object({
|
||||
email: z.email({ message: 'Please enter a valid email address' }),
|
||||
first_name: z.string().min(1, 'First name is required'),
|
||||
last_name: z.string().min(1, 'Last name is required'),
|
||||
status: z.enum(['active', 'suspended', 'deleted'], {
|
||||
message: 'Status is required',
|
||||
email: z.email({ message: "Please enter a valid email address" }),
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
status: z.enum(["active", "suspended", "deleted"], {
|
||||
message: "Status is required",
|
||||
}),
|
||||
tenant_id: z.string().min(1, 'Tenant is required'),
|
||||
role_id: z.string().min(1, 'Role is required'),
|
||||
tenant_id: z.string().min(1, "Tenant is required"),
|
||||
role_id: z.string().min(1, "Role is required"),
|
||||
department_id: z.string().optional(),
|
||||
designation_id: z.string().optional(),
|
||||
});
|
||||
|
||||
type EditUserFormData = z.infer<typeof editUserSchema>;
|
||||
@ -41,9 +44,9 @@ interface EditUserModalProps {
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "suspended", label: "Suspended" },
|
||||
{ value: "deleted", label: "Deleted" },
|
||||
];
|
||||
|
||||
export const EditUserModal = ({
|
||||
@ -58,8 +61,6 @@ export const EditUserModal = ({
|
||||
const [isLoadingUser, setIsLoadingUser] = useState<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const loadedUserIdRef = useRef<string | null>(null);
|
||||
// const [selectedTenantId, setSelectedTenantId] = useState<string>('');
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>('');
|
||||
|
||||
const {
|
||||
register,
|
||||
@ -74,79 +75,28 @@ export const EditUserModal = ({
|
||||
resolver: zodResolver(editUserSchema),
|
||||
});
|
||||
|
||||
const statusValue = watch('status');
|
||||
// const tenantIdValue = watch('tenant_id');
|
||||
const roleIdValue = watch('role_id');
|
||||
const statusValue = watch("status");
|
||||
const roleIdValue = watch("role_id");
|
||||
const departmentIdValue = watch("department_id");
|
||||
const designationIdValue = watch("designation_id");
|
||||
|
||||
// Store tenant and role names from user response
|
||||
// const [currentTenantName, setCurrentTenantName] = useState<string>('');
|
||||
const [currentRoleName, setCurrentRoleName] = useState<string>('');
|
||||
// Store initial options for immediate display
|
||||
// const [initialTenantOption, setInitialTenantOption] = useState<{ value: string; label: string } | null>(null);
|
||||
const [initialRoleOption, setInitialRoleOption] = useState<{ value: string; label: string } | null>(null);
|
||||
const [currentRoleName, setCurrentRoleName] = useState<string>("");
|
||||
|
||||
console.log('roleIdValue', roleIdValue);
|
||||
console.log('initialRoleOption', initialRoleOption);
|
||||
|
||||
// Load tenants for dropdown - ensure selected tenant is included
|
||||
// const loadTenants = async (page: number, limit: number) => {
|
||||
// const response = await tenantService.getAll(page, limit);
|
||||
// let options = response.data.map((tenant) => ({
|
||||
// value: tenant.id,
|
||||
// label: tenant.name,
|
||||
// }));
|
||||
|
||||
// // Always include initial option if it exists and matches the selected value
|
||||
// if (initialTenantOption && page === 1) {
|
||||
// const exists = options.find((opt) => opt.value === initialTenantOption.value);
|
||||
// if (!exists) {
|
||||
// options = [initialTenantOption, ...options];
|
||||
// }
|
||||
// }
|
||||
|
||||
// // If we have a selected tenant ID and it's not in the current options, add it with stored name
|
||||
// if (selectedTenantId && page === 1 && !initialTenantOption) {
|
||||
// const existingOption = options.find((opt) => opt.value === selectedTenantId);
|
||||
// if (!existingOption) {
|
||||
// // If we have the name from user response, use it; otherwise fetch
|
||||
// if (currentTenantName) {
|
||||
// options = [
|
||||
// {
|
||||
// value: selectedTenantId,
|
||||
// label: currentTenantName,
|
||||
// },
|
||||
// ...options,
|
||||
// ];
|
||||
// } else {
|
||||
// try {
|
||||
// const tenantResponse = await tenantService.getById(selectedTenantId);
|
||||
// if (tenantResponse.success) {
|
||||
// // Prepend the selected tenant to the options
|
||||
// options = [
|
||||
// {
|
||||
// value: tenantResponse.data.id,
|
||||
// label: tenantResponse.data.name,
|
||||
// },
|
||||
// ...options,
|
||||
// ];
|
||||
// }
|
||||
// } catch (err) {
|
||||
// // If fetching fails, just continue with existing options
|
||||
// console.warn('Failed to fetch selected tenant:', err);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return {
|
||||
// options,
|
||||
// pagination: response.pagination,
|
||||
// };
|
||||
// };
|
||||
const [initialRoleOption, setInitialRoleOption] = useState<{
|
||||
value: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
const [initialDepartmentOption, setInitialDepartmentOption] = useState<{
|
||||
value: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
const [initialDesignationOption, setInitialDesignationOption] = useState<{
|
||||
value: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
|
||||
// Load roles for dropdown - ensure selected role is included
|
||||
const loadRoles = async (page: number, limit: number) => {
|
||||
// If defaultTenantId is provided, filter roles by tenant_id
|
||||
const response = defaultTenantId
|
||||
? await roleService.getByTenant(defaultTenantId, page, limit)
|
||||
: await roleService.getAll(page, limit);
|
||||
@ -155,58 +105,84 @@ export const EditUserModal = ({
|
||||
label: role.name,
|
||||
}));
|
||||
|
||||
// Always include initial option if it exists and matches the selected value
|
||||
if (initialRoleOption && page === 1) {
|
||||
const exists = options.find((opt) => opt.value === initialRoleOption.value);
|
||||
const exists = options.find(
|
||||
(opt) => opt.value === initialRoleOption.value,
|
||||
);
|
||||
if (!exists) {
|
||||
options = [initialRoleOption, ...options];
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a selected role ID and it's not in the current options, add it with stored name
|
||||
if (selectedRoleId && page === 1 && !initialRoleOption) {
|
||||
const existingOption = options.find((opt) => opt.value === selectedRoleId);
|
||||
if (!existingOption) {
|
||||
// If we have the name from user response, use it; otherwise fetch
|
||||
if (currentRoleName) {
|
||||
options = [
|
||||
{
|
||||
value: selectedRoleId,
|
||||
label: currentRoleName,
|
||||
},
|
||||
...options,
|
||||
];
|
||||
} else {
|
||||
try {
|
||||
const roleResponse = await roleService.getById(selectedRoleId);
|
||||
if (roleResponse.success) {
|
||||
// Prepend the selected role to the options
|
||||
options = [
|
||||
{
|
||||
value: roleResponse.data.id,
|
||||
label: roleResponse.data.name,
|
||||
},
|
||||
...options,
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
// If fetching fails, just continue with existing options
|
||||
console.warn('Failed to fetch selected role:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
// Load user data when modal opens - only load once per userId
|
||||
const loadDepartments = async () => {
|
||||
const response = await departmentService.list(defaultTenantId, {
|
||||
active_only: true,
|
||||
});
|
||||
let options = response.data.map((dept) => ({
|
||||
value: dept.id,
|
||||
label: dept.name,
|
||||
}));
|
||||
|
||||
if (initialDepartmentOption) {
|
||||
const exists = options.find(
|
||||
(opt) => opt.value === initialDepartmentOption.value,
|
||||
);
|
||||
if (!exists) {
|
||||
options = [initialDepartmentOption, ...options];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: response.data.length,
|
||||
total: response.data.length,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const loadDesignations = async () => {
|
||||
const response = await designationService.list(defaultTenantId, {
|
||||
active_only: true,
|
||||
});
|
||||
let options = response.data.map((desig) => ({
|
||||
value: desig.id,
|
||||
label: desig.name,
|
||||
}));
|
||||
|
||||
if (initialDesignationOption) {
|
||||
const exists = options.find(
|
||||
(opt) => opt.value === initialDesignationOption.value,
|
||||
);
|
||||
if (!exists) {
|
||||
options = [initialDesignationOption, ...options];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: response.data.length,
|
||||
total: response.data.length,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Load user data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
// Only load if this is a new userId or modal was closed and reopened
|
||||
if (loadedUserIdRef.current !== userId) {
|
||||
const loadUser = async (): Promise<void> => {
|
||||
try {
|
||||
@ -216,24 +192,34 @@ export const EditUserModal = ({
|
||||
const user = await onLoadUser(userId);
|
||||
loadedUserIdRef.current = userId;
|
||||
|
||||
// Extract tenant and role IDs from nested objects or fallback to direct properties
|
||||
const tenantId = user.tenant?.id || user.tenant_id || '';
|
||||
const roleId = user.role?.id || user.role_id || '';
|
||||
// const tenantName = user.tenant?.name || '';
|
||||
const roleName = user.role?.name || '';
|
||||
const tenantId = user.tenant?.id || user.tenant_id || "";
|
||||
const roleId = user.role?.id || user.role_id || "";
|
||||
const departmentId =
|
||||
user.department?.id || user.department_id || "";
|
||||
const designationId =
|
||||
user.designation?.id || user.designation_id || "";
|
||||
|
||||
const roleName = user.role?.name || "";
|
||||
const departmentName = user.department?.name || "";
|
||||
const designationName = user.designation?.name || "";
|
||||
|
||||
// setSelectedTenantId(tenantId);
|
||||
setSelectedRoleId(roleId);
|
||||
// setCurrentTenantName(tenantName);
|
||||
setCurrentRoleName(roleName);
|
||||
|
||||
// Set initial options for immediate display using names from user response
|
||||
// if (tenantId && tenantName) {
|
||||
// setInitialTenantOption({ value: tenantId, label: tenantName });
|
||||
// }
|
||||
if (roleId && roleName) {
|
||||
setInitialRoleOption({ value: roleId, label: roleName });
|
||||
}
|
||||
if (departmentId && departmentName) {
|
||||
setInitialDepartmentOption({
|
||||
value: departmentId,
|
||||
label: departmentName,
|
||||
});
|
||||
}
|
||||
if (designationId && designationName) {
|
||||
setInitialDesignationOption({
|
||||
value: designationId,
|
||||
label: designationName,
|
||||
});
|
||||
}
|
||||
|
||||
reset({
|
||||
email: user.email,
|
||||
@ -242,14 +228,18 @@ export const EditUserModal = ({
|
||||
status: user.status,
|
||||
tenant_id: defaultTenantId || tenantId,
|
||||
role_id: roleId,
|
||||
department_id: departmentId,
|
||||
designation_id: designationId,
|
||||
});
|
||||
|
||||
// If defaultTenantId is provided, override tenant_id
|
||||
if (defaultTenantId) {
|
||||
setValue('tenant_id', defaultTenantId, { shouldValidate: true });
|
||||
setValue("tenant_id", defaultTenantId, { shouldValidate: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLoadError(err?.response?.data?.error?.message || 'Failed to load user details');
|
||||
setLoadError(
|
||||
err?.response?.data?.error?.message ||
|
||||
"Failed to load user details",
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
@ -257,70 +247,74 @@ export const EditUserModal = ({
|
||||
loadUser();
|
||||
}
|
||||
} else if (!isOpen) {
|
||||
// Only reset when modal is closed
|
||||
loadedUserIdRef.current = null;
|
||||
// setSelectedTenantId('');
|
||||
setSelectedRoleId('');
|
||||
// setCurrentTenantName('');
|
||||
setCurrentRoleName('');
|
||||
// setInitialTenantOption(null);
|
||||
setCurrentRoleName("");
|
||||
setInitialRoleOption(null);
|
||||
setInitialDepartmentOption(null);
|
||||
setInitialDesignationOption(null);
|
||||
reset({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
status: 'active',
|
||||
tenant_id: defaultTenantId || '',
|
||||
role_id: '',
|
||||
email: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
status: "active",
|
||||
tenant_id: defaultTenantId || "",
|
||||
role_id: "",
|
||||
department_id: "",
|
||||
designation_id: "",
|
||||
});
|
||||
setLoadError(null);
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, userId, onLoadUser, reset, clearErrors, defaultTenantId, setValue]);
|
||||
}, [
|
||||
isOpen,
|
||||
userId,
|
||||
onLoadUser,
|
||||
reset,
|
||||
clearErrors,
|
||||
defaultTenantId,
|
||||
setValue,
|
||||
]);
|
||||
|
||||
const handleFormSubmit = async (data: EditUserFormData): Promise<void> => {
|
||||
if (!userId) return;
|
||||
|
||||
clearErrors();
|
||||
try {
|
||||
// Ensure tenant_id is set from defaultTenantId if provided
|
||||
if (defaultTenantId) {
|
||||
data.tenant_id = defaultTenantId;
|
||||
}
|
||||
await onSubmit(userId, data);
|
||||
} catch (error: any) {
|
||||
// Handle validation errors from API
|
||||
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
|
||||
if (
|
||||
error?.response?.data?.details &&
|
||||
Array.isArray(error.response.data.details)
|
||||
) {
|
||||
const validationErrors = error.response.data.details;
|
||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||
if (
|
||||
detail.path === 'email' ||
|
||||
detail.path === 'first_name' ||
|
||||
detail.path === 'last_name' ||
|
||||
detail.path === 'status' ||
|
||||
detail.path === 'auth_provider' ||
|
||||
detail.path === 'tenant_id' ||
|
||||
detail.path === 'role_id'
|
||||
) {
|
||||
validationErrors.forEach(
|
||||
(detail: { path: string; message: string }) => {
|
||||
setError(detail.path as keyof EditUserFormData, {
|
||||
type: 'server',
|
||||
type: "server",
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Handle general errors
|
||||
// Check for nested error object with message property
|
||||
const errorObj = error?.response?.data?.error;
|
||||
const errorMessage =
|
||||
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
|
||||
(typeof errorObj === 'string' ? errorObj : null) ||
|
||||
(typeof errorObj === "object" &&
|
||||
errorObj !== null &&
|
||||
"message" in errorObj
|
||||
? errorObj.message
|
||||
: null) ||
|
||||
(typeof errorObj === "string" ? errorObj : null) ||
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
'Failed to update user. Please try again.';
|
||||
setError('root', {
|
||||
type: 'server',
|
||||
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to update user. Please try again.',
|
||||
"Failed to update user. Please try again.";
|
||||
setError("root", {
|
||||
type: "server",
|
||||
message:
|
||||
typeof errorMessage === "string"
|
||||
? errorMessage
|
||||
: "Failed to update user. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -350,7 +344,7 @@ export const EditUserModal = ({
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update User'}
|
||||
{isLoading ? "Updating..." : "Update User"}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
@ -370,80 +364,87 @@ export const EditUserModal = ({
|
||||
|
||||
{!isLoadingUser && (
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* General Error Display */}
|
||||
{errors.root && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter email address"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
{/* First Name and Last Name Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormField
|
||||
label="First Name"
|
||||
required
|
||||
placeholder="Enter first name"
|
||||
error={errors.first_name?.message}
|
||||
{...register('first_name')}
|
||||
{...register("first_name")}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Last Name"
|
||||
required
|
||||
placeholder="Enter last name"
|
||||
error={errors.last_name?.message}
|
||||
{...register('last_name')}
|
||||
{...register("last_name")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect
|
||||
label="Department"
|
||||
placeholder="Select Department"
|
||||
value={departmentIdValue || ""}
|
||||
onValueChange={(value) => setValue("department_id", value)}
|
||||
onLoadOptions={loadDepartments}
|
||||
initialOption={initialDepartmentOption || undefined}
|
||||
error={errors.department_id?.message}
|
||||
/>
|
||||
<PaginatedSelect
|
||||
label="Designation"
|
||||
placeholder="Select Designation"
|
||||
value={designationIdValue || ""}
|
||||
onValueChange={(value) => setValue("designation_id", value)}
|
||||
onLoadOptions={loadDesignations}
|
||||
initialOption={initialDesignationOption || undefined}
|
||||
error={errors.designation_id?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tenant and Role Row */}
|
||||
<div className={`grid grid-cols-2 gap-5 pb-4`}>
|
||||
{/* {!defaultTenantId && (
|
||||
<PaginatedSelect
|
||||
label="Assign Tenant"
|
||||
required
|
||||
placeholder="Select Tenant"
|
||||
value={tenantIdValue || ''}
|
||||
onValueChange={(value) => setValue('tenant_id', value)}
|
||||
onLoadOptions={loadTenants}
|
||||
initialOption={initialTenantOption || undefined}
|
||||
error={errors.tenant_id?.message}
|
||||
/>
|
||||
)} */}
|
||||
{currentRoleName !== 'Tenant Admin' && (
|
||||
{currentRoleName !== "Tenant Admin" && (
|
||||
<PaginatedSelect
|
||||
label="Assign Role"
|
||||
required
|
||||
placeholder="Select Role"
|
||||
value={roleIdValue || ''}
|
||||
onValueChange={(value) => setValue('role_id', value)}
|
||||
value={roleIdValue || ""}
|
||||
onValueChange={(value) => setValue("role_id", value)}
|
||||
onLoadOptions={loadRoles}
|
||||
initialOption={initialRoleOption || undefined}
|
||||
error={errors.role_id?.message}
|
||||
/>
|
||||
)}
|
||||
{/* Status */}
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
|
||||
onValueChange={(value) =>
|
||||
setValue(
|
||||
"status",
|
||||
value as "active" | "suspended" | "deleted",
|
||||
)
|
||||
}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
@ -10,28 +10,35 @@ import {
|
||||
PaginatedSelect,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/components/shared';
|
||||
import { roleService } from '@/services/role-service';
|
||||
} from "@/components/shared";
|
||||
import { roleService } from "@/services/role-service";
|
||||
import { departmentService } from "@/services/department-service";
|
||||
import { designationService } from "@/services/designation-service";
|
||||
|
||||
// Validation schema
|
||||
const newUserSchema = z
|
||||
.object({
|
||||
email: z.email({ message: 'Please enter a valid email address' }),
|
||||
password: z.string().min(1, 'Password is required').min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string().min(1, 'Confirm password is required'),
|
||||
first_name: z.string().min(1, 'First name is required'),
|
||||
last_name: z.string().min(1, 'Last name is required'),
|
||||
status: z.enum(['active', 'suspended', 'deleted'], {
|
||||
message: 'Status is required',
|
||||
email: z.email({ message: "Please enter a valid email address" }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, "Password is required")
|
||||
.min(6, "Password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(1, "Confirm password is required"),
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
status: z.enum(["active", "suspended", "deleted"], {
|
||||
message: "Status is required",
|
||||
}),
|
||||
auth_provider: z.enum(['local'], {
|
||||
message: 'Auth provider is required',
|
||||
auth_provider: z.enum(["local"], {
|
||||
message: "Auth provider is required",
|
||||
}),
|
||||
role_id: z.string().min(1, 'Role is required'),
|
||||
role_id: z.string().min(1, "Role is required"),
|
||||
department_id: z.string().optional(),
|
||||
designation_id: z.string().optional(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type NewUserFormData = z.infer<typeof newUserSchema>;
|
||||
@ -39,15 +46,15 @@ type NewUserFormData = z.infer<typeof newUserSchema>;
|
||||
interface NewUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: Omit<NewUserFormData, 'confirmPassword'>) => Promise<void>;
|
||||
onSubmit: (data: Omit<NewUserFormData, "confirmPassword">) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
defaultTenantId?: string; // If provided, filter roles by tenant_id
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "suspended", label: "Suspended" },
|
||||
{ value: "deleted", label: "Deleted" },
|
||||
];
|
||||
|
||||
export const NewUserModal = ({
|
||||
@ -69,27 +76,31 @@ export const NewUserModal = ({
|
||||
} = useForm<NewUserFormData>({
|
||||
resolver: zodResolver(newUserSchema),
|
||||
defaultValues: {
|
||||
status: 'active',
|
||||
auth_provider: 'local',
|
||||
role_id: '',
|
||||
role_id: "",
|
||||
department_id: "",
|
||||
designation_id: "",
|
||||
},
|
||||
});
|
||||
|
||||
const statusValue = watch('status');
|
||||
const roleIdValue = watch('role_id');
|
||||
const statusValue = watch("status");
|
||||
const roleIdValue = watch("role_id");
|
||||
const departmentIdValue = watch("department_id");
|
||||
const designationIdValue = watch("designation_id");
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
status: 'active',
|
||||
auth_provider: 'local',
|
||||
role_id: '',
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
status: "active",
|
||||
auth_provider: "local",
|
||||
role_id: "",
|
||||
department_id: "",
|
||||
designation_id: "",
|
||||
});
|
||||
clearErrors();
|
||||
}
|
||||
@ -97,7 +108,6 @@ export const NewUserModal = ({
|
||||
|
||||
// Load roles for dropdown
|
||||
const loadRoles = async (page: number, limit: number) => {
|
||||
// If defaultTenantId is provided, filter roles by tenant_id
|
||||
const response = defaultTenantId
|
||||
? await roleService.getByTenant(defaultTenantId, page, limit)
|
||||
: await roleService.getAll(page, limit);
|
||||
@ -110,6 +120,44 @@ export const NewUserModal = ({
|
||||
};
|
||||
};
|
||||
|
||||
const loadDepartments = async () => {
|
||||
const response = await departmentService.list(defaultTenantId, {
|
||||
active_only: true,
|
||||
});
|
||||
return {
|
||||
options: response.data.map((dept) => ({
|
||||
value: dept.id,
|
||||
label: dept.name,
|
||||
})),
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: response.data.length,
|
||||
total: response.data.length,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const loadDesignations = async () => {
|
||||
const response = await designationService.list(defaultTenantId, {
|
||||
active_only: true,
|
||||
});
|
||||
return {
|
||||
options: response.data.map((desig) => ({
|
||||
value: desig.id,
|
||||
label: desig.name,
|
||||
})),
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: response.data.length,
|
||||
total: response.data.length,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: NewUserFormData): Promise<void> => {
|
||||
clearErrors();
|
||||
try {
|
||||
@ -117,37 +165,49 @@ export const NewUserModal = ({
|
||||
await onSubmit(submitData);
|
||||
} catch (error: any) {
|
||||
// Handle validation errors from API
|
||||
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
|
||||
if (
|
||||
error?.response?.data?.details &&
|
||||
Array.isArray(error.response.data.details)
|
||||
) {
|
||||
const validationErrors = error.response.data.details;
|
||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||
if (
|
||||
detail.path === 'email' ||
|
||||
detail.path === 'password' ||
|
||||
detail.path === 'first_name' ||
|
||||
detail.path === 'last_name' ||
|
||||
detail.path === 'status' ||
|
||||
detail.path === 'auth_provider' ||
|
||||
detail.path === 'role_id'
|
||||
) {
|
||||
setError(detail.path as keyof NewUserFormData, {
|
||||
type: 'server',
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
validationErrors.forEach(
|
||||
(detail: { path: string; message: string }) => {
|
||||
if (
|
||||
detail.path === "email" ||
|
||||
detail.path === "password" ||
|
||||
detail.path === "first_name" ||
|
||||
detail.path === "last_name" ||
|
||||
detail.path === "status" ||
|
||||
detail.path === "auth_provider" ||
|
||||
detail.path === "role_id"
|
||||
) {
|
||||
setError(detail.path as keyof NewUserFormData, {
|
||||
type: "server",
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Handle general errors
|
||||
// Check for nested error object with message property
|
||||
const errorObj = error?.response?.data?.error;
|
||||
const errorMessage =
|
||||
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
|
||||
(typeof errorObj === 'string' ? errorObj : null) ||
|
||||
(typeof errorObj === "object" &&
|
||||
errorObj !== null &&
|
||||
"message" in errorObj
|
||||
? errorObj.message
|
||||
: null) ||
|
||||
(typeof errorObj === "string" ? errorObj : null) ||
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
'Failed to create user. Please try again.';
|
||||
setError('root', {
|
||||
type: 'server',
|
||||
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create user. Please try again.',
|
||||
"Failed to create user. Please try again.";
|
||||
setError("root", {
|
||||
type: "server",
|
||||
message:
|
||||
typeof errorMessage === "string"
|
||||
? errorMessage
|
||||
: "Failed to create user. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -177,7 +237,7 @@ export const NewUserModal = ({
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create User'}
|
||||
{isLoading ? "Creating..." : "Create User"}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
@ -198,7 +258,7 @@ export const NewUserModal = ({
|
||||
required
|
||||
placeholder="Enter email address"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
{/* First Name and Last Name Row */}
|
||||
@ -208,7 +268,7 @@ export const NewUserModal = ({
|
||||
required
|
||||
placeholder="Enter first name"
|
||||
error={errors.first_name?.message}
|
||||
{...register('first_name')}
|
||||
{...register("first_name")}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
@ -216,7 +276,7 @@ export const NewUserModal = ({
|
||||
required
|
||||
placeholder="Enter last name"
|
||||
error={errors.last_name?.message}
|
||||
{...register('last_name')}
|
||||
{...register("last_name")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -228,7 +288,7 @@ export const NewUserModal = ({
|
||||
required
|
||||
placeholder="Enter password"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
{...register("password")}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
@ -237,33 +297,54 @@ export const NewUserModal = ({
|
||||
required
|
||||
placeholder="Confirm password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="pb-4">
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect
|
||||
label="Department"
|
||||
placeholder="Select Department"
|
||||
value={departmentIdValue || ""}
|
||||
onValueChange={(value) => setValue("department_id", value)}
|
||||
onLoadOptions={loadDepartments}
|
||||
error={errors.department_id?.message}
|
||||
/>
|
||||
|
||||
<PaginatedSelect
|
||||
label="Designation"
|
||||
placeholder="Select Designation"
|
||||
value={designationIdValue || ""}
|
||||
onValueChange={(value) => setValue("designation_id", value)}
|
||||
onLoadOptions={loadDesignations}
|
||||
error={errors.designation_id?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role and Status Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect
|
||||
label="Assign Role"
|
||||
required
|
||||
placeholder="Select Role"
|
||||
value={roleIdValue}
|
||||
onValueChange={(value) => setValue('role_id', value)}
|
||||
value={roleIdValue || ""}
|
||||
onValueChange={(value) => setValue("role_id", value)}
|
||||
onLoadOptions={loadRoles}
|
||||
error={errors.role_id?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) =>
|
||||
setValue("status", value as "active" | "suspended" | "deleted")
|
||||
}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@ -1,32 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal, SecondaryButton, StatusBadge } from '@/components/shared';
|
||||
import type { User } from '@/types/user';
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Modal, SecondaryButton, StatusBadge } from "@/components/shared";
|
||||
import type { User } from "@/types/user";
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
||||
const getStatusVariant = (
|
||||
status: string,
|
||||
): "success" | "failure" | "process" => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'deleted':
|
||||
return 'failure';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
case "active":
|
||||
return "success";
|
||||
case "deleted":
|
||||
return "failure";
|
||||
case "suspended":
|
||||
return "process";
|
||||
default:
|
||||
return 'success';
|
||||
return "success";
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
@ -57,7 +59,10 @@ export const ViewUserModal = ({
|
||||
const data = await onLoadUser(userId);
|
||||
setUser(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load user details');
|
||||
setError(
|
||||
err?.response?.data?.error?.message ||
|
||||
"Failed to load user details",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -77,7 +82,11 @@ export const ViewUserModal = ({
|
||||
description="View user information"
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<SecondaryButton type="button" onClick={onClose} className="px-4 py-2.5 text-sm">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Close
|
||||
</SecondaryButton>
|
||||
}
|
||||
@ -99,20 +108,28 @@ export const ViewUserModal = ({
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Basic Information</h3>
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Email</label>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Email
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Full Name</label>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Full Name
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Status</label>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Status
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>
|
||||
{user.status}
|
||||
@ -120,13 +137,41 @@ export const ViewUserModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Auth Provider</label>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Role
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{user.role?.name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Department
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{user.department?.name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Designation
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{user.designation?.name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Auth Provider
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{user.auth_provider}</p>
|
||||
</div>
|
||||
{user.tenant_id && (
|
||||
{user.tenant?.name && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Tenant ID</label>
|
||||
<p className="text-sm text-[#0e1b2a] font-mono">{user.tenant_id}</p>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Tenant
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{user.tenant.name}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -134,15 +179,25 @@ export const ViewUserModal = ({
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">Timestamps</h3>
|
||||
<h3 className="text-sm font-semibold text-[#0e1b2a]">
|
||||
Timestamps
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Created At</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{formatDate(user.created_at)}</p>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Created At
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{formatDate(user.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Updated At</label>
|
||||
<p className="text-sm text-[#0e1b2a]">{formatDate(user.updated_at)}</p>
|
||||
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
|
||||
Updated At
|
||||
</label>
|
||||
<p className="text-sm text-[#0e1b2a]">
|
||||
{formatDate(user.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -22,4 +22,6 @@ export { EditRoleModal } from './EditRoleModal';
|
||||
export { ViewAuditLogModal } from './ViewAuditLogModal';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export type { TabItem } from './PageHeader';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export * from './DepartmentModals';
|
||||
export * from './DesignationModals';
|
||||
354
src/components/superadmin/DepartmentsTable.tsx
Normal file
354
src/components/superadmin/DepartmentsTable.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
DeleteConfirmationModal,
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import {
|
||||
NewDepartmentModal,
|
||||
EditDepartmentModal,
|
||||
ViewDepartmentModal,
|
||||
} from "@/components/shared/DepartmentModals";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { departmentService } from "@/services/department-service";
|
||||
import type {
|
||||
Department,
|
||||
CreateDepartmentRequest,
|
||||
UpdateDepartmentRequest,
|
||||
} from "@/types/department";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import type { RootState } from "@/store/store";
|
||||
|
||||
interface DepartmentsTableProps {
|
||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||
compact?: boolean; // Compact mode for tabs
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
const DepartmentsTable = ({
|
||||
tenantId: propsTenantId,
|
||||
compact = false,
|
||||
showHeader = true,
|
||||
}: DepartmentsTableProps): ReactElement => {
|
||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||
const effectiveTenantId = propsTenantId || reduxTenantId;
|
||||
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pagination state (Client-side since backend doesn't support it yet)
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(compact ? 10 : 5);
|
||||
|
||||
// Filter state
|
||||
const [activeOnly, setActiveOnly] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
|
||||
|
||||
// Modal states
|
||||
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedDepartment, setSelectedDepartment] =
|
||||
useState<Department | null>(null);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await departmentService.list(effectiveTenantId, {
|
||||
active_only: activeOnly,
|
||||
search: debouncedSearchQuery,
|
||||
});
|
||||
if (response.success) {
|
||||
setDepartments(response.data);
|
||||
} else {
|
||||
setError("Failed to load departments");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.response?.data?.error?.message || "Failed to load departments",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debouncing search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDepartments();
|
||||
}, [effectiveTenantId, activeOnly, debouncedSearchQuery]);
|
||||
|
||||
const handleCreate = async (data: CreateDepartmentRequest) => {
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await departmentService.create(data, effectiveTenantId);
|
||||
if (response.success) {
|
||||
showToast.success("Department created successfully");
|
||||
setIsNewModalOpen(false);
|
||||
fetchDepartments();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to create department",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: UpdateDepartmentRequest) => {
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await departmentService.update(
|
||||
id,
|
||||
data,
|
||||
effectiveTenantId,
|
||||
);
|
||||
if (response.success) {
|
||||
showToast.success("Department updated successfully");
|
||||
setIsEditModalOpen(false);
|
||||
fetchDepartments();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to update department",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedDepartment) return;
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await departmentService.delete(
|
||||
selectedDepartment.id,
|
||||
effectiveTenantId,
|
||||
);
|
||||
if (response.success) {
|
||||
showToast.success("Department deleted successfully");
|
||||
setIsDeleteModalOpen(false);
|
||||
fetchDepartments();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to delete department",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Client-side pagination logic
|
||||
const totalItems = departments.length;
|
||||
const totalPages = Math.ceil(totalItems / limit);
|
||||
const paginatedData = departments.slice(
|
||||
(currentPage - 1) * limit,
|
||||
currentPage * limit,
|
||||
);
|
||||
|
||||
const columns: Column<Department>[] = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Department Name",
|
||||
render: (dept) => (
|
||||
<span className="text-sm font-medium text-[#0f1724]">{dept.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "parent_name",
|
||||
label: "Parent",
|
||||
render: (dept) => (
|
||||
<span className="text-sm text-[#6b7280]">
|
||||
{dept.parent_name || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
label: "Level",
|
||||
render: (dept) => (
|
||||
<span className="text-sm text-[#6b7280]">{dept.level}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sort_order",
|
||||
label: "Order",
|
||||
render: (dept) => (
|
||||
<span className="text-sm text-[#6b7280]">{dept.sort_order}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "child_count",
|
||||
label: "Sub-depts",
|
||||
render: (dept) => (
|
||||
<span className="text-sm text-[#6b7280]">{dept.child_count || 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "user_count",
|
||||
label: "Users",
|
||||
render: (dept) => (
|
||||
<span className="text-sm text-[#6b7280]">{dept.user_count || 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (dept) => (
|
||||
<StatusBadge variant={dept.is_active ? "success" : "failure"}>
|
||||
{dept.is_active ? "Active" : "Inactive"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
align: "right",
|
||||
render: (dept) => (
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => {
|
||||
setSelectedDepartment(dept);
|
||||
setIsViewModalOpen(true);
|
||||
}}
|
||||
onEdit={() => {
|
||||
setSelectedDepartment(dept);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setSelectedDepartment(dept);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-4 ${!compact ? "bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-sm" : ""}`}
|
||||
>
|
||||
{showHeader && (
|
||||
<div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search departments..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#0052cc]"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: "all", label: "All Status" },
|
||||
{ value: "active", label: "Active Only" },
|
||||
]}
|
||||
value={activeOnly ? "active" : "all"}
|
||||
onChange={(value) => setActiveOnly(value === "active")}
|
||||
/>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2 w-full sm:w-auto"
|
||||
onClick={() => setIsNewModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Department</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
data={paginatedData}
|
||||
columns={columns}
|
||||
keyExtractor={(dept) => dept.id}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
emptyMessage="No departments found"
|
||||
/>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={(newLimit) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NewDepartmentModal
|
||||
isOpen={isNewModalOpen}
|
||||
onClose={() => setIsNewModalOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={isActionLoading}
|
||||
tenantId={effectiveTenantId}
|
||||
/>
|
||||
|
||||
<EditDepartmentModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedDepartment(null);
|
||||
}}
|
||||
department={selectedDepartment}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={isActionLoading}
|
||||
tenantId={effectiveTenantId}
|
||||
/>
|
||||
|
||||
<ViewDepartmentModal
|
||||
isOpen={isViewModalOpen}
|
||||
onClose={() => {
|
||||
setIsViewModalOpen(false);
|
||||
setSelectedDepartment(null);
|
||||
}}
|
||||
department={selectedDepartment}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedDepartment(null);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Department"
|
||||
message="Are you sure you want to delete this department? This action cannot be undone."
|
||||
itemName={selectedDepartment?.name || ""}
|
||||
isLoading={isActionLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DepartmentsTable;
|
||||
343
src/components/superadmin/DesignationsTable.tsx
Normal file
343
src/components/superadmin/DesignationsTable.tsx
Normal file
@ -0,0 +1,343 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
DeleteConfirmationModal,
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import {
|
||||
NewDesignationModal,
|
||||
EditDesignationModal,
|
||||
ViewDesignationModal,
|
||||
} from "@/components/shared/DesignationModals";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { designationService } from "@/services/designation-service";
|
||||
import type {
|
||||
Designation,
|
||||
CreateDesignationRequest,
|
||||
UpdateDesignationRequest,
|
||||
} from "@/types/designation";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import type { RootState } from "@/store/store";
|
||||
|
||||
interface DesignationsTableProps {
|
||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||
compact?: boolean; // Compact mode for tabs
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
const DesignationsTable = ({
|
||||
tenantId: propsTenantId,
|
||||
compact = false,
|
||||
showHeader = true,
|
||||
}: DesignationsTableProps): ReactElement => {
|
||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||
const effectiveTenantId = propsTenantId || reduxTenantId;
|
||||
|
||||
const [designations, setDesignations] = useState<Designation[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(compact ? 10 : 5);
|
||||
|
||||
// Filter state
|
||||
const [activeOnly, setActiveOnly] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
|
||||
|
||||
// Modal states
|
||||
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedDesignation, setSelectedDesignation] =
|
||||
useState<Designation | null>(null);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
|
||||
const fetchDesignations = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await designationService.list(effectiveTenantId, {
|
||||
active_only: activeOnly,
|
||||
search: debouncedSearchQuery,
|
||||
});
|
||||
if (response.success) {
|
||||
setDesignations(response.data);
|
||||
} else {
|
||||
setError("Failed to load designations");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.response?.data?.error?.message || "Failed to load designations",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debouncing search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDesignations();
|
||||
}, [effectiveTenantId, activeOnly, debouncedSearchQuery]);
|
||||
|
||||
const handleCreate = async (data: CreateDesignationRequest) => {
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await designationService.create(data, effectiveTenantId);
|
||||
if (response.success) {
|
||||
showToast.success("Designation created successfully");
|
||||
setIsNewModalOpen(false);
|
||||
fetchDesignations();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to create designation",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: UpdateDesignationRequest) => {
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await designationService.update(
|
||||
id,
|
||||
data,
|
||||
effectiveTenantId,
|
||||
);
|
||||
if (response.success) {
|
||||
showToast.success("Designation updated successfully");
|
||||
setIsEditModalOpen(false);
|
||||
fetchDesignations();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to update designation",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedDesignation) return;
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await designationService.delete(
|
||||
selectedDesignation.id,
|
||||
effectiveTenantId,
|
||||
);
|
||||
if (response.success) {
|
||||
showToast.success("Designation deleted successfully");
|
||||
setIsDeleteModalOpen(false);
|
||||
fetchDesignations();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to delete designation",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Client-side pagination logic
|
||||
const totalItems = designations.length;
|
||||
const totalPages = Math.ceil(totalItems / limit);
|
||||
const paginatedData = designations.slice(
|
||||
(currentPage - 1) * limit,
|
||||
currentPage * limit,
|
||||
);
|
||||
|
||||
const columns: Column<Designation>[] = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Designation Name",
|
||||
render: (desig) => (
|
||||
<span className="text-sm font-medium text-[#0f1724]">{desig.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "Code",
|
||||
render: (desig) => (
|
||||
<span className="text-sm text-[#6b7280]">{desig.code}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
label: "Level",
|
||||
render: (desig) => (
|
||||
<span className="text-sm text-[#6b7280]">{desig.level}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sort_order",
|
||||
label: "Order",
|
||||
render: (desig) => (
|
||||
<span className="text-sm text-[#6b7280]">{desig.sort_order}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "user_count",
|
||||
label: "Users",
|
||||
render: (desig) => (
|
||||
<span className="text-sm text-[#6b7280]">{desig.user_count || 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (desig) => (
|
||||
<StatusBadge variant={desig.is_active ? "success" : "failure"}>
|
||||
{desig.is_active ? "Active" : "Inactive"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
align: "right",
|
||||
render: (desig) => (
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => {
|
||||
setSelectedDesignation(desig);
|
||||
setIsViewModalOpen(true);
|
||||
}}
|
||||
onEdit={() => {
|
||||
setSelectedDesignation(desig);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setSelectedDesignation(desig);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-4 ${!compact ? "bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-sm" : ""}`}
|
||||
>
|
||||
{showHeader && (
|
||||
<div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search designations..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#0052cc]"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: "all", label: "All Status" },
|
||||
{ value: "active", label: "Active Only" },
|
||||
]}
|
||||
value={activeOnly ? "active" : "all"}
|
||||
onChange={(value) => setActiveOnly(value === "active")}
|
||||
/>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2 w-full sm:w-auto"
|
||||
onClick={() => setIsNewModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Designation</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
data={paginatedData}
|
||||
columns={columns}
|
||||
keyExtractor={(desig) => desig.id}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
emptyMessage="No designations found"
|
||||
/>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={(newLimit) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NewDesignationModal
|
||||
isOpen={isNewModalOpen}
|
||||
onClose={() => setIsNewModalOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={isActionLoading}
|
||||
/>
|
||||
|
||||
<EditDesignationModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedDesignation(null);
|
||||
}}
|
||||
designation={selectedDesignation}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={isActionLoading}
|
||||
/>
|
||||
|
||||
<ViewDesignationModal
|
||||
isOpen={isViewModalOpen}
|
||||
onClose={() => {
|
||||
setIsViewModalOpen(false);
|
||||
setSelectedDesignation(null);
|
||||
}}
|
||||
designation={selectedDesignation}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedDesignation(null);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Designation"
|
||||
message="Are you sure you want to delete this designation? This action cannot be undone."
|
||||
itemName={selectedDesignation?.name || ""}
|
||||
isLoading={isActionLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesignationsTable;
|
||||
9
src/components/superadmin/UserCategoriesTable.tsx
Normal file
9
src/components/superadmin/UserCategoriesTable.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
|
||||
const UserCategoriesTable = ({tenantId, compact=false}: {tenantId: string, compact?: boolean}) => {
|
||||
console.log(tenantId, compact);
|
||||
return (
|
||||
<div>UserCategoriesTable</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserCategoriesTable
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, type ReactElement } from 'react';
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
@ -11,12 +11,12 @@ import {
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { userService } from '@/services/user-service';
|
||||
import type { User } from '@/types/user';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { formatDate } from '@/utils/format-date';
|
||||
} from "@/components/shared";
|
||||
import { Plus, Download, ArrowUpDown } from "lucide-react";
|
||||
import { userService } from "@/services/user-service";
|
||||
import type { User } from "@/types/user";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
|
||||
// Helper function to get user initials
|
||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
@ -24,20 +24,22 @@ const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
};
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
||||
const getStatusVariant = (
|
||||
status: string,
|
||||
): "success" | "failure" | "process" => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'pending_verification':
|
||||
return 'process';
|
||||
case 'inactive':
|
||||
return 'failure';
|
||||
case 'deleted':
|
||||
return 'failure';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
case "active":
|
||||
return "success";
|
||||
case "pending_verification":
|
||||
return "process";
|
||||
case "inactive":
|
||||
return "failure";
|
||||
case "deleted":
|
||||
return "failure";
|
||||
case "suspended":
|
||||
return "process";
|
||||
default:
|
||||
return 'success';
|
||||
return "success";
|
||||
}
|
||||
};
|
||||
|
||||
@ -47,7 +49,11 @@ interface UsersTableProps {
|
||||
compact?: boolean; // Compact mode for tabs (default: false)
|
||||
}
|
||||
|
||||
export const UsersTable = ({ tenantId, showHeader = true, compact = false }: UsersTableProps): ReactElement => {
|
||||
export const UsersTable = ({
|
||||
tenantId,
|
||||
showHeader = true,
|
||||
compact = false,
|
||||
}: UsersTableProps): ReactElement => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -80,7 +86,7 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [selectedUserName, setSelectedUserName] = useState<string>('');
|
||||
const [selectedUserName, setSelectedUserName] = useState<string>("");
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
@ -88,22 +94,28 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
page: number,
|
||||
itemsPerPage: number,
|
||||
status: string | null = null,
|
||||
sortBy: string[] | null = null
|
||||
sortBy: string[] | null = null,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = tenantId
|
||||
? await userService.getByTenant(tenantId, page, itemsPerPage, status, sortBy)
|
||||
? await userService.getByTenant(
|
||||
tenantId,
|
||||
page,
|
||||
itemsPerPage,
|
||||
status,
|
||||
sortBy,
|
||||
)
|
||||
: await userService.getAll(page, itemsPerPage, status, sortBy);
|
||||
if (response.success) {
|
||||
setUsers(response.data);
|
||||
setPagination(response.pagination);
|
||||
} else {
|
||||
setError('Failed to load users');
|
||||
setError("Failed to load users");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load users');
|
||||
setError(err?.response?.data?.error?.message || "Failed to load users");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -118,19 +130,21 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'deleted';
|
||||
auth_provider: 'local';
|
||||
status: "active" | "suspended" | "deleted";
|
||||
auth_provider: "local";
|
||||
role_id: string;
|
||||
department_id?: string;
|
||||
designation_id?: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
// Explicitly add tenant_id when tenantId is provided (for super admin creating users in tenant details)
|
||||
const createData = tenantId
|
||||
? { ...data, tenant_id: tenantId }
|
||||
: data;
|
||||
const createData = tenantId ? { ...data, tenant_id: tenantId } : data;
|
||||
const response = await userService.create(createData);
|
||||
const message = response.message || `User created successfully`;
|
||||
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
|
||||
const description = response.message
|
||||
? undefined
|
||||
: `${data.first_name} ${data.last_name} has been added`;
|
||||
showToast.success(message, description);
|
||||
setIsModalOpen(false);
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
@ -161,21 +175,25 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'deleted';
|
||||
status: "active" | "suspended" | "deleted";
|
||||
auth_provider?: string;
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
}
|
||||
department_id?: string;
|
||||
designation_id?: string;
|
||||
},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
const response = await userService.update(userId, data);
|
||||
const message = response.message || `User updated successfully`;
|
||||
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been updated`;
|
||||
const description = response.message
|
||||
? undefined
|
||||
: `${data.first_name} ${data.last_name} has been updated`;
|
||||
showToast.success(message, description);
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
setSelectedUserName("");
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
@ -200,7 +218,7 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
await userService.delete(selectedUserId);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
setSelectedUserName("");
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err; // Let the modal handle the error display
|
||||
@ -218,8 +236,8 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
// Define table columns
|
||||
const columns: Column<User>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'User Name',
|
||||
key: "name",
|
||||
label: "User Name",
|
||||
render: (user) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
@ -232,50 +250,84 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
mobileLabel: 'Name',
|
||||
mobileLabel: "Name",
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.email}</span>,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'role',
|
||||
render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.role?.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
key: "email",
|
||||
label: "Email",
|
||||
render: (user) => (
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
||||
<span className="text-sm font-normal text-[#0f1724]">{user.email}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'auth_provider',
|
||||
label: 'Auth Provider',
|
||||
key: "role",
|
||||
label: "Role",
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{user.auth_provider}</span>
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{user.role?.name || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Joined Date',
|
||||
key: "department",
|
||||
label: "Department",
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(user.created_at)}</span>
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{user.department?.name || "-"}
|
||||
</span>
|
||||
),
|
||||
mobileLabel: 'Joined',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
key: "designation",
|
||||
label: "Designation",
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{user.designation?.name || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (user) => (
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>
|
||||
{user.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "auth_provider",
|
||||
label: "Auth Provider",
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{user.auth_provider}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
label: "Joined Date",
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{formatDate(user.created_at)}
|
||||
</span>
|
||||
),
|
||||
mobileLabel: "Joined",
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
align: "right",
|
||||
render: (user) => (
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => handleViewUser(user.id)}
|
||||
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onEdit={() =>
|
||||
handleEditUser(user.id, `${user.first_name} ${user.last_name}`)
|
||||
}
|
||||
onDelete={() =>
|
||||
handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@ -296,29 +348,53 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
<p className="text-xs text-[#6b7280] mt-0.5 truncate">{user.email}</p>
|
||||
<p className="text-xs text-[#6b7280] mt-0.5 truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActionDropdown
|
||||
onView={() => handleViewUser(user.id)}
|
||||
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onEdit={() =>
|
||||
handleEditUser(user.id, `${user.first_name} ${user.last_name}`)
|
||||
}
|
||||
onDelete={() =>
|
||||
handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Status:</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>
|
||||
{user.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Auth Provider:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{user.auth_provider}</p>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{user.auth_provider}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Department:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{user.department?.name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Designation:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{user.designation?.name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Joined:</span>
|
||||
<p className="text-[#6b7280] font-normal mt-1">{formatDate(user.created_at)}</p>
|
||||
<p className="text-[#6b7280] font-normal mt-1">
|
||||
{formatDate(user.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -335,12 +411,12 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
{ value: "", label: "All Status" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "suspended", label: "Suspended" },
|
||||
{ value: "deleted", label: "Deleted" },
|
||||
]}
|
||||
value={statusFilter || ''}
|
||||
value={statusFilter || ""}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(Array.isArray(value) ? null : value || null);
|
||||
setCurrentPage(1);
|
||||
@ -407,7 +483,7 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
setSelectedUserName("");
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
@ -421,7 +497,7 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
onClose={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
setSelectedUserName("");
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete User"
|
||||
@ -447,11 +523,14 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'pending_verification', label: 'Pending Verification' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
{ value: "active", label: "Active" },
|
||||
{
|
||||
value: "pending_verification",
|
||||
label: "Pending Verification",
|
||||
},
|
||||
{ value: "inactive", label: "Inactive" },
|
||||
{ value: "suspended", label: "Suspended" },
|
||||
{ value: "deleted", label: "Deleted" },
|
||||
]}
|
||||
value={statusFilter}
|
||||
onChange={(value) => {
|
||||
@ -465,16 +544,16 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
<FilterDropdown
|
||||
label="Sort by"
|
||||
options={[
|
||||
{ value: ['first_name', 'asc'], label: 'First Name (A-Z)' },
|
||||
{ value: ['first_name', 'desc'], label: 'First Name (Z-A)' },
|
||||
{ value: ['last_name', 'asc'], label: 'Last Name (A-Z)' },
|
||||
{ value: ['last_name', 'desc'], label: 'Last Name (Z-A)' },
|
||||
{ value: ['email', 'asc'], label: 'Email (A-Z)' },
|
||||
{ value: ['email', 'desc'], label: 'Email (Z-A)' },
|
||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
||||
{ value: ["first_name", "asc"], label: "First Name (A-Z)" },
|
||||
{ value: ["first_name", "desc"], label: "First Name (Z-A)" },
|
||||
{ value: ["last_name", "asc"], label: "Last Name (A-Z)" },
|
||||
{ value: ["last_name", "desc"], label: "Last Name (Z-A)" },
|
||||
{ value: ["email", "asc"], label: "Email (A-Z)" },
|
||||
{ value: ["email", "desc"], label: "Email (Z-A)" },
|
||||
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
|
||||
{ value: ["created_at", "desc"], label: "Created (Newest)" },
|
||||
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
|
||||
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
|
||||
]}
|
||||
value={orderBy}
|
||||
onChange={(value) => {
|
||||
@ -564,7 +643,7 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
setSelectedUserName("");
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
@ -577,7 +656,7 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
|
||||
onClose={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
setSelectedUserName("");
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete User"
|
||||
|
||||
@ -5,3 +5,5 @@ export { NewModuleModal } from './NewModuleModal';
|
||||
export { ViewModuleModal } from './ViewModuleModal';
|
||||
export { UsersTable } from './UsersTable';
|
||||
export { RolesTable } from './RolesTable';
|
||||
export { default as DepartmentsTable } from './DepartmentsTable';
|
||||
export { default as DesignationsTable } from './DesignationsTable';
|
||||
|
||||
@ -12,6 +12,9 @@ import {
|
||||
Edit,
|
||||
Settings,
|
||||
Image as ImageIcon,
|
||||
Building2,
|
||||
BadgeCheck,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
@ -28,13 +31,19 @@ import type { Tenant } from '@/types/tenant';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
import type { MyModule } from '@/types/module';
|
||||
import { formatDate } from '@/utils/format-date';
|
||||
import DepartmentsTable from '@/components/superadmin/DepartmentsTable';
|
||||
import DesignationsTable from '@/components/superadmin/DesignationsTable';
|
||||
import UserCategoriesTable from '@/components/superadmin/UserCategoriesTable';
|
||||
|
||||
type TabType = 'overview' | 'users' | 'roles' | 'modules' | 'settings' | 'license' | 'audit-logs' | 'billing';
|
||||
type TabType = 'overview' | 'users' | 'roles' | 'departments' | 'designations' | 'user-categories' | 'modules' | 'settings' | 'license' | 'audit-logs' | 'billing';
|
||||
|
||||
const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [
|
||||
{ id: 'overview', label: 'Overview', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||
{ id: 'roles', label: 'Roles', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'departments', label: 'Departments', icon: <Building2 className="w-4 h-4" /> },
|
||||
{ id: 'designations', label: 'Designations', icon: <BadgeCheck className="w-4 h-4" /> },
|
||||
{ id: 'user-categories', label: 'User Categories', icon: <UserCog className="w-4 h-4" /> },
|
||||
{ id: 'modules', label: 'Modules', icon: <Package className="w-4 h-4" /> },
|
||||
{ id: 'settings', label: 'Settings', icon: <Settings className="w-4 h-4" /> },
|
||||
{ id: 'license', label: 'License', icon: <FileText className="w-4 h-4" /> },
|
||||
@ -278,6 +287,15 @@ const TenantDetails = (): ReactElement => {
|
||||
{activeTab === 'roles' && id && (
|
||||
<RolesTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'departments' && id && (
|
||||
<DepartmentsTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'designations' && id && (
|
||||
<DesignationsTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'user-categories' && id && (
|
||||
<UserCategoriesTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'modules' && id && (
|
||||
<ModulesTab tenantId={id} />
|
||||
)}
|
||||
|
||||
19
src/pages/tenant/Departments.tsx
Normal file
19
src/pages/tenant/Departments.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { type ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { DepartmentsTable } from '@/components/superadmin';
|
||||
|
||||
const Departments = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Departments"
|
||||
pageHeader={{
|
||||
title: 'Department Management',
|
||||
description: 'View and manage all departments within your organization.',
|
||||
}}
|
||||
>
|
||||
<DepartmentsTable />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Departments;
|
||||
19
src/pages/tenant/Designations.tsx
Normal file
19
src/pages/tenant/Designations.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { type ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { DesignationsTable } from '@/components/superadmin';
|
||||
|
||||
const Designations = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Designations"
|
||||
pageHeader={{
|
||||
title: 'Designation Management',
|
||||
description: 'View and manage all designations within your organization.',
|
||||
}}
|
||||
>
|
||||
<DesignationsTable />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Designations;
|
||||
@ -8,6 +8,8 @@ const Settings = lazy(() => import('@/pages/tenant/Settings'));
|
||||
const Users = lazy(() => import('@/pages/tenant/Users'));
|
||||
const AuditLogs = lazy(() => import('@/pages/tenant/AuditLogs'));
|
||||
const Modules = lazy(() => import('@/pages/tenant/Modules'));
|
||||
const Departments = lazy(() => import('@/pages/tenant/Departments'));
|
||||
const Designations = lazy(() => import('@/pages/tenant/Designations'));
|
||||
|
||||
// Loading fallback component
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -54,4 +56,12 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
path: '/tenant/settings',
|
||||
element: <LazyRoute component={Settings} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/departments',
|
||||
element: <LazyRoute component={Departments} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/designations',
|
||||
element: <LazyRoute component={Designations} />,
|
||||
},
|
||||
];
|
||||
|
||||
53
src/services/department-service.ts
Normal file
53
src/services/department-service.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import apiClient from './api-client';
|
||||
import type {
|
||||
DepartmentsResponse,
|
||||
DepartmentResponse,
|
||||
CreateDepartmentRequest,
|
||||
UpdateDepartmentRequest,
|
||||
} from '@/types/department';
|
||||
|
||||
export const departmentService = {
|
||||
list: async (tenantId?: string | null, params?: { active_only?: boolean; search?: string }): Promise<DepartmentsResponse> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.active_only) queryParams.append('active_only', 'true');
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
|
||||
const url = `/departments${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
|
||||
const response = await apiClient.get<DepartmentsResponse>(url, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTree: async (tenantId?: string | null, activeOnly: boolean = false): Promise<DepartmentsResponse> => {
|
||||
const url = `/departments/tree${activeOnly ? '?active_only=true' : ''}`;
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
|
||||
const response = await apiClient.get<DepartmentsResponse>(url, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string, tenantId?: string | null): Promise<DepartmentResponse> => {
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
const response = await apiClient.get<DepartmentResponse>(`/departments/${id}`, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateDepartmentRequest, tenantId?: string | null): Promise<DepartmentResponse> => {
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
const response = await apiClient.post<DepartmentResponse>('/departments', data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateDepartmentRequest, tenantId?: string | null): Promise<DepartmentResponse> => {
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
const response = await apiClient.put<DepartmentResponse>(`/departments/${id}`, data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, tenantId?: string | null): Promise<{ success: boolean; message: string }> => {
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
const response = await apiClient.delete<{ success: boolean; message: string }>(`/departments/${id}`, { headers });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
45
src/services/designation-service.ts
Normal file
45
src/services/designation-service.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import apiClient from './api-client';
|
||||
import type {
|
||||
DesignationsResponse,
|
||||
DesignationResponse,
|
||||
CreateDesignationRequest,
|
||||
UpdateDesignationRequest,
|
||||
} from '@/types/designation';
|
||||
|
||||
export const designationService = {
|
||||
list: async (tenantId?: string | null, params?: { active_only?: boolean; search?: string }): Promise<DesignationsResponse> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.active_only) queryParams.append('active_only', 'true');
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
|
||||
const url = `/designations${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
|
||||
const response = await apiClient.get<DesignationsResponse>(url, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string, tenantId?: string | null): Promise<DesignationResponse> => {
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
const response = await apiClient.get<DesignationResponse>(`/designations/${id}`, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateDesignationRequest, tenantId?: string | null): Promise<DesignationResponse> => {
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
const response = await apiClient.post<DesignationResponse>('/designations', data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateDesignationRequest, tenantId?: string | null): Promise<DesignationResponse> => {
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
const response = await apiClient.put<DesignationResponse>(`/designations/${id}`, data, { headers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string, tenantId?: string | null): Promise<{ success: boolean; message: string }> => {
|
||||
const headers = tenantId ? { 'x-tenant-id': tenantId } : {};
|
||||
const response = await apiClient.delete<{ success: boolean; message: string }>(`/designations/${id}`, { headers });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
53
src/types/department.ts
Normal file
53
src/types/department.ts
Normal file
@ -0,0 +1,53 @@
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
parent_id?: string | null;
|
||||
tenant_id: string;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
// Hierarchical properties if using tree
|
||||
parent_name?: string;
|
||||
child_count?: number;
|
||||
children?: Department[];
|
||||
user_count: string;
|
||||
}
|
||||
|
||||
export interface DepartmentTree extends Department {
|
||||
children: DepartmentTree[];
|
||||
}
|
||||
|
||||
export interface DepartmentsResponse {
|
||||
success: boolean;
|
||||
data: Department[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DepartmentResponse {
|
||||
success: boolean;
|
||||
data: Department;
|
||||
}
|
||||
|
||||
export interface CreateDepartmentRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDepartmentRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
44
src/types/designation.ts
Normal file
44
src/types/designation.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export interface Designation {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
tenant_id: string;
|
||||
is_active: boolean;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
user_count?: number;
|
||||
}
|
||||
|
||||
export interface DesignationsResponse {
|
||||
success: boolean;
|
||||
data: Designation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DesignationResponse {
|
||||
success: boolean;
|
||||
data: Designation;
|
||||
}
|
||||
|
||||
export interface CreateDesignationRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDesignationRequest {
|
||||
name?: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
level?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
@ -15,6 +15,16 @@ export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
department_id?: string;
|
||||
designation_id?: string;
|
||||
department?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
designation?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -40,8 +50,10 @@ export interface CreateUserRequest {
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'deleted';
|
||||
auth_provider: 'local';
|
||||
tenant_id?: string; // Optional - backend handles it automatically for tenant admin users
|
||||
tenant_id?: string;
|
||||
role_id: string;
|
||||
department_id?: string;
|
||||
designation_id?: string;
|
||||
}
|
||||
|
||||
export interface CreateUserResponse {
|
||||
@ -63,6 +75,8 @@ export interface UpdateUserRequest {
|
||||
auth_provider?: string;
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
department_id?: string;
|
||||
designation_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserResponse {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user