feat: Implement Department and Designation management for tenants and superadmins, including UI, services, and user association.

This commit is contained in:
Yashwin 2026-03-12 18:17:03 +05:30
parent e17af04b46
commit b1ac65b345
22 changed files with 2618 additions and 420 deletions

117
SETUP.md Normal file
View 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**

View File

@ -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' },
];

View 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>
);
};

View 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>
);
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -23,3 +23,5 @@ export { ViewAuditLogModal } from './ViewAuditLogModal';
export { PageHeader } from './PageHeader';
export type { TabItem } from './PageHeader';
export { AuthenticatedImage } from './AuthenticatedImage';
export * from './DepartmentModals';
export * from './DesignationModals';

View 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;

View 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;

View 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

View File

@ -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"

View File

@ -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';

View File

@ -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} />
)}

View 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;

View 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;

View File

@ -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} />,
},
];

View 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;
},
};

View 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
View 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
View 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;
}

View File

@ -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 {