diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..38d09b8 --- /dev/null +++ b/SETUP.md @@ -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** diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4e8e903..3ee4237 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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' }, ]; diff --git a/src/components/shared/DepartmentModals.tsx b/src/components/shared/DepartmentModals.tsx new file mode 100644 index 0000000..2df7887 --- /dev/null +++ b/src/components/shared/DepartmentModals.tsx @@ -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; + +const statusOptions = [ + { value: "true", label: "Active" }, + { value: "false", label: "Inactive" }, +]; + +interface NewDepartmentModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: CreateDepartmentRequest) => Promise; + 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({ + 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 = async (data) => { + await onSubmit(data as CreateDepartmentRequest); + }; + + return ( + + + Cancel + + + {isLoading ? "Creating..." : "Create Department"} + + + } + > +
+
+ + +
+ + + +
+ setValue("parent_id", value || null)} + onLoadOptions={loadDepartments} + error={errors.parent_id?.message} + /> + +
+ + setValue("is_active", value === "true")} + error={errors.is_active?.message} + /> + +
+ ); +}; + +interface EditDepartmentModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (id: string, data: UpdateDepartmentRequest) => Promise; + 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({ + 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 = async (data) => { + if (department) { + await onSubmit(department.id, data as UpdateDepartmentRequest); + } + }; + + return ( + + + Cancel + + + {isLoading ? "Updating..." : "Update Department"} + + + } + > +
+
+ + +
+ + + +
+ setValue("parent_id", value || null)} + onLoadOptions={loadDepartments} + error={errors.parent_id?.message} + /> + +
+ + setValue("is_active", value === "true")} + error={errors.is_active?.message} + /> + +
+ ); +}; + +interface ViewDepartmentModalProps { + isOpen: boolean; + onClose: () => void; + department: Department | null; +} + +export const ViewDepartmentModal = ({ + isOpen, + onClose, + department, +}: ViewDepartmentModalProps): ReactElement | null => { + return ( + Close} + > + {department && ( +
+
+

+ Basic Information +

+
+
+

+ Name +

+

{department.name}

+
+
+

+ Code +

+

{department.code}

+
+
+

+ Status +

+

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

+
+
+
+ +
+

+ Hierarchy & Stats +

+
+
+

+ Parent Department +

+

+ {department.parent_name || "-"} +

+
+
+

+ Level +

+

+ {department.level} +

+
+
+

+ Sort Order +

+

+ {department.sort_order} +

+
+
+

+ Sub-departments +

+

+ {department.child_count || 0} +

+
+
+

+ Users +

+

+ {department.user_count || 0} +

+
+
+
+ +
+

+ Description +

+

+ {department.description || "No description provided."} +

+
+ +
+ + Created: {new Date(department.created_at).toLocaleString()} + + | + + Updated: {new Date(department.updated_at).toLocaleString()} + +
+
+ )} +
+ ); +}; diff --git a/src/components/shared/DesignationModals.tsx b/src/components/shared/DesignationModals.tsx new file mode 100644 index 0000000..3a1459e --- /dev/null +++ b/src/components/shared/DesignationModals.tsx @@ -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; + +const statusOptions = [ + { value: "true", label: "Active" }, + { value: "false", label: "Inactive" }, +]; + +interface NewDesignationModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: CreateDesignationRequest) => Promise; + isLoading?: boolean; +} + +export const NewDesignationModal = ({ + isOpen, + onClose, + onSubmit, + isLoading = false, +}: NewDesignationModalProps): ReactElement | null => { + const { + register, + handleSubmit, + setValue, + watch, + reset, + clearErrors, + formState: { errors }, + } = useForm({ + 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 = async (data) => { + await onSubmit(data as CreateDesignationRequest); + }; + + return ( + + + Cancel + + + {isLoading ? "Creating..." : "Create Designation"} + + + } + > +
+
+ + +
+ + + +
+ + +
+ + setValue("is_active", value === "true")} + error={errors.is_active?.message} + /> + +
+ ); +}; + +interface EditDesignationModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (id: string, data: UpdateDesignationRequest) => Promise; + 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({ + 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 = async (data) => { + if (designation) { + await onSubmit(designation.id, data as UpdateDesignationRequest); + } + }; + + return ( + + + Cancel + + + {isLoading ? "Updating..." : "Update Designation"} + + + } + > +
+
+ + +
+ + + +
+ + +
+ + setValue("is_active", value === "true")} + error={errors.is_active?.message} + /> + +
+ ); +}; + +interface ViewDesignationModalProps { + isOpen: boolean; + onClose: () => void; + designation: Designation | null; +} + +export const ViewDesignationModal = ({ + isOpen, + onClose, + designation, +}: ViewDesignationModalProps): ReactElement | null => { + return ( + Close} + > + {designation && ( +
+
+

+ Basic Information +

+
+
+

+ Name +

+

+ {designation.name} +

+
+
+

+ Code +

+

+ {designation.code} +

+
+
+

+ Status +

+

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

+
+
+
+ +
+

+ Hierarchy & Stats +

+
+
+

+ Hierarchy Level +

+

+ {designation.level} +

+
+
+

+ Sort Order +

+

+ {designation.sort_order} +

+
+
+

+ Assigned Users +

+

+ {designation.user_count || 0} +

+
+
+
+ +
+

+ Description +

+

+ {designation.description || "No description provided."} +

+
+ +
+ + Created: {new Date(designation.created_at).toLocaleString()} + + | + + Updated: {new Date(designation.updated_at).toLocaleString()} + +
+
+ )} +
+ ); +}; diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index b15b941..21e1e10 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -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; @@ -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(false); const [loadError, setLoadError] = useState(null); const loadedUserIdRef = useRef(null); - // const [selectedTenantId, setSelectedTenantId] = useState(''); - const [selectedRoleId, setSelectedRoleId] = useState(''); 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(''); - const [currentRoleName, setCurrentRoleName] = useState(''); - // 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(""); - 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 => { 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 => { 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"} } @@ -370,80 +364,87 @@ export const EditUserModal = ({ {!isLoadingUser && (
- {/* General Error Display */} {errors.root && (

{errors.root.message}

)} - {/* Email */} - {/* First Name and Last Name Row */}
- +
+ +
+ setValue("department_id", value)} + onLoadOptions={loadDepartments} + initialOption={initialDepartmentOption || undefined} + error={errors.department_id?.message} + /> + setValue("designation_id", value)} + onLoadOptions={loadDesignations} + initialOption={initialDesignationOption || undefined} + error={errors.designation_id?.message} />
- {/* Tenant and Role Row */}
- {/* {!defaultTenantId && ( - setValue('tenant_id', value)} - onLoadOptions={loadTenants} - initialOption={initialTenantOption || undefined} - error={errors.tenant_id?.message} - /> - )} */} - {currentRoleName !== 'Tenant Admin' && ( + {currentRoleName !== "Tenant Admin" && ( setValue('role_id', value)} + value={roleIdValue || ""} + onValueChange={(value) => setValue("role_id", value)} onLoadOptions={loadRoles} initialOption={initialRoleOption || undefined} error={errors.role_id?.message} /> )} - {/* Status */} setValue('status', value as 'active' | 'suspended' | 'deleted')} + onValueChange={(value) => + setValue( + "status", + value as "active" | "suspended" | "deleted", + ) + } error={errors.status?.message} />
-
)} diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index 1761408..ef427ef 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -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; @@ -39,15 +46,15 @@ type NewUserFormData = z.infer; interface NewUserModalProps { isOpen: boolean; onClose: () => void; - onSubmit: (data: Omit) => Promise; + onSubmit: (data: Omit) => Promise; 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({ 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 => { 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"} } @@ -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")} /> @@ -228,7 +288,7 @@ export const NewUserModal = ({ required placeholder="Enter password" error={errors.password?.message} - {...register('password')} + {...register("password")} /> - {/* Role */} -
+
+ setValue("department_id", value)} + onLoadOptions={loadDepartments} + error={errors.department_id?.message} + /> + + setValue("designation_id", value)} + onLoadOptions={loadDesignations} + error={errors.designation_id?.message} + /> +
+ + {/* Role and Status Row */} +
setValue('role_id', value)} + value={roleIdValue || ""} + onValueChange={(value) => setValue("role_id", value)} onLoadOptions={loadRoles} error={errors.role_id?.message} /> -
- {/* Status */} - setValue('status', value as 'active' | 'suspended' | 'deleted')} - error={errors.status?.message} - /> + + setValue("status", value as "active" | "suspended" | "deleted") + } + error={errors.status?.message} + /> +
diff --git a/src/components/shared/ViewUserModal.tsx b/src/components/shared/ViewUserModal.tsx index 66fa9c3..b0a0bfa 100644 --- a/src/components/shared/ViewUserModal.tsx +++ b/src/components/shared/ViewUserModal.tsx @@ -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={ - + Close } @@ -99,20 +108,28 @@ export const ViewUserModal = ({
{/* Basic Information */}
-

Basic Information

+

+ Basic Information +

- +

{user.email}

- +

{user.first_name} {user.last_name}

- +
{user.status} @@ -120,13 +137,41 @@ export const ViewUserModal = ({
- + +

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

+
+
+ +

+ {user.department?.name || "-"} +

+
+
+ +

+ {user.designation?.name || "-"} +

+
+
+

{user.auth_provider}

- {user.tenant_id && ( + {user.tenant?.name && (
- -

{user.tenant_id}

+ +

{user.tenant.name}

)}
@@ -134,15 +179,25 @@ export const ViewUserModal = ({ {/* Timestamps */}
-

Timestamps

+

+ Timestamps +

- -

{formatDate(user.created_at)}

+ +

+ {formatDate(user.created_at)} +

- -

{formatDate(user.updated_at)}

+ +

+ {formatDate(user.updated_at)} +

diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index d62252c..eb03e97 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -22,4 +22,6 @@ export { EditRoleModal } from './EditRoleModal'; export { ViewAuditLogModal } from './ViewAuditLogModal'; export { PageHeader } from './PageHeader'; export type { TabItem } from './PageHeader'; -export { AuthenticatedImage } from './AuthenticatedImage'; \ No newline at end of file +export { AuthenticatedImage } from './AuthenticatedImage'; +export * from './DepartmentModals'; +export * from './DesignationModals'; \ No newline at end of file diff --git a/src/components/superadmin/DepartmentsTable.tsx b/src/components/superadmin/DepartmentsTable.tsx new file mode 100644 index 0000000..75649ef --- /dev/null +++ b/src/components/superadmin/DepartmentsTable.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Pagination state (Client-side since backend doesn't support it yet) + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(compact ? 10 : 5); + + // Filter state + const [activeOnly, setActiveOnly] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + + // 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(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[] = [ + { + key: "name", + label: "Department Name", + render: (dept) => ( + {dept.name} + ), + }, + { + key: "parent_name", + label: "Parent", + render: (dept) => ( + + {dept.parent_name || "-"} + + ), + }, + { + key: "level", + label: "Level", + render: (dept) => ( + {dept.level} + ), + }, + { + key: "sort_order", + label: "Order", + render: (dept) => ( + {dept.sort_order} + ), + }, + { + key: "child_count", + label: "Sub-depts", + render: (dept) => ( + {dept.child_count || 0} + ), + }, + { + key: "user_count", + label: "Users", + render: (dept) => ( + {dept.user_count || 0} + ), + }, + { + key: "status", + label: "Status", + render: (dept) => ( + + {dept.is_active ? "Active" : "Inactive"} + + ), + }, + { + key: "actions", + label: "Actions", + align: "right", + render: (dept) => ( +
+ { + setSelectedDepartment(dept); + setIsViewModalOpen(true); + }} + onEdit={() => { + setSelectedDepartment(dept); + setIsEditModalOpen(true); + }} + onDelete={() => { + setSelectedDepartment(dept); + setIsDeleteModalOpen(true); + }} + /> +
+ ), + }, + ]; + + return ( +
+ {showHeader && ( +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ setActiveOnly(value === "active")} + /> +
+ setIsNewModalOpen(true)} + > + + New Department + +
+ )} + + dept.id} + isLoading={isLoading} + error={error} + emptyMessage="No departments found" + /> + + {totalItems > 0 && ( + { + setLimit(newLimit); + setCurrentPage(1); + }} + /> + )} + + setIsNewModalOpen(false)} + onSubmit={handleCreate} + isLoading={isActionLoading} + tenantId={effectiveTenantId} + /> + + { + setIsEditModalOpen(false); + setSelectedDepartment(null); + }} + department={selectedDepartment} + onSubmit={handleUpdate} + isLoading={isActionLoading} + tenantId={effectiveTenantId} + /> + + { + setIsViewModalOpen(false); + setSelectedDepartment(null); + }} + department={selectedDepartment} + /> + + { + 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} + /> +
+ ); +}; + +export default DepartmentsTable; diff --git a/src/components/superadmin/DesignationsTable.tsx b/src/components/superadmin/DesignationsTable.tsx new file mode 100644 index 0000000..ddb2a2b --- /dev/null +++ b/src/components/superadmin/DesignationsTable.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(compact ? 10 : 5); + + // Filter state + const [activeOnly, setActiveOnly] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + + // 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(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[] = [ + { + key: "name", + label: "Designation Name", + render: (desig) => ( + {desig.name} + ), + }, + { + key: "code", + label: "Code", + render: (desig) => ( + {desig.code} + ), + }, + { + key: "level", + label: "Level", + render: (desig) => ( + {desig.level} + ), + }, + { + key: "sort_order", + label: "Order", + render: (desig) => ( + {desig.sort_order} + ), + }, + { + key: "user_count", + label: "Users", + render: (desig) => ( + {desig.user_count || 0} + ), + }, + { + key: "status", + label: "Status", + render: (desig) => ( + + {desig.is_active ? "Active" : "Inactive"} + + ), + }, + { + key: "actions", + label: "Actions", + align: "right", + render: (desig) => ( +
+ { + setSelectedDesignation(desig); + setIsViewModalOpen(true); + }} + onEdit={() => { + setSelectedDesignation(desig); + setIsEditModalOpen(true); + }} + onDelete={() => { + setSelectedDesignation(desig); + setIsDeleteModalOpen(true); + }} + /> +
+ ), + }, + ]; + + return ( +
+ {showHeader && ( +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ setActiveOnly(value === "active")} + /> +
+ setIsNewModalOpen(true)} + > + + New Designation + +
+ )} + + desig.id} + isLoading={isLoading} + error={error} + emptyMessage="No designations found" + /> + + {totalItems > 0 && ( + { + setLimit(newLimit); + setCurrentPage(1); + }} + /> + )} + + setIsNewModalOpen(false)} + onSubmit={handleCreate} + isLoading={isActionLoading} + /> + + { + setIsEditModalOpen(false); + setSelectedDesignation(null); + }} + designation={selectedDesignation} + onSubmit={handleUpdate} + isLoading={isActionLoading} + /> + + { + setIsViewModalOpen(false); + setSelectedDesignation(null); + }} + designation={selectedDesignation} + /> + + { + 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} + /> +
+ ); +}; + +export default DesignationsTable; diff --git a/src/components/superadmin/UserCategoriesTable.tsx b/src/components/superadmin/UserCategoriesTable.tsx new file mode 100644 index 0000000..c18f52f --- /dev/null +++ b/src/components/superadmin/UserCategoriesTable.tsx @@ -0,0 +1,9 @@ + +const UserCategoriesTable = ({tenantId, compact=false}: {tenantId: string, compact?: boolean}) => { + console.log(tenantId, compact); + return ( +
UserCategoriesTable
+ ) +} + +export default UserCategoriesTable \ No newline at end of file diff --git a/src/components/superadmin/UsersTable.tsx b/src/components/superadmin/UsersTable.tsx index 7f1f7e7..1f5b327 100644 --- a/src/components/superadmin/UsersTable.tsx +++ b/src/components/superadmin/UsersTable.tsx @@ -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([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -80,7 +86,7 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use const [editModalOpen, setEditModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [selectedUserId, setSelectedUserId] = useState(null); - const [selectedUserName, setSelectedUserName] = useState(''); + const [selectedUserName, setSelectedUserName] = useState(""); const [isUpdating, setIsUpdating] = useState(false); const [isDeleting, setIsDeleting] = useState(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 => { 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 => { 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 => { 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[] = [ { - key: 'name', - label: 'User Name', + key: "name", + label: "User Name", render: (user) => (
@@ -232,50 +250,84 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use
), - mobileLabel: 'Name', + mobileLabel: "Name", }, { - key: 'email', - label: 'Email', - render: (user) => {user.email}, - }, - { - key: 'role', - label: 'role', - render: (user) => {user.role?.name}, - }, - { - key: 'status', - label: 'Status', + key: "email", + label: "Email", render: (user) => ( - {user.status} + {user.email} ), }, { - key: 'auth_provider', - label: 'Auth Provider', + key: "role", + label: "Role", render: (user) => ( - {user.auth_provider} + + {user.role?.name || "-"} + ), }, { - key: 'created_at', - label: 'Joined Date', + key: "department", + label: "Department", render: (user) => ( - {formatDate(user.created_at)} + + {user.department?.name || "-"} + ), - mobileLabel: 'Joined', }, { - key: 'actions', - label: 'Actions', - align: 'right', + key: "designation", + label: "Designation", + render: (user) => ( + + {user.designation?.name || "-"} + + ), + }, + { + key: "status", + label: "Status", + render: (user) => ( + + {user.status} + + ), + }, + { + key: "auth_provider", + label: "Auth Provider", + render: (user) => ( + + {user.auth_provider} + + ), + }, + { + key: "created_at", + label: "Joined Date", + render: (user) => ( + + {formatDate(user.created_at)} + + ), + mobileLabel: "Joined", + }, + { + key: "actions", + label: "Actions", + align: "right", render: (user) => (
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}`) + } />
), @@ -296,29 +348,53 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use

{user.first_name} {user.last_name}

-

{user.email}

+

+ {user.email} +

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}`) + } />
Status:
- {user.status} + + {user.status} +
Auth Provider: -

{user.auth_provider}

+

+ {user.auth_provider} +

+
+
+ Department: +

+ {user.department?.name || "-"} +

+
+
+ Designation: +

+ {user.designation?.name || "-"} +

Joined: -

{formatDate(user.created_at)}

+

+ {formatDate(user.created_at)} +

@@ -335,12 +411,12 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use { 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 { @@ -465,16 +544,16 @@ export const UsersTable = ({ tenantId, showHeader = true, compact = false }: Use { @@ -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" diff --git a/src/components/superadmin/index.ts b/src/components/superadmin/index.ts index 8d49774..3f9810a 100644 --- a/src/components/superadmin/index.ts +++ b/src/components/superadmin/index.ts @@ -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'; diff --git a/src/pages/superadmin/TenantDetails.tsx b/src/pages/superadmin/TenantDetails.tsx index 3dfc94b..0735a28 100644 --- a/src/pages/superadmin/TenantDetails.tsx +++ b/src/pages/superadmin/TenantDetails.tsx @@ -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: }, { id: 'users', label: 'Users', icon: }, { id: 'roles', label: 'Roles', icon: }, + { id: 'departments', label: 'Departments', icon: }, + { id: 'designations', label: 'Designations', icon: }, + { id: 'user-categories', label: 'User Categories', icon: }, { id: 'modules', label: 'Modules', icon: }, { id: 'settings', label: 'Settings', icon: }, { id: 'license', label: 'License', icon: }, @@ -278,6 +287,15 @@ const TenantDetails = (): ReactElement => { {activeTab === 'roles' && id && ( )} + {activeTab === 'departments' && id && ( + + )} + {activeTab === 'designations' && id && ( + + )} + {activeTab === 'user-categories' && id && ( + + )} {activeTab === 'modules' && id && ( )} diff --git a/src/pages/tenant/Departments.tsx b/src/pages/tenant/Departments.tsx new file mode 100644 index 0000000..6498e85 --- /dev/null +++ b/src/pages/tenant/Departments.tsx @@ -0,0 +1,19 @@ +import { type ReactElement } from 'react'; +import { Layout } from '@/components/layout/Layout'; +import { DepartmentsTable } from '@/components/superadmin'; + +const Departments = (): ReactElement => { + return ( + + + + ); +}; + +export default Departments; diff --git a/src/pages/tenant/Designations.tsx b/src/pages/tenant/Designations.tsx new file mode 100644 index 0000000..23dfdc6 --- /dev/null +++ b/src/pages/tenant/Designations.tsx @@ -0,0 +1,19 @@ +import { type ReactElement } from 'react'; +import { Layout } from '@/components/layout/Layout'; +import { DesignationsTable } from '@/components/superadmin'; + +const Designations = (): ReactElement => { + return ( + + + + ); +}; + +export default Designations; diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index edcec6c..65b21ed 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -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: , }, + { + path: '/tenant/departments', + element: , + }, + { + path: '/tenant/designations', + element: , + }, ]; diff --git a/src/services/department-service.ts b/src/services/department-service.ts new file mode 100644 index 0000000..d11b6c6 --- /dev/null +++ b/src/services/department-service.ts @@ -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 => { + 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(url, { headers }); + return response.data; + }, + + getTree: async (tenantId?: string | null, activeOnly: boolean = false): Promise => { + const url = `/departments/tree${activeOnly ? '?active_only=true' : ''}`; + const headers = tenantId ? { 'x-tenant-id': tenantId } : {}; + + const response = await apiClient.get(url, { headers }); + return response.data; + }, + + getById: async (id: string, tenantId?: string | null): Promise => { + const headers = tenantId ? { 'x-tenant-id': tenantId } : {}; + const response = await apiClient.get(`/departments/${id}`, { headers }); + return response.data; + }, + + create: async (data: CreateDepartmentRequest, tenantId?: string | null): Promise => { + const headers = tenantId ? { 'x-tenant-id': tenantId } : {}; + const response = await apiClient.post('/departments', data, { headers }); + return response.data; + }, + + update: async (id: string, data: UpdateDepartmentRequest, tenantId?: string | null): Promise => { + const headers = tenantId ? { 'x-tenant-id': tenantId } : {}; + const response = await apiClient.put(`/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; + }, +}; diff --git a/src/services/designation-service.ts b/src/services/designation-service.ts new file mode 100644 index 0000000..14c111b --- /dev/null +++ b/src/services/designation-service.ts @@ -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 => { + 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(url, { headers }); + return response.data; + }, + + getById: async (id: string, tenantId?: string | null): Promise => { + const headers = tenantId ? { 'x-tenant-id': tenantId } : {}; + const response = await apiClient.get(`/designations/${id}`, { headers }); + return response.data; + }, + + create: async (data: CreateDesignationRequest, tenantId?: string | null): Promise => { + const headers = tenantId ? { 'x-tenant-id': tenantId } : {}; + const response = await apiClient.post('/designations', data, { headers }); + return response.data; + }, + + update: async (id: string, data: UpdateDesignationRequest, tenantId?: string | null): Promise => { + const headers = tenantId ? { 'x-tenant-id': tenantId } : {}; + const response = await apiClient.put(`/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; + }, +}; diff --git a/src/types/department.ts b/src/types/department.ts new file mode 100644 index 0000000..4f9bf36 --- /dev/null +++ b/src/types/department.ts @@ -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; +} diff --git a/src/types/designation.ts b/src/types/designation.ts new file mode 100644 index 0000000..a09182a --- /dev/null +++ b/src/types/designation.ts @@ -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; +} diff --git a/src/types/user.ts b/src/types/user.ts index e3c0dbf..4abb319 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -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 {