refactor: standardize UI components with SearchBox, ActiveOnlyToggle, CodeBadge, and FormTextArea, while updating associated services and pages.
This commit is contained in:
parent
1b97371f73
commit
901dde3362
36
src/components/shared/ActiveOnlyToggle.tsx
Normal file
36
src/components/shared/ActiveOnlyToggle.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||||
|
|
||||||
|
interface ActiveOnlyToggleProps {
|
||||||
|
activeOnly: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActiveOnlyToggle = ({
|
||||||
|
activeOnly,
|
||||||
|
onChange,
|
||||||
|
label = 'Active Only',
|
||||||
|
className = ''
|
||||||
|
}: ActiveOnlyToggleProps): ReactElement => {
|
||||||
|
const { primaryColor } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
|
<span className="text-sm font-medium text-[#475569]">{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(!activeOnly)}
|
||||||
|
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none"
|
||||||
|
style={{ backgroundColor: activeOnly ? primaryColor : '#e2e8f0' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
||||||
|
activeOnly ? 'translate-x-5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
src/components/shared/CodeBadge.tsx
Normal file
19
src/components/shared/CodeBadge.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CodeBadgeProps {
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeBadge({ label, className }: CodeBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-full bg-[#EDF3FE] px-3 py-1 text-sm font-medium text-[#3B82F6]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
PaginatedSelect,
|
PaginatedSelect,
|
||||||
|
FormTextArea,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import type {
|
import type {
|
||||||
Department,
|
Department,
|
||||||
@ -140,11 +141,18 @@ export const NewDepartmentModal = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
{/* <FormField
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="Enter department description"
|
placeholder="Enter department description"
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
{...register("description")}
|
{...register("description")}
|
||||||
|
/> */}
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Enter department description"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@ -320,11 +328,18 @@ export const EditDepartmentModal = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
{/* <FormField
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="Enter department description"
|
placeholder="Enter department description"
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
{...register("description")}
|
{...register("description")}
|
||||||
|
/> */}
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Enter department description"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
FormSelect,
|
FormSelect,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
|
FormTextArea,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import type {
|
import type {
|
||||||
Designation,
|
Designation,
|
||||||
@ -113,11 +114,18 @@ export const NewDesignationModal = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
{/* <FormField
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="Enter designation description"
|
placeholder="Enter designation description"
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
{...register("description")}
|
{...register("description")}
|
||||||
|
/> */}
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Enter designation description"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@ -243,11 +251,18 @@ export const EditDesignationModal = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
{/* <FormField
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="Enter designation description"
|
placeholder="Enter designation description"
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
{...register("description")}
|
{...register("description")}
|
||||||
|
/> */}
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Enter designation description"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
|
FormTextArea,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import type { Role, UpdateRoleRequest } from "@/types/role";
|
import type { Role, UpdateRoleRequest } from "@/types/role";
|
||||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||||
@ -486,12 +487,20 @@ export const EditRoleModal = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<FormField
|
{/* <FormField
|
||||||
label="Description"
|
label="Description"
|
||||||
required
|
required
|
||||||
placeholder="Enter Text Here"
|
placeholder="Enter Text Here"
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
{...register("description")}
|
{...register("description")}
|
||||||
|
/> */}
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
required
|
||||||
|
placeholder="Enter Text Here"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Permissions Section */}
|
{/* Permissions Section */}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
|
FormTextArea,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import type { CreateRoleRequest } from "@/types/role";
|
import type { CreateRoleRequest } from "@/types/role";
|
||||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||||
@ -378,12 +379,20 @@ export const NewRoleModal = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<FormField
|
{/* <FormField
|
||||||
label="Description"
|
label="Description"
|
||||||
required
|
required
|
||||||
placeholder="Enter Text Here"
|
placeholder="Enter Text Here"
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
{...register("description")}
|
{...register("description")}
|
||||||
|
/> */}
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
required
|
||||||
|
placeholder="Enter Text Here"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Permissions Section */}
|
{/* Permissions Section */}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
FormSelect,
|
FormSelect,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
|
ActiveOnlyToggle,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { supplierService } from "@/services/supplier-service";
|
import { supplierService } from "@/services/supplier-service";
|
||||||
@ -23,12 +24,31 @@ const supplierSchema = z.object({
|
|||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
risk_level: z.string().optional(),
|
risk_level: z.string().optional(),
|
||||||
website: z.string().url("Invalid URL").optional().or(z.literal("")),
|
website: z.string().url("Invalid URL").optional().or(z.literal("")),
|
||||||
products_services_input: z.string().min(1, "At least one product/service is required"),
|
products_services_input: z
|
||||||
|
.string()
|
||||||
|
.min(1, "At least one product/service is required"),
|
||||||
|
|
||||||
// Primary Contact
|
// Primary Contact
|
||||||
primary_contact_name: z.string().optional(),
|
primary_contact_name: z.string().optional(),
|
||||||
primary_contact_email: z.string().email("Invalid email").optional().or(z.literal("")),
|
primary_contact_email: z
|
||||||
primary_contact_phone: z.string().optional(),
|
.string()
|
||||||
|
.email("Invalid email")
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("")),
|
||||||
|
// primary_contact_phone: z.string().optional(),
|
||||||
|
primary_contact_phone: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val || val.trim() === "") return true; // Optional field, empty is valid
|
||||||
|
return /^\d{10}$/.test(val);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Phone number must be exactly 10 digits",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Address
|
// Address
|
||||||
address_line1: z.string().optional(),
|
address_line1: z.string().optional(),
|
||||||
@ -44,15 +64,29 @@ const supplierSchema = z.object({
|
|||||||
|
|
||||||
// Quality Agreement
|
// Quality Agreement
|
||||||
quality_agreement_signed: z.boolean().optional(),
|
quality_agreement_signed: z.boolean().optional(),
|
||||||
quality_agreement_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(),
|
quality_agreement_date: z
|
||||||
|
.union([z.date(), z.string(), z.null()])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
|
||||||
// Certifications
|
// Certifications
|
||||||
certifications: z.array(z.object({
|
certifications: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
issuing_body: z.string().optional().or(z.literal("")),
|
issuing_body: z.string().optional().or(z.literal("")),
|
||||||
expiry_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(),
|
expiry_date: z
|
||||||
document_url: z.string().url("Invalid URL").optional().or(z.literal("")),
|
.union([z.date(), z.string(), z.null()])
|
||||||
})).default([]),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
document_url: z
|
||||||
|
.string()
|
||||||
|
.url("Invalid URL")
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SupplierFormData = z.infer<typeof supplierSchema>;
|
type SupplierFormData = z.infer<typeof supplierSchema>;
|
||||||
@ -91,6 +125,7 @@ export const SupplierModal = ({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<SupplierFormData>({
|
} = useForm<SupplierFormData>({
|
||||||
resolver: zodResolver(supplierSchema) as any,
|
resolver: zodResolver(supplierSchema) as any,
|
||||||
@ -152,7 +187,7 @@ export const SupplierModal = ({
|
|||||||
const formatDateForInput = (val: any) => {
|
const formatDateForInput = (val: any) => {
|
||||||
if (!val) return "";
|
if (!val) return "";
|
||||||
const d = new Date(val);
|
const d = new Date(val);
|
||||||
return isNaN(d.getTime()) ? "" : d.toISOString().split('T')[0];
|
return isNaN(d.getTime()) ? "" : d.toISOString().split("T")[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -173,7 +208,9 @@ export const SupplierModal = ({
|
|||||||
status: s.status || "pending",
|
status: s.status || "pending",
|
||||||
risk_level: s.risk_level || "low",
|
risk_level: s.risk_level || "low",
|
||||||
website: s.website || "",
|
website: s.website || "",
|
||||||
products_services_input: Array.isArray(s.products_services) ? s.products_services.join(", ") : "",
|
products_services_input: Array.isArray(s.products_services)
|
||||||
|
? s.products_services.join(", ")
|
||||||
|
: "",
|
||||||
|
|
||||||
primary_contact_name: s.contact?.name || "",
|
primary_contact_name: s.contact?.name || "",
|
||||||
primary_contact_email: s.contact?.email || "",
|
primary_contact_email: s.contact?.email || "",
|
||||||
@ -189,8 +226,12 @@ export const SupplierModal = ({
|
|||||||
tax_id: s.tax_id || "",
|
tax_id: s.tax_id || "",
|
||||||
duns_number: s.duns_number || "",
|
duns_number: s.duns_number || "",
|
||||||
quality_agreement_signed: s.quality_agreement?.signed || false,
|
quality_agreement_signed: s.quality_agreement?.signed || false,
|
||||||
quality_agreement_date: s.quality_agreement?.date ? new Date(s.quality_agreement.date) : null,
|
quality_agreement_date: s.quality_agreement?.date
|
||||||
certifications: Array.isArray(s.certifications) ? s.certifications : [],
|
? new Date(s.quality_agreement.date)
|
||||||
|
: null,
|
||||||
|
certifications: Array.isArray(s.certifications)
|
||||||
|
? s.certifications
|
||||||
|
: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -238,8 +279,11 @@ export const SupplierModal = ({
|
|||||||
const payload: any = {
|
const payload: any = {
|
||||||
...data,
|
...data,
|
||||||
products_services: data.products_services_input
|
products_services: data.products_services_input
|
||||||
? data.products_services_input.split(',').map((item: string) => item.trim()).filter(Boolean)
|
? data.products_services_input
|
||||||
: []
|
.split(",")
|
||||||
|
.map((item: string) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove the UI-specific field before sending
|
// Remove the UI-specific field before sending
|
||||||
@ -258,7 +302,8 @@ export const SupplierModal = ({
|
|||||||
showToast.error(
|
showToast.error(
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? error?.response?.data?.error?.message || "Failed to create supplier"
|
? error?.response?.data?.error?.message || "Failed to create supplier"
|
||||||
: error?.response?.data?.error?.message || "Failed to update supplier",
|
: error?.response?.data?.error?.message ||
|
||||||
|
"Failed to update supplier",
|
||||||
error?.message,
|
error?.message,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@ -270,7 +315,13 @@ export const SupplierModal = ({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={mode === "create" ? "Add New Supplier" : mode === "view" ? "View Supplier" : "Edit Supplier"}
|
title={
|
||||||
|
mode === "create"
|
||||||
|
? "Add New Supplier"
|
||||||
|
: mode === "view"
|
||||||
|
? "View Supplier"
|
||||||
|
: "Edit Supplier"
|
||||||
|
}
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
footer={
|
footer={
|
||||||
<div className="p-3 flex justify-end gap-3">
|
<div className="p-3 flex justify-end gap-3">
|
||||||
@ -300,7 +351,9 @@ export const SupplierModal = ({
|
|||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 p-5">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 p-5">
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Basic Information</h4>
|
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
|
||||||
|
Basic Information
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="Supplier Name"
|
label="Supplier Name"
|
||||||
@ -421,7 +474,9 @@ export const SupplierModal = ({
|
|||||||
|
|
||||||
{/* Products & Services */}
|
{/* Products & Services */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Products & Services</h4>
|
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
|
||||||
|
Products & Services
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="Deliverables"
|
label="Deliverables"
|
||||||
@ -436,7 +491,9 @@ export const SupplierModal = ({
|
|||||||
|
|
||||||
{/* Contact Person */}
|
{/* Contact Person */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Primary Contact Person</h4>
|
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
|
||||||
|
Primary Contact Person
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="Contact Name"
|
label="Contact Name"
|
||||||
@ -452,19 +509,41 @@ export const SupplierModal = ({
|
|||||||
{...register("primary_contact_email")}
|
{...register("primary_contact_email")}
|
||||||
error={errors.primary_contact_email?.message}
|
error={errors.primary_contact_email?.message}
|
||||||
/>
|
/>
|
||||||
<FormField
|
{/* <FormField
|
||||||
label="Contact Phone"
|
label="Contact Phone"
|
||||||
placeholder="+1 (555) 000-0000"
|
placeholder="+1 (555) 000-0000"
|
||||||
disabled={mode === "view" || isSubmitting}
|
disabled={mode === "view" || isSubmitting}
|
||||||
{...register("primary_contact_phone")}
|
{...register("primary_contact_phone")}
|
||||||
error={errors.primary_contact_phone?.message}
|
error={errors.primary_contact_phone?.message}
|
||||||
|
/> */}
|
||||||
|
<FormField
|
||||||
|
label="Contact Phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="Enter 10-digit phone number"
|
||||||
|
maxLength={10}
|
||||||
|
error={
|
||||||
|
errors.primary_contact_phone?.message
|
||||||
|
}
|
||||||
|
{...register("primary_contact_phone", {
|
||||||
|
onChange: (e) => {
|
||||||
|
// Only allow digits and limit to 10 characters
|
||||||
|
const value = e.target.value
|
||||||
|
.replace(/\D/g, "")
|
||||||
|
.slice(0, 10);
|
||||||
|
setValue("primary_contact_phone", value, {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address Information */}
|
{/* Address Information */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Company Address</h4>
|
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
|
||||||
|
Company Address
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="Address Line 1"
|
label="Address Line 1"
|
||||||
@ -513,19 +592,22 @@ export const SupplierModal = ({
|
|||||||
|
|
||||||
{/* Quality Agreement */}
|
{/* Quality Agreement */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Quality Agreement</h4>
|
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
|
||||||
|
Quality Agreement
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<input
|
<Controller
|
||||||
type="checkbox"
|
name="quality_agreement_signed"
|
||||||
id="quality_agreement_signed"
|
control={control}
|
||||||
className="w-4 h-4 text-[#112868] rounded border-gray-300 focus:ring-[#112868]"
|
render={({ field }) => (
|
||||||
disabled={mode === "view" || isSubmitting}
|
<ActiveOnlyToggle
|
||||||
{...register("quality_agreement_signed")}
|
activeOnly={field.value || false}
|
||||||
|
onChange={field.onChange}
|
||||||
|
label="Quality Agreement Signed"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="quality_agreement_signed" className="text-sm font-medium text-gray-700">
|
|
||||||
Quality Agreement Signed
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<Controller
|
<Controller
|
||||||
name="quality_agreement_date"
|
name="quality_agreement_date"
|
||||||
@ -548,11 +630,20 @@ export const SupplierModal = ({
|
|||||||
{/* Certifications Section */}
|
{/* Certifications Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between border-b pb-2">
|
<div className="flex items-center justify-between border-b pb-2">
|
||||||
<h4 className="text-sm font-semibold text-[#112868]">Certifications</h4>
|
<h4 className="text-sm font-semibold text-[#112868]">
|
||||||
|
Certifications
|
||||||
|
</h4>
|
||||||
{mode !== "view" && (
|
{mode !== "view" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => append({ name: "", issuing_body: "", expiry_date: null, document_url: "" })}
|
onClick={() =>
|
||||||
|
append({
|
||||||
|
name: "",
|
||||||
|
issuing_body: "",
|
||||||
|
expiry_date: null,
|
||||||
|
document_url: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
className="flex items-center gap-1 text-[12px] font-medium text-[#112868] hover:opacity-80 transition-opacity"
|
className="flex items-center gap-1 text-[12px] font-medium text-[#112868] hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
@ -563,10 +654,15 @@ export const SupplierModal = ({
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{fields.length === 0 ? (
|
{fields.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 italic py-2">No certifications added.</p>
|
<p className="text-sm text-gray-500 italic py-2">
|
||||||
|
No certifications added.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
fields.map((field, index) => (
|
fields.map((field, index) => (
|
||||||
<div key={field.id} className="p-4 bg-gray-50 rounded-lg border border-gray-100 relative group">
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className="p-4 bg-gray-50 rounded-lg border border-gray-100 relative group"
|
||||||
|
>
|
||||||
{mode !== "view" && (
|
{mode !== "view" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -583,7 +679,10 @@ export const SupplierModal = ({
|
|||||||
required
|
required
|
||||||
disabled={mode === "view" || isSubmitting}
|
disabled={mode === "view" || isSubmitting}
|
||||||
{...register(`certifications.${index}.name`)}
|
{...register(`certifications.${index}.name`)}
|
||||||
error={(errors.certifications?.[index] as any)?.name?.message}
|
error={
|
||||||
|
(errors.certifications?.[index] as any)?.name
|
||||||
|
?.message
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
label="Issuing Body"
|
label="Issuing Body"
|
||||||
@ -601,7 +700,9 @@ export const SupplierModal = ({
|
|||||||
disabled={mode === "view" || isSubmitting}
|
disabled={mode === "view" || isSubmitting}
|
||||||
{...dateField}
|
{...dateField}
|
||||||
value={formatDateForInput(dateField.value)}
|
value={formatDateForInput(dateField.value)}
|
||||||
onChange={(e) => dateField.onChange(e.target.value || null)}
|
onChange={(e) =>
|
||||||
|
dateField.onChange(e.target.value || null)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -610,7 +711,10 @@ export const SupplierModal = ({
|
|||||||
placeholder="Link to certificate"
|
placeholder="Link to certificate"
|
||||||
disabled={mode === "view" || isSubmitting}
|
disabled={mode === "view" || isSubmitting}
|
||||||
{...register(`certifications.${index}.document_url`)}
|
{...register(`certifications.${index}.document_url`)}
|
||||||
error={(errors.certifications?.[index] as any)?.document_url?.message}
|
error={
|
||||||
|
(errors.certifications?.[index] as any)
|
||||||
|
?.document_url?.message
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,18 +5,19 @@ import {
|
|||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
// FilterDropdown,
|
||||||
type Column,
|
type Column,
|
||||||
SupplierModal,
|
SupplierModal,
|
||||||
ViewSupplierModal,
|
ViewSupplierModal,
|
||||||
SupplierContactsModal,
|
SupplierContactsModal,
|
||||||
SupplierScorecardsModal,
|
SupplierScorecardsModal,
|
||||||
|
SearchBox,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, Building2 } from "lucide-react";
|
import { Plus, Building2 } from "lucide-react";
|
||||||
import { supplierService } from "@/services/supplier-service";
|
import { supplierService } from "@/services/supplier-service";
|
||||||
import type { Supplier } from "@/types/supplier";
|
import type { Supplier } from "@/types/supplier";
|
||||||
// import { formatDate } from "@/utils/format-date";
|
// import { formatDate } from "@/utils/format-date";
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
// import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
|
||||||
interface SuppliersTableProps {
|
interface SuppliersTableProps {
|
||||||
tenantId?: string | null;
|
tenantId?: string | null;
|
||||||
@ -60,14 +61,14 @@ export const SuppliersTable = ({
|
|||||||
showHeader = true,
|
showHeader = true,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: SuppliersTableProps): ReactElement => {
|
}: SuppliersTableProps): ReactElement => {
|
||||||
const { primaryColor } = useAppTheme();
|
// const { primaryColor } = useAppTheme();
|
||||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [limit, setLimit] = useState<number>(compact ? 5 : 10);
|
const [limit, setLimit] = useState<number>(compact ? 5 : 10);
|
||||||
const [total, setTotal] = useState<number>(0);
|
const [total, setTotal] = useState<number>(0);
|
||||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
// const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
@ -89,7 +90,7 @@ export const SuppliersTable = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
const response = await supplierService.list({
|
const response = await supplierService.list({
|
||||||
tenantId,
|
tenantId,
|
||||||
status: statusFilter || undefined,
|
// status: statusFilter || undefined,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
limit,
|
limit,
|
||||||
offset: (currentPage - 1) * limit,
|
offset: (currentPage - 1) * limit,
|
||||||
@ -112,7 +113,7 @@ export const SuppliersTable = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSuppliers();
|
fetchSuppliers();
|
||||||
}, [tenantId, currentPage, limit, statusFilter, searchQuery]);
|
}, [tenantId, currentPage, limit, searchQuery]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setModalMode("create");
|
setModalMode("create");
|
||||||
@ -146,7 +147,7 @@ export const SuppliersTable = ({
|
|||||||
const columns: Column<Supplier>[] = [
|
const columns: Column<Supplier>[] = [
|
||||||
{
|
{
|
||||||
key: "name",
|
key: "name",
|
||||||
label: "Supplier",
|
label: "Supplier Name & Code",
|
||||||
render: (supplier) => (
|
render: (supplier) => (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
<div className="w-8 h-8 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||||
@ -185,15 +186,15 @@ export const SuppliersTable = ({
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: "status",
|
// key: "status",
|
||||||
label: "Status",
|
// label: "Status",
|
||||||
render: (supplier) => (
|
// render: (supplier) => (
|
||||||
<StatusBadge variant={getStatusVariant(supplier.status)}>
|
// <StatusBadge variant={getStatusVariant(supplier.status)}>
|
||||||
{supplier.status}
|
// {supplier.status}
|
||||||
</StatusBadge>
|
// </StatusBadge>
|
||||||
),
|
// ),
|
||||||
},
|
// },
|
||||||
// {
|
// {
|
||||||
// key: "location",
|
// key: "location",
|
||||||
// label: "Location",
|
// label: "Location",
|
||||||
@ -268,38 +269,21 @@ export const SuppliersTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 px-4 py-3 bg-white border-b border-[rgba(0,0,0,0.08)]">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 py-3 bg-white border-b border-[rgba(0,0,0,0.08)]">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|
||||||
<div className="relative">
|
<SearchBox
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search suppliers..."
|
|
||||||
className="pl-3 pr-10 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-xs focus:outline-none focus:ring-2 w-full sm:w-64 transition-all"
|
|
||||||
style={{
|
|
||||||
// @ts-ignore
|
|
||||||
'--tw-ring-color': `${primaryColor}33`,
|
|
||||||
borderColor: 'rgba(0,0,0,0.08)'
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = primaryColor;
|
|
||||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(val) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(val);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
|
placeholder="Search by name or code..."
|
||||||
/>
|
/>
|
||||||
</div>
|
{/* <FilterDropdown
|
||||||
<FilterDropdown
|
|
||||||
label="Status"
|
label="Status"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "All Status" },
|
// { value: "", label: "All Status" },
|
||||||
{ value: "approved", label: "Approved" },
|
{ value: "approved", label: "Approved" },
|
||||||
{ value: "qualified", label: "Qualified" },
|
{ value: "qualified", label: "Qualified" },
|
||||||
{ value: "pending", label: "Pending" },
|
{ value: "pending", label: "Pending" },
|
||||||
@ -311,7 +295,7 @@ export const SuppliersTable = ({
|
|||||||
setStatusFilter(val as string);
|
setStatusFilter(val as string);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
|
|||||||
@ -9,27 +9,27 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Clock,
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Modal, SecondaryButton, StatusBadge } from "@/components/shared";
|
import { Modal, SecondaryButton } from "@/components/shared";
|
||||||
import { supplierService } from "@/services/supplier-service";
|
import { supplierService } from "@/services/supplier-service";
|
||||||
import type { Supplier } from "@/types/supplier";
|
import type { Supplier } from "@/types/supplier";
|
||||||
|
|
||||||
// Helper function to get status badge variant
|
// Helper function to get status badge variant
|
||||||
const getStatusVariant = (
|
// const getStatusVariant = (
|
||||||
status: string,
|
// status: string,
|
||||||
): "success" | "failure" | "process" | "info" => {
|
// ): "success" | "failure" | "process" | "info" => {
|
||||||
switch (status.toLowerCase()) {
|
// switch (status.toLowerCase()) {
|
||||||
case "approved":
|
// case "approved":
|
||||||
case "active":
|
// case "active":
|
||||||
return "success";
|
// return "success";
|
||||||
case "rejected":
|
// case "rejected":
|
||||||
case "inactive":
|
// case "inactive":
|
||||||
return "failure";
|
// return "failure";
|
||||||
case "pending":
|
// case "pending":
|
||||||
return "process";
|
// return "process";
|
||||||
default:
|
// default:
|
||||||
return "info";
|
// return "info";
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Helper function to format date
|
// Helper function to format date
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
@ -231,7 +231,7 @@ export const ViewSupplierModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
{/* <div className="shrink-0">
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
variant={getStatusVariant(supplier.status)}
|
variant={getStatusVariant(supplier.status)}
|
||||||
className=" shadow-xl !px-6 !py-2 !text-[12px] font-black"
|
className=" shadow-xl !px-6 !py-2 !text-[12px] font-black"
|
||||||
@ -249,7 +249,7 @@ export const ViewSupplierModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -276,16 +276,18 @@ export const ViewSupplierModal = ({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SectionHeader icon={MapPin} title="Headquarters" colorClass="bg-red-50 text-red-600" />
|
<SectionHeader icon={MapPin} title="Headquarters" colorClass="bg-red-50 text-red-600" />
|
||||||
<div className="p-4 bg-gray-50 rounded-xl space-y-2 border border-gray-100">
|
<div className="p-4 bg-gray-50 rounded-xl space-y-2 border border-gray-100">
|
||||||
{supplier.address ? (
|
{supplier.address && (supplier.address.line1 || supplier.address.city || supplier.address.country) ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm font-bold text-slate-700">{supplier.address.line1}</p>
|
{supplier.address.line1 && <p className="text-sm font-bold text-slate-700">{supplier.address.line1}</p>}
|
||||||
{supplier.address.line2 && <p className="text-sm text-slate-600">{supplier.address.line2}</p>}
|
{supplier.address.line2 && <p className="text-sm text-slate-600">{supplier.address.line2}</p>}
|
||||||
<p className="text-xs font-semibold text-slate-500">
|
<p className="text-xs font-semibold text-slate-500">
|
||||||
{[supplier.address.city, supplier.address.state, supplier.address.postal_code].filter(Boolean).join(', ')}
|
{[supplier.address.city, supplier.address.state, supplier.address.postal_code].filter(Boolean).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
|
{supplier.address.country && (
|
||||||
<p className="text-[11px] font-black text-slate-800 uppercase tracking-wider pt-2 border-t mt-2">
|
<p className="text-[11px] font-black text-slate-800 uppercase tracking-wider pt-2 border-t mt-2">
|
||||||
{supplier.address.country}
|
{supplier.address.country}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-slate-400 italic">No address recorded</p>
|
<p className="text-sm text-slate-400 italic">No address recorded</p>
|
||||||
@ -299,8 +301,8 @@ export const ViewSupplierModal = ({
|
|||||||
{supplier.workflow_instance_id && (
|
{supplier.workflow_instance_id && (
|
||||||
<InfoRow label="Internal Workflow ID" value={supplier.workflow_instance_id} />
|
<InfoRow label="Internal Workflow ID" value={supplier.workflow_instance_id} />
|
||||||
)}
|
)}
|
||||||
<InfoRow label="Approved On" value={supplier.dates?.approved ? formatDate(supplier.dates.approved) : "Pending Approval"} />
|
{/* <InfoRow label="Approved On" value={supplier.dates?.approved ? formatDate(supplier.dates.approved) : "Pending Approval"} />
|
||||||
<InfoRow label="Expiry Date" value={supplier.dates?.expiry ? formatDate(supplier.dates.expiry) : null} />
|
<InfoRow label="Expiry Date" value={supplier.dates?.expiry ? formatDate(supplier.dates.expiry) : null} /> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -321,10 +323,10 @@ export const ViewSupplierModal = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
{/* <div className="space-y-1">
|
||||||
<InfoRow label="Last Audit" value={supplier.dates?.last_audit ? formatDate(supplier.dates.last_audit) : null} />
|
<InfoRow label="Last Audit" value={supplier.dates?.last_audit ? formatDate(supplier.dates.last_audit) : null} />
|
||||||
<InfoRow label="Next Scheduled Audit" value={supplier.dates?.next_audit ? formatDate(supplier.dates.next_audit) : null} />
|
<InfoRow label="Next Scheduled Audit" value={supplier.dates?.next_audit ? formatDate(supplier.dates.next_audit) : null} />
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -363,7 +365,7 @@ export const ViewSupplierModal = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Extended Contacts Section */}
|
{/* Extended Contacts Section */}
|
||||||
{supplier.contacts && supplier.contacts.length > 0 && (
|
{/* {supplier.contacts && supplier.contacts.length > 0 && (
|
||||||
<div className="space-y-4 pt-4 border-t border-gray-100">
|
<div className="space-y-4 pt-4 border-t border-gray-100">
|
||||||
<SectionHeader icon={Building2} title="Associated Contacts" colorClass="bg-slate-100 text-slate-600" />
|
<SectionHeader icon={Building2} title="Associated Contacts" colorClass="bg-slate-100 text-slate-600" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@ -390,7 +392,7 @@ export const ViewSupplierModal = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{/* Operational Narrative */}
|
{/* Operational Narrative */}
|
||||||
{/* <div className="space-y-4">
|
{/* <div className="space-y-4">
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import type { WorkflowDefinition } from "@/types/workflow";
|
|||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import type { RootState } from "@/store/store";
|
import type { RootState } from "@/store/store";
|
||||||
import { formatDate } from "@/utils/format-date";
|
import { formatDate } from "@/utils/format-date";
|
||||||
|
import CodeBadge from "./CodeBadge";
|
||||||
|
|
||||||
interface WorkflowDefinitionsTableProps {
|
interface WorkflowDefinitionsTableProps {
|
||||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||||
@ -206,7 +207,7 @@ const WorkflowDefinitionsTable = ({
|
|||||||
key: "entity_type",
|
key: "entity_type",
|
||||||
label: "Entity Type",
|
label: "Entity Type",
|
||||||
render: (wf) => (
|
render: (wf) => (
|
||||||
<span className="text-sm text-[#6b7280]">{wf.entity_type}</span>
|
<CodeBadge label={wf.entity_type} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -38,4 +38,6 @@ export { FormSlider } from './FormSlider';
|
|||||||
export { RichTextEditor } from './RichTextEditor';
|
export { RichTextEditor } from './RichTextEditor';
|
||||||
export { FileUploadModal } from './FileUploadModal';
|
export { FileUploadModal } from './FileUploadModal';
|
||||||
export type { FileUploadModalProps } from './FileUploadModal';
|
export type { FileUploadModalProps } from './FileUploadModal';
|
||||||
export { FileShareModal } from './FileShareModal';export { SearchBox } from './SearchBox';
|
export { FileShareModal } from './FileShareModal';
|
||||||
|
export { ActiveOnlyToggle } from './ActiveOnlyToggle';
|
||||||
|
export { SearchBox } from './SearchBox';
|
||||||
|
|||||||
53
src/components/superadmin/DepartmentListView.tsx
Normal file
53
src/components/superadmin/DepartmentListView.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { type ReactElement } from "react";
|
||||||
|
import { DataTable, Pagination, type Column } from "@/components/shared";
|
||||||
|
import type { Department } from "@/types/department";
|
||||||
|
|
||||||
|
interface DepartmentListViewProps {
|
||||||
|
data: Department[];
|
||||||
|
columns: Column<Department>[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
limit: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onLimitChange: (limit: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DepartmentListView = ({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems,
|
||||||
|
limit,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}: DepartmentListViewProps): ReactElement => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
data={data}
|
||||||
|
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={onPageChange}
|
||||||
|
onLimitChange={onLimitChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
210
src/components/superadmin/DepartmentTreeView.tsx
Normal file
210
src/components/superadmin/DepartmentTreeView.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { useState, type ReactElement } from "react";
|
||||||
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Folder,
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Department } from "@/types/department";
|
||||||
|
|
||||||
|
interface TreeItemProps {
|
||||||
|
item: Department;
|
||||||
|
level?: number;
|
||||||
|
onAddSub: (item: Department) => void;
|
||||||
|
onEdit: (item: Department) => void;
|
||||||
|
onDelete?: (item: Department) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TreeItem = ({
|
||||||
|
item,
|
||||||
|
level = 0,
|
||||||
|
onAddSub,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: TreeItemProps) => {
|
||||||
|
const { primaryColor } = useAppTheme();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(level === 0);
|
||||||
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div
|
||||||
|
className={`group flex items-center py-2.5 px-4 rounded-lg transition-all duration-200 ${
|
||||||
|
level === 0
|
||||||
|
? "text-white shadow-md"
|
||||||
|
: "text-[#0f1724] hover:bg-[#f8fafc] border border-transparent hover:border-[#e2e8f0]"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: level === 0 ? primaryColor : undefined,
|
||||||
|
marginLeft: level > 0 ? `${level * 28}px` : "0",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-center w-5 h-5">
|
||||||
|
{hasChildren && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}}
|
||||||
|
className={`p-0.5 rounded transition-colors ${
|
||||||
|
level === 0
|
||||||
|
? "hover:bg-white/10 text-white/70"
|
||||||
|
: "hover:bg-gray-100 text-[#94a3b8]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`p-1.5 rounded-md ${level === 0 ? "bg-white/10" : "bg-transparent"}`}
|
||||||
|
>
|
||||||
|
<Folder
|
||||||
|
className={`w-4 h-4 shrink-0 ${level === 0 ? "text-white" : "text-[#64748b]"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium truncate ${level === 0 ? "text-white" : "text-[#1e293b]"}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.5 rounded text-[10px] font-bold font-mono shrink-0 uppercase ${
|
||||||
|
level === 0
|
||||||
|
? "bg-white/20 text-white"
|
||||||
|
: "bg-[#f1f5f9] text-[#475569]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.code}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{level === 0 ? (
|
||||||
|
<span className="text-xs text-white/60 font-normal ml-2">
|
||||||
|
{item.user_count || 0} total
|
||||||
|
</span>
|
||||||
|
) : hasChildren ? (
|
||||||
|
<span className="text-xs text-[#94a3b8] font-normal ml-2">
|
||||||
|
{item.child_count || 0} sub-departments
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200 ${level === 0 ? "text-white" : "text-[#64748b]"}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddSub(item)}
|
||||||
|
className={`p-1.5 rounded transition-colors ${level === 0 ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
|
||||||
|
title="Add Sub-department"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
className={`p-1.5 rounded transition-colors ${level === 0 ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
className={`p-1.5 rounded transition-colors ${level === 0 ? "hover:bg-white/10 text-white/70" : "hover:bg-red-50 hover:text-red-600"}`}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && hasChildren && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{item.children?.map((child) => (
|
||||||
|
<TreeItem
|
||||||
|
key={child.id}
|
||||||
|
item={child}
|
||||||
|
level={level + 1}
|
||||||
|
onAddSub={onAddSub}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DepartmentTreeViewProps {
|
||||||
|
data: Department[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onAddSub: (item: Department) => void;
|
||||||
|
onEdit: (item: Department) => void;
|
||||||
|
onDelete?: (item: Department) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DepartmentTreeView = ({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onAddSub,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: DepartmentTreeViewProps): ReactElement => {
|
||||||
|
const { primaryColor } = useAppTheme();
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div
|
||||||
|
className="animate-spin rounded-full h-8 w-8 border-b-2"
|
||||||
|
style={{ borderBottomColor: primaryColor }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-red-500 py-10 min-h-[400px]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-gray-500 py-10 min-h-[400px]">
|
||||||
|
No departments found in hierarchy
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 flex flex-col gap-2 min-h-[400px]">
|
||||||
|
{data.map((item) => (
|
||||||
|
<TreeItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onAddSub={onAddSub}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,13 +1,15 @@
|
|||||||
import { useState, useEffect, type ReactElement } from "react";
|
import { useState, useEffect, useImperativeHandle, forwardRef, type ReactElement } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
// PrimaryButton,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
DataTable,
|
// DataTable,
|
||||||
Pagination,
|
// Pagination,
|
||||||
FilterDropdown,
|
// FilterDropdown,
|
||||||
// DeleteConfirmationModal,
|
// DeleteConfirmationModal,
|
||||||
|
SearchBox,
|
||||||
|
ActiveOnlyToggle,
|
||||||
type Column,
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import {
|
import {
|
||||||
@ -15,8 +17,10 @@ import {
|
|||||||
EditDepartmentModal,
|
EditDepartmentModal,
|
||||||
ViewDepartmentModal,
|
ViewDepartmentModal,
|
||||||
} from "@/components/shared/DepartmentModals";
|
} from "@/components/shared/DepartmentModals";
|
||||||
import { Plus, Search } from "lucide-react";
|
// import { Plus } from "lucide-react";
|
||||||
import { departmentService } from "@/services/department-service";
|
import { departmentService } from "@/services/department-service";
|
||||||
|
import { DepartmentListView } from "./DepartmentListView";
|
||||||
|
import { DepartmentTreeView } from "./DepartmentTreeView";
|
||||||
import type {
|
import type {
|
||||||
Department,
|
Department,
|
||||||
CreateDepartmentRequest,
|
CreateDepartmentRequest,
|
||||||
@ -25,6 +29,7 @@ import type {
|
|||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import type { RootState } from "@/store/store";
|
import type { RootState } from "@/store/store";
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
import CodeBadge from "../shared/CodeBadge";
|
||||||
|
|
||||||
interface DepartmentsTableProps {
|
interface DepartmentsTableProps {
|
||||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||||
@ -32,18 +37,24 @@ interface DepartmentsTableProps {
|
|||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DepartmentsTable = ({
|
export interface DepartmentsTableRef {
|
||||||
|
openNewModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DepartmentsTable = forwardRef<DepartmentsTableRef, DepartmentsTableProps>(({
|
||||||
tenantId: propsTenantId,
|
tenantId: propsTenantId,
|
||||||
compact = false,
|
compact = false,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
}: DepartmentsTableProps): ReactElement => {
|
}: DepartmentsTableProps, ref): ReactElement => {
|
||||||
const { primaryColor } = useAppTheme();
|
const { primaryColor } = useAppTheme();
|
||||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||||
const effectiveTenantId = propsTenantId || reduxTenantId;
|
const effectiveTenantId = propsTenantId || reduxTenantId;
|
||||||
|
|
||||||
const [departments, setDepartments] = useState<Department[]>([]);
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
|
const [treeData, setTreeData] = useState<Department[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'tree'>('list');
|
||||||
|
|
||||||
// Pagination state (Client-side since backend doesn't support it yet)
|
// Pagination state (Client-side since backend doesn't support it yet)
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
@ -63,10 +74,16 @@ const DepartmentsTable = ({
|
|||||||
useState<Department | null>(null);
|
useState<Department | null>(null);
|
||||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Expose methods to parent
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openNewModal: () => setIsNewModalOpen(true),
|
||||||
|
}));
|
||||||
|
|
||||||
const fetchDepartments = async () => {
|
const fetchDepartments = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (viewMode === 'list') {
|
||||||
const response = await departmentService.list(effectiveTenantId, {
|
const response = await departmentService.list(effectiveTenantId, {
|
||||||
active_only: activeOnly,
|
active_only: activeOnly,
|
||||||
search: debouncedSearchQuery,
|
search: debouncedSearchQuery,
|
||||||
@ -76,6 +93,14 @@ const DepartmentsTable = ({
|
|||||||
} else {
|
} else {
|
||||||
setError("Failed to load departments");
|
setError("Failed to load departments");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const response = await departmentService.getTree(effectiveTenantId, activeOnly);
|
||||||
|
if (response.success) {
|
||||||
|
setTreeData(response.data);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load department tree");
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(
|
setError(
|
||||||
err?.response?.data?.error?.message || "Failed to load departments",
|
err?.response?.data?.error?.message || "Failed to load departments",
|
||||||
@ -96,7 +121,7 @@ const DepartmentsTable = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDepartments();
|
fetchDepartments();
|
||||||
}, [effectiveTenantId, activeOnly, debouncedSearchQuery]);
|
}, [effectiveTenantId, activeOnly, debouncedSearchQuery, viewMode]);
|
||||||
|
|
||||||
const handleCreate = async (data: CreateDepartmentRequest) => {
|
const handleCreate = async (data: CreateDepartmentRequest) => {
|
||||||
try {
|
try {
|
||||||
@ -176,6 +201,13 @@ const DepartmentsTable = ({
|
|||||||
<span className="text-sm font-medium text-[#0f1724]">{dept.name}</span>
|
<span className="text-sm font-medium text-[#0f1724]">{dept.name}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "code",
|
||||||
|
label: "Code",
|
||||||
|
render: (dept) => (
|
||||||
|
<CodeBadge label={dept.code} />
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "parent_name",
|
key: "parent_name",
|
||||||
label: "Parent",
|
label: "Parent",
|
||||||
@ -237,10 +269,6 @@ const DepartmentsTable = ({
|
|||||||
setSelectedDepartment(dept);
|
setSelectedDepartment(dept);
|
||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
}}
|
}}
|
||||||
// onDelete={() => {
|
|
||||||
// setSelectedDepartment(dept);
|
|
||||||
// setIsDeleteModalOpen(true);
|
|
||||||
// }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -252,63 +280,66 @@ const DepartmentsTable = ({
|
|||||||
className={`flex flex-col gap-4 ${!compact ? "bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-sm" : ""}`}
|
className={`flex flex-col gap-4 ${!compact ? "bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-sm" : ""}`}
|
||||||
>
|
>
|
||||||
{showHeader && (
|
{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 flex-col border-b border-[rgba(0,0,0,0.08)]">
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
{/* Tabs */}
|
||||||
<div className="relative flex-1 sm:w-64">
|
<div className="px-4 pt-3 flex items-center justify-between border-b border-transparent">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
|
<div className="flex items-center gap-6">
|
||||||
<input
|
<button
|
||||||
type="text"
|
className="pb-3 text-sm font-medium transition-all relative"
|
||||||
placeholder="Search departments..."
|
style={{ color: viewMode === 'list' ? primaryColor : '#64748b' }}
|
||||||
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-2 transition-all"
|
onClick={() => setViewMode('list')}
|
||||||
style={{
|
|
||||||
// @ts-ignore
|
|
||||||
'--tw-ring-color': `${primaryColor}33`,
|
|
||||||
borderColor: 'rgba(0,0,0,0.08)'
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = primaryColor;
|
|
||||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
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" />
|
List View
|
||||||
<span>New Department</span>
|
{viewMode === 'list' && (
|
||||||
</PrimaryButton>
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-0.5"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pb-3 text-sm font-medium transition-all relative"
|
||||||
|
style={{ color: viewMode === 'tree' ? primaryColor : '#64748b' }}
|
||||||
|
onClick={() => setViewMode('tree')}
|
||||||
|
>
|
||||||
|
Tree View
|
||||||
|
{viewMode === 'tree' && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-0.5"
|
||||||
|
style={{ backgroundColor: primaryColor }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Only Toggle */}
|
||||||
|
<ActiveOnlyToggle
|
||||||
|
activeOnly={activeOnly}
|
||||||
|
onChange={setActiveOnly}
|
||||||
|
className="pb-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<SearchBox
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
placeholder="Search by name or code..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DataTable
|
{viewMode === 'list' ? (
|
||||||
|
<DepartmentListView
|
||||||
data={paginatedData}
|
data={paginatedData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
keyExtractor={(dept) => dept.id}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
emptyMessage="No departments found"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{totalItems > 0 && (
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalItems={totalItems}
|
totalItems={totalItems}
|
||||||
@ -319,6 +350,20 @@ const DepartmentsTable = ({
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<DepartmentTreeView
|
||||||
|
data={treeData}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
onAddSub={(item) => {
|
||||||
|
setSelectedDepartment(item);
|
||||||
|
setIsNewModalOpen(true);
|
||||||
|
}}
|
||||||
|
onEdit={(item) => {
|
||||||
|
setSelectedDepartment(item);
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<NewDepartmentModal
|
<NewDepartmentModal
|
||||||
@ -364,6 +409,4 @@ const DepartmentsTable = ({
|
|||||||
/> */}
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default DepartmentsTable;
|
|
||||||
|
|||||||
@ -6,16 +6,19 @@ import {
|
|||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
|
||||||
// DeleteConfirmationModal,
|
|
||||||
type Column,
|
type Column,
|
||||||
|
SearchBox,
|
||||||
|
ActiveOnlyToggle,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import {
|
import {
|
||||||
NewDesignationModal,
|
NewDesignationModal,
|
||||||
EditDesignationModal,
|
EditDesignationModal,
|
||||||
ViewDesignationModal,
|
ViewDesignationModal,
|
||||||
} from "@/components/shared/DesignationModals";
|
} from "@/components/shared/DesignationModals";
|
||||||
import { Plus, Search } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
// , Search
|
||||||
|
} from "lucide-react";
|
||||||
import { designationService } from "@/services/designation-service";
|
import { designationService } from "@/services/designation-service";
|
||||||
import type {
|
import type {
|
||||||
Designation,
|
Designation,
|
||||||
@ -24,7 +27,8 @@ import type {
|
|||||||
} from "@/types/designation";
|
} from "@/types/designation";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import type { RootState } from "@/store/store";
|
import type { RootState } from "@/store/store";
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
// import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
import CodeBadge from "../shared/CodeBadge";
|
||||||
|
|
||||||
interface DesignationsTableProps {
|
interface DesignationsTableProps {
|
||||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||||
@ -37,7 +41,7 @@ const DesignationsTable = ({
|
|||||||
compact = false,
|
compact = false,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
}: DesignationsTableProps): ReactElement => {
|
}: DesignationsTableProps): ReactElement => {
|
||||||
const { primaryColor } = useAppTheme();
|
// const { primaryColor } = useAppTheme();
|
||||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||||
const effectiveTenantId = propsTenantId || reduxTenantId;
|
const effectiveTenantId = propsTenantId || reduxTenantId;
|
||||||
|
|
||||||
@ -179,9 +183,7 @@ const DesignationsTable = ({
|
|||||||
{
|
{
|
||||||
key: "code",
|
key: "code",
|
||||||
label: "Code",
|
label: "Code",
|
||||||
render: (desig) => (
|
render: (desig) => <CodeBadge label={desig.code} />,
|
||||||
<span className="text-sm text-[#6b7280]">{desig.code}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "level",
|
key: "level",
|
||||||
@ -245,37 +247,14 @@ const DesignationsTable = ({
|
|||||||
{showHeader && (
|
{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="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="flex items-center gap-3 w-full sm:w-auto">
|
||||||
<div className="relative flex-1 sm:w-64">
|
<SearchBox
|
||||||
<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-2 transition-all"
|
|
||||||
style={{
|
|
||||||
// @ts-ignore
|
|
||||||
'--tw-ring-color': `${primaryColor}33`,
|
|
||||||
borderColor: 'rgba(0,0,0,0.08)'
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = primaryColor;
|
|
||||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={setSearchQuery}
|
||||||
|
placeholder="Search designations..."
|
||||||
/>
|
/>
|
||||||
</div>
|
<ActiveOnlyToggle
|
||||||
<FilterDropdown
|
activeOnly={activeOnly}
|
||||||
label="Status"
|
onChange={(val) => setActiveOnly(val)}
|
||||||
options={[
|
|
||||||
{ value: "all", label: "All Status" },
|
|
||||||
{ value: "active", label: "Active Only" },
|
|
||||||
]}
|
|
||||||
value={activeOnly ? "active" : "all"}
|
|
||||||
onChange={(value) => setActiveOnly(value === "active")}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { ReactElement } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared';
|
import { Modal, FormField, FormTextArea, PrimaryButton, SecondaryButton } from '@/components/shared';
|
||||||
import { Copy, Check, Loader2 } from 'lucide-react';
|
import { Copy, Check, Loader2 } from 'lucide-react';
|
||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from '@/utils/toast';
|
||||||
import type { Module, UpdateModuleRequest } from '@/types/module';
|
import type { Module, UpdateModuleRequest } from '@/types/module';
|
||||||
@ -204,26 +204,28 @@ export const EditModuleModal = ({
|
|||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-6">
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-6">
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Basic Information</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Basic Information</h3>
|
||||||
<div className="grid grid-cols-2 gap-5">
|
{/* <div className="grid grid-cols-2 gap-5"> */}
|
||||||
<FormField
|
<FormField
|
||||||
label="Module Name"
|
label="Module Name"
|
||||||
required
|
required
|
||||||
placeholder="Enter module name"
|
placeholder="Enter module name"
|
||||||
error={errors.name?.message}
|
error={errors.name?.message}
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
|
// helperText="Display name (3-100 chars)."
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
{/* <div className="flex flex-col gap-2">
|
||||||
<label className="text-[13px] font-medium text-[#0e1b2a]">Module ID (Read Only)</label>
|
<label className="text-[13px] font-medium text-[#0e1b2a]">Module ID (Read Only)</label>
|
||||||
<div className="h-10 px-3.5 py-2 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-gray-500 font-mono">
|
<div className="h-10 px-3.5 py-2 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-gray-500 font-mono">
|
||||||
{module.module_id}
|
{module.module_id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
{/* </div> */}
|
||||||
<FormField
|
<FormTextArea
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="Enter module description"
|
placeholder="Enter module description"
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -1,71 +1,122 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from "react";
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared';
|
import {
|
||||||
import { Copy, Check } from 'lucide-react';
|
Modal,
|
||||||
import { showToast } from '@/utils/toast';
|
FormField,
|
||||||
|
PrimaryButton,
|
||||||
|
SecondaryButton,
|
||||||
|
FormTextArea,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { Copy, Check } from "lucide-react";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
|
||||||
// Validation schema - matches backend validation
|
// Validation schema - matches backend validation
|
||||||
const newModuleSchema = z.object({
|
const newModuleSchema = z.object({
|
||||||
module_id: z
|
module_id: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'module_id is required')
|
.min(1, "module_id is required")
|
||||||
.min(3, 'module_id must be at least 3 characters')
|
.min(3, "module_id must be at least 3 characters")
|
||||||
.max(100, 'module_id must be at most 100 characters'),
|
.max(100, "module_id must be at most 100 characters"),
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'name is required')
|
.min(1, "name is required")
|
||||||
.min(3, 'name must be at least 3 characters')
|
.min(3, "name must be at least 3 characters")
|
||||||
.max(100, 'name must be at most 100 characters'),
|
.max(100, "name must be at most 100 characters"),
|
||||||
description: z.string().max(1000, 'description must be at most 1000 characters').optional().nullable(),
|
description: z
|
||||||
|
.string()
|
||||||
|
.max(1000, "description must be at most 1000 characters")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
version: z
|
version: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'version is required')
|
.min(1, "version is required")
|
||||||
.max(20, 'version must be at most 20 characters')
|
.max(20, "version must be at most 20 characters")
|
||||||
.regex(/^[0-9]+\.[0-9]+\.[0-9]+$/, 'version format is invalid (must be X.Y.Z)'),
|
.regex(
|
||||||
|
/^[0-9]+\.[0-9]+\.[0-9]+$/,
|
||||||
|
"version format is invalid (must be X.Y.Z)",
|
||||||
|
),
|
||||||
runtime_language: z
|
runtime_language: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'runtime_language is required')
|
.min(1, "runtime_language is required")
|
||||||
.max(50, 'runtime_language must be at most 50 characters'),
|
.max(50, "runtime_language must be at most 50 characters"),
|
||||||
framework: z.string().max(50, 'framework must be at most 50 characters').optional().nullable(),
|
framework: z
|
||||||
|
.string()
|
||||||
|
.max(50, "framework must be at most 50 characters")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
webhookurl: z
|
webhookurl: z
|
||||||
.union([
|
.union([
|
||||||
z.string().url("Invalid URL format").max(500, "webhookurl must be at most 500 characters"),
|
z
|
||||||
|
.string()
|
||||||
|
.url("Invalid URL format")
|
||||||
|
.max(500, "webhookurl must be at most 500 characters"),
|
||||||
z.literal("").transform(() => null),
|
z.literal("").transform(() => null),
|
||||||
z.null(),
|
z.null(),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
sync_webhook_url: z
|
sync_webhook_url: z
|
||||||
.union([
|
.union([
|
||||||
z.string().url("Invalid URL format").max(500, "sync_webhook_url must be at most 500 characters"),
|
z
|
||||||
|
.string()
|
||||||
|
.url("Invalid URL format")
|
||||||
|
.max(500, "sync_webhook_url must be at most 500 characters"),
|
||||||
z.literal("").transform(() => null),
|
z.literal("").transform(() => null),
|
||||||
z.null(),
|
z.null(),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
frontend_base_url: z
|
frontend_base_url: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'frontend_base_url is required')
|
.min(1, "frontend_base_url is required")
|
||||||
.max(255, 'frontend_base_url must be at most 255 characters')
|
.max(255, "frontend_base_url must be at most 255 characters")
|
||||||
.url('Invalid URL format'),
|
.url("Invalid URL format"),
|
||||||
backend_base_url: z
|
backend_base_url: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'backend_base_url is required')
|
.min(1, "backend_base_url is required")
|
||||||
.max(255, 'backend_base_url must be at most 255 characters')
|
.max(255, "backend_base_url must be at most 255 characters")
|
||||||
.url('Invalid URL format'),
|
.url("Invalid URL format"),
|
||||||
health_endpoint: z
|
health_endpoint: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'health_endpoint is required')
|
.min(1, "health_endpoint is required")
|
||||||
.max(255, 'health_endpoint must be at most 255 characters'),
|
.max(255, "health_endpoint must be at most 255 characters"),
|
||||||
endpoints: z.any().optional().nullable(),
|
endpoints: z.any().optional().nullable(),
|
||||||
kafka_topics: z.any().optional().nullable(),
|
kafka_topics: z.any().optional().nullable(),
|
||||||
cpu_request: z.string().max(20, 'cpu_request must be at most 20 characters').optional().nullable(),
|
cpu_request: z
|
||||||
cpu_limit: z.string().max(20, 'cpu_limit must be at most 20 characters').optional().nullable(),
|
.string()
|
||||||
memory_request: z.string().max(20, 'memory_request must be at most 20 characters').optional().nullable(),
|
.max(20, "cpu_request must be at most 20 characters")
|
||||||
memory_limit: z.string().max(20, 'memory_limit must be at most 20 characters').optional().nullable(),
|
.optional()
|
||||||
min_replicas: z.number().int().min(1, 'min_replicas must be at least 1').max(50, 'min_replicas must be at most 50').optional().nullable(),
|
.nullable(),
|
||||||
max_replicas: z.number().int().min(1, 'max_replicas must be at least 1').max(50, 'max_replicas must be at most 50').optional().nullable(),
|
cpu_limit: z
|
||||||
|
.string()
|
||||||
|
.max(20, "cpu_limit must be at most 20 characters")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
memory_request: z
|
||||||
|
.string()
|
||||||
|
.max(20, "memory_request must be at most 20 characters")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
memory_limit: z
|
||||||
|
.string()
|
||||||
|
.max(20, "memory_limit must be at most 20 characters")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
min_replicas: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, "min_replicas must be at least 1")
|
||||||
|
.max(50, "min_replicas must be at most 50")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
max_replicas: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, "max_replicas must be at least 1")
|
||||||
|
.max(50, "max_replicas must be at most 50")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
last_health_check: z.string().optional().nullable(),
|
last_health_check: z.string().optional().nullable(),
|
||||||
consecutive_failures: z.number().int().optional().nullable(),
|
consecutive_failures: z.number().int().optional().nullable(),
|
||||||
registered_by: z.uuid().optional().nullable(),
|
registered_by: z.uuid().optional().nullable(),
|
||||||
@ -125,16 +176,16 @@ export const NewModuleModal = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
reset({
|
reset({
|
||||||
module_id: '',
|
module_id: "",
|
||||||
name: '',
|
name: "",
|
||||||
description: null,
|
description: null,
|
||||||
version: '',
|
version: "",
|
||||||
runtime_language: '',
|
runtime_language: "",
|
||||||
framework: null,
|
framework: null,
|
||||||
webhookurl: null,
|
webhookurl: null,
|
||||||
frontend_base_url: '',
|
frontend_base_url: "",
|
||||||
backend_base_url: '',
|
backend_base_url: "",
|
||||||
health_endpoint: '',
|
health_endpoint: "",
|
||||||
endpoints: null,
|
endpoints: null,
|
||||||
kafka_topics: null,
|
kafka_topics: null,
|
||||||
cpu_request: null,
|
cpu_request: null,
|
||||||
@ -164,38 +215,75 @@ export const NewModuleModal = ({
|
|||||||
if (response?.api_key?.key) {
|
if (response?.api_key?.key) {
|
||||||
setApiKey(response.api_key.key);
|
setApiKey(response.api_key.key);
|
||||||
showToast.success(
|
showToast.success(
|
||||||
'Module registered successfully',
|
"Module registered successfully",
|
||||||
'Your API key has been generated. Please copy and store it securely - this is the only time you will see it.'
|
"Your API key has been generated. Please copy and store it securely - this is the only time you will see it.",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
showToast.success('Module registered successfully');
|
showToast.success("Module registered successfully");
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Handle validation errors from API
|
// 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;
|
const validationErrors = error.response.data.details;
|
||||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
validationErrors.forEach(
|
||||||
if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'webhookurl' || detail.path === 'sync_webhook_url' || detail.path === 'frontend_base_url' || detail.path === 'backend_base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') {
|
(detail: { path: string; message: string }) => {
|
||||||
|
if (
|
||||||
|
detail.path === "name" ||
|
||||||
|
detail.path === "module_id" ||
|
||||||
|
detail.path === "description" ||
|
||||||
|
detail.path === "version" ||
|
||||||
|
detail.path === "runtime_language" ||
|
||||||
|
detail.path === "framework" ||
|
||||||
|
detail.path === "webhookurl" ||
|
||||||
|
detail.path === "sync_webhook_url" ||
|
||||||
|
detail.path === "frontend_base_url" ||
|
||||||
|
detail.path === "backend_base_url" ||
|
||||||
|
detail.path === "health_endpoint" ||
|
||||||
|
detail.path === "endpoints" ||
|
||||||
|
detail.path === "kafka_topics" ||
|
||||||
|
detail.path === "cpu_request" ||
|
||||||
|
detail.path === "cpu_limit" ||
|
||||||
|
detail.path === "memory_request" ||
|
||||||
|
detail.path === "memory_limit" ||
|
||||||
|
detail.path === "min_replicas" ||
|
||||||
|
detail.path === "max_replicas" ||
|
||||||
|
detail.path === "last_health_check" ||
|
||||||
|
detail.path === "consecutive_failures" ||
|
||||||
|
detail.path === "registered_by" ||
|
||||||
|
detail.path === "tenant_id" ||
|
||||||
|
detail.path === "metadata"
|
||||||
|
) {
|
||||||
setError(detail.path as keyof NewModuleFormData, {
|
setError(detail.path as keyof NewModuleFormData, {
|
||||||
type: 'server',
|
type: "server",
|
||||||
message: detail.message,
|
message: detail.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Handle general errors
|
// Handle general errors
|
||||||
// Check for nested error object with message property
|
// Check for nested error object with message property
|
||||||
const errorObj = error?.response?.data?.error;
|
const errorObj = error?.response?.data?.error;
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
|
(typeof errorObj === "object" &&
|
||||||
(typeof errorObj === 'string' ? errorObj : null) ||
|
errorObj !== null &&
|
||||||
|
"message" in errorObj
|
||||||
|
? errorObj.message
|
||||||
|
: null) ||
|
||||||
|
(typeof errorObj === "string" ? errorObj : null) ||
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message ||
|
||||||
error?.message ||
|
error?.message ||
|
||||||
'Failed to create module. Please try again.';
|
"Failed to create module. Please try again.";
|
||||||
setError('root', {
|
setError("root", {
|
||||||
type: 'server',
|
type: "server",
|
||||||
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create module. Please try again.',
|
message:
|
||||||
|
typeof errorMessage === "string"
|
||||||
|
? errorMessage
|
||||||
|
: "Failed to create module. Please try again.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -207,9 +295,9 @@ export const NewModuleModal = ({
|
|||||||
await navigator.clipboard.writeText(apiKey);
|
await navigator.clipboard.writeText(apiKey);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
showToast.success('API key copied to clipboard');
|
showToast.success("API key copied to clipboard");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast.error('Failed to copy API key');
|
showToast.error("Failed to copy API key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -235,7 +323,7 @@ export const NewModuleModal = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-4 py-2.5 text-sm"
|
className="px-4 py-2.5 text-sm"
|
||||||
>
|
>
|
||||||
{apiKey ? 'Close' : 'Cancel'}
|
{apiKey ? "Close" : "Cancel"}
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
{!apiKey && (
|
{!apiKey && (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@ -245,7 +333,7 @@ export const NewModuleModal = ({
|
|||||||
size="default"
|
size="default"
|
||||||
className="px-4 py-2.5 text-sm"
|
className="px-4 py-2.5 text-sm"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Registering...' : 'Register Module'}
|
{isLoading ? "Registering..." : "Register Module"}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -260,11 +348,16 @@ export const NewModuleModal = ({
|
|||||||
⚠️ Important: Save Your API Key
|
⚠️ Important: Save Your API Key
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-[#6b7280] mb-3">
|
<p className="text-sm text-[#6b7280] mb-3">
|
||||||
Your API key has been generated. This is the <strong>only time</strong> you will see this key. Store it securely in your module project to authenticate with QAssure services. If you lose this key, you cannot retrieve it.
|
Your API key has been generated. This is the{" "}
|
||||||
|
<strong>only time</strong> you will see this key. Store it
|
||||||
|
securely in your module project to authenticate with QAssure
|
||||||
|
services. If you lose this key, you cannot retrieve it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 p-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md">
|
<div className="flex items-center gap-2 p-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md">
|
||||||
<code className="flex-1 text-sm font-mono text-[#0f1724] break-all">{apiKey}</code>
|
<code className="flex-1 text-sm font-mono text-[#0f1724] break-all">
|
||||||
|
{apiKey}
|
||||||
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyApiKey}
|
onClick={handleCopyApiKey}
|
||||||
@ -296,7 +389,9 @@ export const NewModuleModal = ({
|
|||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
{/* Basic Information Section */}
|
{/* Basic Information Section */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Basic Information</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -305,26 +400,25 @@ export const NewModuleModal = ({
|
|||||||
required
|
required
|
||||||
placeholder="Enter module ID (e.g., my-module)"
|
placeholder="Enter module ID (e.g., my-module)"
|
||||||
error={errors.module_id?.message}
|
error={errors.module_id?.message}
|
||||||
{...register('module_id')}
|
{...register("module_id")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Module Name"
|
label="Module Name"
|
||||||
required
|
required
|
||||||
placeholder="Enter module name"
|
placeholder="Enter module name"
|
||||||
error={errors.name?.message}
|
error={errors.name?.message}
|
||||||
{...register('name')}
|
{...register("name")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FormTextArea
|
||||||
<FormField
|
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="Enter module description (optional)"
|
placeholder="Enter module description"
|
||||||
error={errors.description?.message}
|
error={errors.description?.message}
|
||||||
{...register('description')}
|
{...register("description")}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* <div className="flex gap-5">
|
{/* <div className="flex gap-5">
|
||||||
@ -334,7 +428,7 @@ export const NewModuleModal = ({
|
|||||||
required
|
required
|
||||||
placeholder="e.g., 1.0.0"
|
placeholder="e.g., 1.0.0"
|
||||||
error={errors.version?.message}
|
error={errors.version?.message}
|
||||||
{...register('version')}
|
{...register("version")}
|
||||||
/>
|
/>
|
||||||
{/* </div> */}
|
{/* </div> */}
|
||||||
{/* <div className="flex-1">
|
{/* <div className="flex-1">
|
||||||
@ -353,7 +447,9 @@ export const NewModuleModal = ({
|
|||||||
|
|
||||||
{/* Runtime Information Section */}
|
{/* Runtime Information Section */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Runtime Information</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">
|
||||||
|
Runtime Information
|
||||||
|
</h3>
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormField
|
<FormField
|
||||||
@ -361,7 +457,7 @@ export const NewModuleModal = ({
|
|||||||
required
|
required
|
||||||
placeholder="e.g., Node.js, Python, Java"
|
placeholder="e.g., Node.js, Python, Java"
|
||||||
error={errors.runtime_language?.message}
|
error={errors.runtime_language?.message}
|
||||||
{...register('runtime_language')}
|
{...register("runtime_language")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -369,7 +465,7 @@ export const NewModuleModal = ({
|
|||||||
label="Framework"
|
label="Framework"
|
||||||
placeholder="e.g., Express, Django, Spring (optional)"
|
placeholder="e.g., Express, Django, Spring (optional)"
|
||||||
error={errors.framework?.message}
|
error={errors.framework?.message}
|
||||||
{...register('framework')}
|
{...register("framework")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -380,7 +476,7 @@ export const NewModuleModal = ({
|
|||||||
placeholder="e.g., https://example.com/webhook/api-key"
|
placeholder="e.g., https://example.com/webhook/api-key"
|
||||||
error={errors.webhookurl?.message}
|
error={errors.webhookurl?.message}
|
||||||
helperText="URL to receive the system API key"
|
helperText="URL to receive the system API key"
|
||||||
{...register('webhookurl')}
|
{...register("webhookurl")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -389,7 +485,7 @@ export const NewModuleModal = ({
|
|||||||
placeholder="e.g., https://example.com/webhook/sync"
|
placeholder="e.g., https://example.com/webhook/sync"
|
||||||
error={errors.sync_webhook_url?.message}
|
error={errors.sync_webhook_url?.message}
|
||||||
helperText="URL to receive identity data (tenants, users, etc.)"
|
helperText="URL to receive identity data (tenants, users, etc.)"
|
||||||
{...register('sync_webhook_url')}
|
{...register("sync_webhook_url")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -397,7 +493,9 @@ export const NewModuleModal = ({
|
|||||||
|
|
||||||
{/* URL Configuration Section */}
|
{/* URL Configuration Section */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">URL Configuration</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">
|
||||||
|
URL Configuration
|
||||||
|
</h3>
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -407,7 +505,7 @@ export const NewModuleModal = ({
|
|||||||
type="url"
|
type="url"
|
||||||
placeholder="https://frontend.example.com"
|
placeholder="https://frontend.example.com"
|
||||||
error={errors.frontend_base_url?.message}
|
error={errors.frontend_base_url?.message}
|
||||||
{...register('frontend_base_url')}
|
{...register("frontend_base_url")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -417,7 +515,7 @@ export const NewModuleModal = ({
|
|||||||
type="url"
|
type="url"
|
||||||
placeholder="https://backend.example.com"
|
placeholder="https://backend.example.com"
|
||||||
error={errors.backend_base_url?.message}
|
error={errors.backend_base_url?.message}
|
||||||
{...register('backend_base_url')}
|
{...register("backend_base_url")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -427,14 +525,16 @@ export const NewModuleModal = ({
|
|||||||
required
|
required
|
||||||
placeholder="/health"
|
placeholder="/health"
|
||||||
error={errors.health_endpoint?.message}
|
error={errors.health_endpoint?.message}
|
||||||
{...register('health_endpoint')}
|
{...register("health_endpoint")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resource Configuration Section */}
|
{/* Resource Configuration Section */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Resource Configuration (Optional)</h3>
|
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">
|
||||||
|
Resource Configuration (Optional)
|
||||||
|
</h3>
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -442,7 +542,7 @@ export const NewModuleModal = ({
|
|||||||
label="CPU Request"
|
label="CPU Request"
|
||||||
placeholder="e.g., 100m, 0.5"
|
placeholder="e.g., 100m, 0.5"
|
||||||
error={errors.cpu_request?.message}
|
error={errors.cpu_request?.message}
|
||||||
{...register('cpu_request')}
|
{...register("cpu_request")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -450,7 +550,7 @@ export const NewModuleModal = ({
|
|||||||
label="CPU Limit"
|
label="CPU Limit"
|
||||||
placeholder="e.g., 500m, 1"
|
placeholder="e.g., 500m, 1"
|
||||||
error={errors.cpu_limit?.message}
|
error={errors.cpu_limit?.message}
|
||||||
{...register('cpu_limit')}
|
{...register("cpu_limit")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -461,7 +561,7 @@ export const NewModuleModal = ({
|
|||||||
label="Memory Request"
|
label="Memory Request"
|
||||||
placeholder="e.g., 128Mi, 512Mi"
|
placeholder="e.g., 128Mi, 512Mi"
|
||||||
error={errors.memory_request?.message}
|
error={errors.memory_request?.message}
|
||||||
{...register('memory_request')}
|
{...register("memory_request")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -469,7 +569,7 @@ export const NewModuleModal = ({
|
|||||||
label="Memory Limit"
|
label="Memory Limit"
|
||||||
placeholder="e.g., 256Mi, 1Gi"
|
placeholder="e.g., 256Mi, 1Gi"
|
||||||
error={errors.memory_limit?.message}
|
error={errors.memory_limit?.message}
|
||||||
{...register('memory_limit')}
|
{...register("memory_limit")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -484,9 +584,14 @@ export const NewModuleModal = ({
|
|||||||
step="1"
|
step="1"
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
error={errors.min_replicas?.message}
|
error={errors.min_replicas?.message}
|
||||||
{...register('min_replicas', {
|
{...register("min_replicas", {
|
||||||
setValueAs: (value) => {
|
setValueAs: (value) => {
|
||||||
if (value === '' || value === null || value === undefined) return null;
|
if (
|
||||||
|
value === "" ||
|
||||||
|
value === null ||
|
||||||
|
value === undefined
|
||||||
|
)
|
||||||
|
return null;
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
return isNaN(num) ? null : num;
|
return isNaN(num) ? null : num;
|
||||||
},
|
},
|
||||||
@ -502,9 +607,14 @@ export const NewModuleModal = ({
|
|||||||
step="1"
|
step="1"
|
||||||
placeholder="5"
|
placeholder="5"
|
||||||
error={errors.max_replicas?.message}
|
error={errors.max_replicas?.message}
|
||||||
{...register('max_replicas', {
|
{...register("max_replicas", {
|
||||||
setValueAs: (value) => {
|
setValueAs: (value) => {
|
||||||
if (value === '' || value === null || value === undefined) return null;
|
if (
|
||||||
|
value === "" ||
|
||||||
|
value === null ||
|
||||||
|
value === undefined
|
||||||
|
)
|
||||||
|
return null;
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
return isNaN(num) ? null : num;
|
return isNaN(num) ? null : num;
|
||||||
},
|
},
|
||||||
@ -517,6 +627,6 @@ export const NewModuleModal = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Modal >
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,10 +10,13 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
|
SearchBox,
|
||||||
type Column,
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, Download, ArrowUpDown } from "lucide-react";
|
import { Plus, Download, ArrowUpDown } from "lucide-react";
|
||||||
import { userService } from "@/services/user-service";
|
import { userService } from "@/services/user-service";
|
||||||
|
import { roleService } from "@/services/role-service";
|
||||||
|
import type { Role } from "@/types/role";
|
||||||
import type { User } from "@/types/user";
|
import type { User } from "@/types/user";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { formatDate } from "@/utils/format-date";
|
import { formatDate } from "@/utils/format-date";
|
||||||
@ -80,6 +83,14 @@ export const UsersTable = ({
|
|||||||
// Filter state
|
// Filter state
|
||||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
||||||
|
|
||||||
|
// Roles list for filter
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
|
||||||
// View, Edit, Delete modals
|
// View, Edit, Delete modals
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||||
@ -95,6 +106,8 @@ export const UsersTable = ({
|
|||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
status: string | null = null,
|
status: string | null = null,
|
||||||
sortBy: string[] | null = null,
|
sortBy: string[] | null = null,
|
||||||
|
searchQuery: string | null = null,
|
||||||
|
roleId: string | null = null,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -106,8 +119,17 @@ export const UsersTable = ({
|
|||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
status,
|
status,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
searchQuery,
|
||||||
|
roleId,
|
||||||
)
|
)
|
||||||
: await userService.getAll(page, itemsPerPage, status, sortBy);
|
: await userService.getAll(
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
status,
|
||||||
|
sortBy,
|
||||||
|
searchQuery,
|
||||||
|
roleId,
|
||||||
|
);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setUsers(response.data);
|
setUsers(response.data);
|
||||||
setPagination(response.pagination);
|
setPagination(response.pagination);
|
||||||
@ -121,9 +143,35 @@ export const UsersTable = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch roles for filter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers(currentPage, limit, statusFilter, orderBy);
|
const fetchRoles = async () => {
|
||||||
}, [currentPage, limit, statusFilter, orderBy, tenantId]);
|
try {
|
||||||
|
const response = tenantId
|
||||||
|
? await roleService.getByTenant(tenantId, 1, 100)
|
||||||
|
: await roleService.getAll(1, 100);
|
||||||
|
if (response.success) {
|
||||||
|
setRoles(response.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch roles:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRoles();
|
||||||
|
}, [tenantId]);
|
||||||
|
|
||||||
|
// Handle search debouncing
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearch(search);
|
||||||
|
if (search) setCurrentPage(1);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter);
|
||||||
|
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter, tenantId]);
|
||||||
|
|
||||||
const handleCreateUser = async (data: {
|
const handleCreateUser = async (data: {
|
||||||
email: string;
|
email: string;
|
||||||
@ -443,9 +491,14 @@ export const UsersTable = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h3 className="text-lg font-semibold text-[#0f1724]"></h3>
|
<h3 className="text-lg font-semibold text-[#0f1724]"></h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<SearchBox
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Search..."
|
||||||
|
/>
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
options={[
|
options={[
|
||||||
@ -461,6 +514,19 @@ export const UsersTable = ({
|
|||||||
}}
|
}}
|
||||||
placeholder="Filter by status"
|
placeholder="Filter by status"
|
||||||
/>
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="Role"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All Roles" },
|
||||||
|
...roles.map(role => ({ value: role.id, label: role.name }))
|
||||||
|
]}
|
||||||
|
value={roleFilter || ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRoleFilter(Array.isArray(value) ? null : value || null);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Filter by role"
|
||||||
|
/>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@ -557,6 +623,13 @@ export const UsersTable = ({
|
|||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Global Search */}
|
||||||
|
<SearchBox
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
@ -578,6 +651,21 @@ export const UsersTable = ({
|
|||||||
placeholder="All"
|
placeholder="All"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Role Filter */}
|
||||||
|
<FilterDropdown
|
||||||
|
label="Role"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All Roles" },
|
||||||
|
...roles.map(role => ({ value: role.id, label: role.name }))
|
||||||
|
]}
|
||||||
|
value={roleFilter || ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRoleFilter(Array.isArray(value) ? null : value || null);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Filter by role"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Sort Filter */}
|
{/* Sort Filter */}
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Sort by"
|
label="Sort by"
|
||||||
|
|||||||
@ -8,5 +8,5 @@ export { WebhookSyncModal } from './WebhookSyncModal';
|
|||||||
export { ApikeyReissueModal } from './ApikeyReissueModal';
|
export { ApikeyReissueModal } from './ApikeyReissueModal';
|
||||||
export { UsersTable } from './UsersTable';
|
export { UsersTable } from './UsersTable';
|
||||||
export { RolesTable } from './RolesTable';
|
export { RolesTable } from './RolesTable';
|
||||||
export { default as DepartmentsTable } from './DepartmentsTable';
|
export { DepartmentsTable, type DepartmentsTableRef } from './DepartmentsTable';
|
||||||
export { default as DesignationsTable } from './DesignationsTable';
|
export { default as DesignationsTable } from './DesignationsTable';
|
||||||
|
|||||||
@ -1454,7 +1454,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Settings */}
|
{/* Security Settings */}
|
||||||
<div className="space-y-4">
|
{/* <div className="space-y-4">
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-medium text-[#0f1724] mb-1">
|
<h4 className="text-xs font-medium text-[#0f1724] mb-1">
|
||||||
@ -1522,7 +1522,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>*/}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1570,7 +1570,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Settings */}
|
{/* Security Settings */}
|
||||||
<div className="space-y-4">
|
{/* <div className="space-y-4">
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-medium text-[#0f1724] mb-1">
|
<h4 className="text-xs font-medium text-[#0f1724] mb-1">
|
||||||
@ -1638,7 +1638,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
|
SearchBox,
|
||||||
type Column,
|
type Column,
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
// SecondaryButton,
|
// SecondaryButton,
|
||||||
@ -79,6 +80,10 @@ const Modules = (): ReactElement => {
|
|||||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
||||||
|
|
||||||
// View modal
|
// View modal
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
|
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
|
||||||
@ -93,6 +98,7 @@ const Modules = (): ReactElement => {
|
|||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
status: string | null = null,
|
status: string | null = null,
|
||||||
sortBy: string[] | null = null,
|
sortBy: string[] | null = null,
|
||||||
|
searchQuery: string | null = null,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -102,6 +108,7 @@ const Modules = (): ReactElement => {
|
|||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
status,
|
status,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
searchQuery,
|
||||||
);
|
);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setModules(response.data);
|
setModules(response.data);
|
||||||
@ -116,10 +123,20 @@ const Modules = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle search debouncing
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearch(search);
|
||||||
|
// We only reset to first page if we are actively searching.
|
||||||
|
if (search) setCurrentPage(1);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
// Fetch modules on mount and when pagination/filters change
|
// Fetch modules on mount and when pagination/filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchModules(currentPage, limit, statusFilter, orderBy);
|
fetchModules(currentPage, limit, statusFilter, orderBy, debouncedSearch);
|
||||||
}, [currentPage, limit, statusFilter, orderBy]);
|
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch]);
|
||||||
|
|
||||||
// View module handler
|
// View module handler
|
||||||
const handleViewModule = (moduleId: string): void => {
|
const handleViewModule = (moduleId: string): void => {
|
||||||
@ -404,6 +421,13 @@ const Modules = (): ReactElement => {
|
|||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Global Search */}
|
||||||
|
<SearchBox
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Search by name, ID or description..."
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo, type ReactElement } from "react";
|
import { useState, useEffect, useMemo, useRef, type ReactElement } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
@ -22,8 +22,10 @@ import {
|
|||||||
WorkflowDefinitionsTable,
|
WorkflowDefinitionsTable,
|
||||||
SuppliersTable,
|
SuppliersTable,
|
||||||
type Column,
|
type Column,
|
||||||
|
PrimaryButton,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { UsersTable, RolesTable } from "@/components/superadmin";
|
import { UsersTable, RolesTable, DepartmentsTable, type DepartmentsTableRef } from "@/components/superadmin";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { tenantService } from "@/services/tenant-service";
|
import { tenantService } from "@/services/tenant-service";
|
||||||
import { moduleService } from "@/services/module-service";
|
import { moduleService } from "@/services/module-service";
|
||||||
import type { Tenant } from "@/types/tenant";
|
import type { Tenant } from "@/types/tenant";
|
||||||
@ -31,7 +33,7 @@ import type { MyModule } from "@/types/module";
|
|||||||
import { formatDate } from "@/utils/format-date";
|
import { formatDate } from "@/utils/format-date";
|
||||||
import AuditLogs from "@/pages/tenant/AuditLogs";
|
import AuditLogs from "@/pages/tenant/AuditLogs";
|
||||||
import TenantSettings from "@/pages/tenant/Settings";
|
import TenantSettings from "@/pages/tenant/Settings";
|
||||||
import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
|
// import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
|
||||||
import DesignationsTable from "@/components/superadmin/DesignationsTable";
|
import DesignationsTable from "@/components/superadmin/DesignationsTable";
|
||||||
|
|
||||||
type TabType =
|
type TabType =
|
||||||
@ -116,6 +118,9 @@ const TenantDetails = (): ReactElement => {
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Refs for tables to trigger actions from header
|
||||||
|
const departmentsRef = useRef<DepartmentsTableRef>(null);
|
||||||
|
|
||||||
// Modules tab state - using assignedModules from tenant response
|
// Modules tab state - using assignedModules from tenant response
|
||||||
|
|
||||||
// Fetch tenant details
|
// Fetch tenant details
|
||||||
@ -203,6 +208,18 @@ const TenantDetails = (): ReactElement => {
|
|||||||
{ label: "Tenant Management", path: "/tenants" },
|
{ label: "Tenant Management", path: "/tenants" },
|
||||||
{ label: "Tenant Details" },
|
{ label: "Tenant Details" },
|
||||||
]}
|
]}
|
||||||
|
pageHeader={{
|
||||||
|
title: tenant.name,
|
||||||
|
action: activeTab === "departments" ? (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => departmentsRef.current?.openNewModal()}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>New Department</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
) : undefined
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Tenant Header Card */}
|
{/* Tenant Header Card */}
|
||||||
@ -293,7 +310,7 @@ const TenantDetails = (): ReactElement => {
|
|||||||
<RolesTable tenantId={id} compact={true} />
|
<RolesTable tenantId={id} compact={true} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "departments" && id && (
|
{activeTab === "departments" && id && (
|
||||||
<DepartmentsTable tenantId={id} compact={true} />
|
<DepartmentsTable ref={departmentsRef} tenantId={id} compact={true} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "designations" && id && (
|
{activeTab === "designations" && id && (
|
||||||
<DesignationsTable tenantId={id} compact={true} />
|
<DesignationsTable tenantId={id} compact={true} />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from "react";
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@ -11,12 +11,12 @@ import {
|
|||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
SearchBox,
|
SearchBox,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from "@/components/shared";
|
||||||
// Note: NewTenantModal, ViewTenantModal, EditTenantModal are now in @/components/superadmin (commented out - using wizard/details/edit pages instead)
|
// Note: NewTenantModal, ViewTenantModal, EditTenantModal are now in @/components/superadmin (commented out - using wizard/details/edit pages instead)
|
||||||
import { Plus, ArrowUpDown } from 'lucide-react';
|
import { Plus, ArrowUpDown } from "lucide-react";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { tenantService } from '@/services/tenant-service';
|
import { tenantService } from "@/services/tenant-service";
|
||||||
import type { Tenant } from '@/types/tenant';
|
import type { Tenant } from "@/types/tenant";
|
||||||
// Helper function to get tenant initials
|
// Helper function to get tenant initials
|
||||||
const getTenantInitials = (name: string): string => {
|
const getTenantInitials = (name: string): string => {
|
||||||
const words = name.trim().split(/\s+/);
|
const words = name.trim().split(/\s+/);
|
||||||
@ -29,26 +29,32 @@ const getTenantInitials = (name: string): string => {
|
|||||||
// Helper function to format date
|
// Helper function to format date
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get status badge variant
|
// Helper function to get status badge variant
|
||||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
const getStatusVariant = (
|
||||||
|
status: string,
|
||||||
|
): "success" | "failure" | "process" => {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
case 'active':
|
case "active":
|
||||||
return 'success';
|
return "success";
|
||||||
case 'deleted':
|
case "deleted":
|
||||||
return 'failure';
|
return "failure";
|
||||||
case 'suspended':
|
case "suspended":
|
||||||
return 'process';
|
return "process";
|
||||||
default:
|
default:
|
||||||
return 'success';
|
return "success";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format subscription tier
|
// Helper function to format subscription tier
|
||||||
const formatSubscriptionTier = (tier: string | null): string => {
|
const formatSubscriptionTier = (tier: string | null): string => {
|
||||||
if (!tier) return 'N/A';
|
if (!tier) return "N/A";
|
||||||
return tier.charAt(0).toUpperCase() + tier.slice(1);
|
return tier.charAt(0).toUpperCase() + tier.slice(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -82,15 +88,15 @@ const Tenants = (): ReactElement => {
|
|||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [search, setSearch] = useState<string>('');
|
const [search, setSearch] = useState<string>("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
|
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
||||||
|
|
||||||
// View, Edit, Delete modals
|
// View, Edit, Delete modals
|
||||||
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead
|
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead
|
||||||
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead
|
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||||
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
|
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
|
||||||
const [selectedTenantName, setSelectedTenantName] = useState<string>('');
|
const [selectedTenantName, setSelectedTenantName] = useState<string>("");
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
|
|
||||||
const fetchTenants = async (
|
const fetchTenants = async (
|
||||||
@ -98,20 +104,26 @@ const Tenants = (): ReactElement => {
|
|||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
status: string | null = null,
|
status: string | null = null,
|
||||||
sortBy: string[] | null = null,
|
sortBy: string[] | null = null,
|
||||||
searchQuery: string | null = null
|
searchQuery: string | null = null,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await tenantService.getAll(page, itemsPerPage, status, sortBy, searchQuery);
|
const response = await tenantService.getAll(
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
status,
|
||||||
|
sortBy,
|
||||||
|
searchQuery,
|
||||||
|
);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setTenants(response.data);
|
setTenants(response.data);
|
||||||
setPagination(response.pagination);
|
setPagination(response.pagination);
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to load tenants');
|
setError("Failed to load tenants");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error?.message || 'Failed to load tenants');
|
setError(err?.response?.data?.error?.message || "Failed to load tenants");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -184,7 +196,7 @@ const Tenants = (): ReactElement => {
|
|||||||
await tenantService.delete(selectedTenantId);
|
await tenantService.delete(selectedTenantId);
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setSelectedTenantId(null);
|
setSelectedTenantId(null);
|
||||||
setSelectedTenantName('');
|
setSelectedTenantName("");
|
||||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw err; // Let the modal handle the error display
|
throw err; // Let the modal handle the error display
|
||||||
@ -196,25 +208,43 @@ const Tenants = (): ReactElement => {
|
|||||||
// Define table columns
|
// Define table columns
|
||||||
const columns: Column<Tenant>[] = [
|
const columns: Column<Tenant>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: "name",
|
||||||
label: 'Tenant Name',
|
label: "Tenant Name",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<div className="flex items-center gap-3">
|
<div
|
||||||
|
className="flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||||
|
>
|
||||||
<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">
|
<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">
|
||||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
<span className="text-xs font-normal text-[#9aa6b2]">
|
||||||
{getTenantInitials(tenant.name)}
|
{tenant.name.substring(0, 2).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-[#112868]">
|
||||||
{tenant.name}
|
{tenant.name}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[10px] text-[#6b7280] font-mono leading-none mt-0.5">
|
||||||
|
{tenant?.slug}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
// <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">
|
||||||
|
// <span className="text-xs font-normal text-[#9aa6b2]">
|
||||||
|
// {getTenantInitials(tenant.name)}
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
// <span className="text-sm font-normal text-[#0f1724]">
|
||||||
|
// {tenant.name}
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
),
|
),
|
||||||
mobileLabel: 'Name',
|
mobileLabel: "Name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: "status",
|
||||||
label: 'Status',
|
label: "Status",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<StatusBadge variant={getStatusVariant(tenant.status)}>
|
<StatusBadge variant={getStatusVariant(tenant.status)}>
|
||||||
{tenant.status}
|
{tenant.status}
|
||||||
@ -222,17 +252,17 @@ const Tenants = (): ReactElement => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'max_users',
|
key: "max_users",
|
||||||
label: 'Users',
|
label: "Users",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
{tenant.max_users ?? 'N/A'}
|
{tenant.max_users ?? "N/A"}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'subscription_tier',
|
key: "subscription_tier",
|
||||||
label: 'Plan',
|
label: "Plan",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
{formatSubscriptionTier(tenant.subscription_tier)}
|
{formatSubscriptionTier(tenant.subscription_tier)}
|
||||||
@ -240,28 +270,28 @@ const Tenants = (): ReactElement => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'max_modules',
|
key: "max_modules",
|
||||||
label: 'Modules',
|
label: "Modules",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
{tenant.max_modules ?? 'N/A'}
|
{tenant.max_modules ?? "N/A"}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'created_at',
|
key: "created_at",
|
||||||
label: 'Joined Date',
|
label: "Joined Date",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<span className="text-sm font-normal text-[#6b7280]">
|
<span className="text-sm font-normal text-[#6b7280]">
|
||||||
{formatDate(tenant.created_at)}
|
{formatDate(tenant.created_at)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
mobileLabel: 'Joined',
|
mobileLabel: "Joined",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: "actions",
|
||||||
label: 'Actions',
|
label: "Actions",
|
||||||
align: 'right',
|
align: "right",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
@ -278,14 +308,17 @@ const Tenants = (): ReactElement => {
|
|||||||
const mobileCardRenderer = (tenant: Tenant) => (
|
const mobileCardRenderer = (tenant: Tenant) => (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div
|
||||||
|
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||||
|
>
|
||||||
<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">
|
<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">
|
||||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
<span className="text-xs font-normal text-[#9aa6b2]">
|
||||||
{getTenantInitials(tenant.name)}
|
{getTenantInitials(tenant.name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
<h3 className="text-sm font-medium text-[#112868] truncate">
|
||||||
{tenant.name}
|
{tenant.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-[#6b7280] mt-0.5">
|
<p className="text-xs text-[#6b7280] mt-0.5">
|
||||||
@ -317,13 +350,13 @@ const Tenants = (): ReactElement => {
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-[#9aa6b2]">Users:</span>
|
<span className="text-[#9aa6b2]">Users:</span>
|
||||||
<p className="text-[#0f1724] font-normal mt-1">
|
<p className="text-[#0f1724] font-normal mt-1">
|
||||||
{tenant.max_users ?? 'N/A'}
|
{tenant.max_users ?? "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[#9aa6b2]">Modules:</span>
|
<span className="text-[#9aa6b2]">Modules:</span>
|
||||||
<p className="text-[#0f1724] font-normal mt-1">
|
<p className="text-[#0f1724] font-normal mt-1">
|
||||||
{tenant.max_modules ?? 'N/A'}
|
{tenant.max_modules ?? "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -334,11 +367,11 @@ const Tenants = (): ReactElement => {
|
|||||||
<Layout
|
<Layout
|
||||||
currentPage="Tenants"
|
currentPage="Tenants"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: 'Tenant List',
|
title: "Tenant List",
|
||||||
description: 'View and manage all tenants in your QAssure platform from a single place.',
|
description:
|
||||||
|
"View and manage all tenants in your QAssure platform from a single place.",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* Table Container */}
|
{/* Table Container */}
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||||
{/* Table Header with Filters */}
|
{/* Table Header with Filters */}
|
||||||
@ -349,16 +382,16 @@ const Tenants = (): ReactElement => {
|
|||||||
<SearchBox
|
<SearchBox
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
placeholder="Search tenants..."
|
placeholder="Search by name or slug..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: "active", label: "Active" },
|
||||||
{ value: 'suspended', label: 'Suspended' },
|
{ value: "suspended", label: "Suspended" },
|
||||||
{ value: 'deleted', label: 'Deleted' },
|
{ value: "deleted", label: "Deleted" },
|
||||||
]}
|
]}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@ -372,12 +405,12 @@ const Tenants = (): ReactElement => {
|
|||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Sort by"
|
label="Sort by"
|
||||||
options={[
|
options={[
|
||||||
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
|
{ value: ["name", "asc"], label: "Name (A-Z)" },
|
||||||
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
|
{ value: ["name", "desc"], label: "Name (Z-A)" },
|
||||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
|
||||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
{ value: ["created_at", "desc"], label: "Created (Newest)" },
|
||||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
|
||||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
|
||||||
]}
|
]}
|
||||||
value={orderBy}
|
value={orderBy}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@ -415,7 +448,7 @@ const Tenants = (): ReactElement => {
|
|||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
onClick={() => navigate('/tenants/create-wizard')}
|
onClick={() => navigate("/tenants/create-wizard")}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs">Add Tenant</span>
|
<span className="text-xs">Add Tenant</span>
|
||||||
@ -479,7 +512,7 @@ const Tenants = (): ReactElement => {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setSelectedTenantId(null);
|
setSelectedTenantId(null);
|
||||||
setSelectedTenantName('');
|
setSelectedTenantName("");
|
||||||
}}
|
}}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={handleConfirmDelete}
|
||||||
title="Delete Tenant"
|
title="Delete Tenant"
|
||||||
|
|||||||
@ -1,17 +1,30 @@
|
|||||||
import { type ReactElement } from 'react';
|
import { type ReactElement, useRef } from 'react';
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from '@/components/layout/Layout';
|
||||||
import { DepartmentsTable } from '@/components/superadmin';
|
import { DepartmentsTable, type DepartmentsTableRef } from '@/components/superadmin';
|
||||||
|
import { PrimaryButton } from '@/components/shared';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
const Departments = (): ReactElement => {
|
const Departments = (): ReactElement => {
|
||||||
|
const tableRef = useRef<DepartmentsTableRef>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
currentPage="Departments"
|
currentPage="Departments"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: 'Department Management',
|
title: 'Department Management',
|
||||||
description: 'View and manage all departments within your organization.',
|
description: 'View and manage all departments within your organization.',
|
||||||
|
action: (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => tableRef.current?.openNewModal()}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>New Department</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DepartmentsTable />
|
<DepartmentsTable ref={tableRef} />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
|
SearchBox,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from '@/components/shared';
|
||||||
import { Plus, ArrowUpDown } from 'lucide-react';
|
import { Plus, ArrowUpDown } from 'lucide-react';
|
||||||
@ -19,6 +20,7 @@ import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
|||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from '@/utils/toast';
|
||||||
import { NewRoleModal } from '@/components/shared/NewRoleModal';
|
import { NewRoleModal } from '@/components/shared/NewRoleModal';
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
|
import CodeBadge from '@/components/shared/CodeBadge';
|
||||||
|
|
||||||
// Helper function to format date
|
// Helper function to format date
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
@ -69,6 +71,10 @@ const Roles = (): ReactElement => {
|
|||||||
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [search, setSearch] = useState<string>('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
|
||||||
|
|
||||||
// View, Edit, Delete modals
|
// View, Edit, Delete modals
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||||
@ -82,12 +88,13 @@ const Roles = (): ReactElement => {
|
|||||||
page: number,
|
page: number,
|
||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
// scope: string | null = null,
|
// scope: string | null = null,
|
||||||
sortBy: string[] | null = null
|
sortBy: string[] | null = null,
|
||||||
|
searchQuery: string | null = null
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await roleService.getAll(page, itemsPerPage, sortBy);
|
const response = await roleService.getAll(page, itemsPerPage, sortBy, searchQuery);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setRoles(response.data);
|
setRoles(response.data);
|
||||||
setPagination(response.pagination);
|
setPagination(response.pagination);
|
||||||
@ -101,9 +108,18 @@ const Roles = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle search debouncing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRoles(currentPage, limit, orderBy);
|
const timer = setTimeout(() => {
|
||||||
}, [currentPage, limit, orderBy]);
|
setDebouncedSearch(search);
|
||||||
|
if (search) setCurrentPage(1);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles(currentPage, limit, orderBy, debouncedSearch);
|
||||||
|
}, [currentPage, limit, orderBy, debouncedSearch]);
|
||||||
|
|
||||||
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -199,7 +215,7 @@ const Roles = (): ReactElement => {
|
|||||||
key: 'code',
|
key: 'code',
|
||||||
label: 'Code',
|
label: 'Code',
|
||||||
render: (role) => (
|
render: (role) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
|
<CodeBadge label={role.code} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -209,6 +225,15 @@ const Roles = (): ReactElement => {
|
|||||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'user_count',
|
||||||
|
label: 'Users',
|
||||||
|
render: (role) => (
|
||||||
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
|
{role.user_count || 0}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'description',
|
key: 'description',
|
||||||
label: 'Description',
|
label: 'Description',
|
||||||
@ -271,6 +296,10 @@ const Roles = (): ReactElement => {
|
|||||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[#9aa6b2]">Users:</span>
|
||||||
|
<p className="text-[#0f1724] font-normal mt-1">{role.user_count || 0}</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[#9aa6b2]">Created:</span>
|
<span className="text-[#9aa6b2]">Created:</span>
|
||||||
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
|
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
|
||||||
@ -299,6 +328,12 @@ const Roles = (): ReactElement => {
|
|||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Global Search */}
|
||||||
|
<SearchBox
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Search by name, code or description..."
|
||||||
|
/>
|
||||||
{/* Scope Filter */}
|
{/* Scope Filter */}
|
||||||
{/* <FilterDropdown
|
{/* <FilterDropdown
|
||||||
label="Scope"
|
label="Scope"
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const Suppliers = (): ReactElement => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg bg-white overflow-hidden p-4 md:p-6">
|
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg bg-white overflow-hidden p-2 md:p-6">
|
||||||
{/* <div className="flex flex-col gap-4"> */}
|
{/* <div className="flex flex-col gap-4"> */}
|
||||||
{/* <div className="flex items-center justify-between">
|
{/* <div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-[#0f1724]">
|
<h2 className="text-lg font-semibold text-[#0f1724]">
|
||||||
|
|||||||
@ -12,11 +12,14 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
|
SearchBox,
|
||||||
type Column,
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, ArrowUpDown } from "lucide-react";
|
import { Plus, ArrowUpDown } from "lucide-react";
|
||||||
import { userService } from "@/services/user-service";
|
import { userService } from "@/services/user-service";
|
||||||
|
import { roleService } from "@/services/role-service";
|
||||||
import type { User } from "@/types/user";
|
import type { User } from "@/types/user";
|
||||||
|
import type { Role } from "@/types/role";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { usePermissions } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
@ -87,6 +90,14 @@ const Users = (): ReactElement => {
|
|||||||
// Filter state
|
// Filter state
|
||||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
||||||
|
|
||||||
|
// Roles list for filter
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
|
||||||
// View, Edit, Delete modals
|
// View, Edit, Delete modals
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||||
@ -102,6 +113,8 @@ const Users = (): ReactElement => {
|
|||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
status: string | null = null,
|
status: string | null = null,
|
||||||
sortBy: string[] | null = null,
|
sortBy: string[] | null = null,
|
||||||
|
searchQuery: string | null = null,
|
||||||
|
roleId: string | null = null,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -111,6 +124,8 @@ const Users = (): ReactElement => {
|
|||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
status,
|
status,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
searchQuery,
|
||||||
|
roleId,
|
||||||
);
|
);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setUsers(response.data);
|
setUsers(response.data);
|
||||||
@ -125,9 +140,35 @@ const Users = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle search debouncing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers(currentPage, limit, statusFilter, orderBy);
|
const timer = setTimeout(() => {
|
||||||
}, [currentPage, limit, statusFilter, orderBy]);
|
setDebouncedSearch(search);
|
||||||
|
// We only reset to first page if we are actively searching.
|
||||||
|
if (search) setCurrentPage(1);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Fetch roles for filter
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await roleService.getAll(1, 100);
|
||||||
|
if (response.success) {
|
||||||
|
setRoles(response.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch roles:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRoles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch users on mount and when pagination/filters change
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter);
|
||||||
|
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter]);
|
||||||
|
|
||||||
const handleCreateUser = async (data: {
|
const handleCreateUser = async (data: {
|
||||||
email: string;
|
email: string;
|
||||||
@ -436,6 +477,13 @@ const Users = (): ReactElement => {
|
|||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Global Search */}
|
||||||
|
<SearchBox
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
@ -457,6 +505,21 @@ const Users = (): ReactElement => {
|
|||||||
placeholder="All"
|
placeholder="All"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Role Filter */}
|
||||||
|
<FilterDropdown
|
||||||
|
label="Role"
|
||||||
|
options={[
|
||||||
|
// { value: "", label: "All" },
|
||||||
|
...roles.map(role => ({ value: role.id, label: role.name }))
|
||||||
|
]}
|
||||||
|
value={roleFilter || ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRoleFilter(Array.isArray(value) ? null : value || null);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="All"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Sort Filter */}
|
{/* Sort Filter */}
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Sort by"
|
label="Sort by"
|
||||||
|
|||||||
@ -10,7 +10,8 @@ export const moduleService = {
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
status?: string | null,
|
status?: string | null,
|
||||||
orderBy?: string[] | null
|
orderBy?: string[] | null,
|
||||||
|
search?: string | null
|
||||||
): Promise<ModulesResponse> => {
|
): Promise<ModulesResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('page', String(page));
|
params.append('page', String(page));
|
||||||
@ -18,6 +19,9 @@ export const moduleService = {
|
|||||||
if (status) {
|
if (status) {
|
||||||
params.append('status', status);
|
params.append('status', status);
|
||||||
}
|
}
|
||||||
|
if (search) {
|
||||||
|
params.append('search', search);
|
||||||
|
}
|
||||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||||
params.append('orderBy[]', orderBy[0]);
|
params.append('orderBy[]', orderBy[0]);
|
||||||
params.append('orderBy[]', orderBy[1]);
|
params.append('orderBy[]', orderBy[1]);
|
||||||
|
|||||||
@ -13,15 +13,15 @@ export const roleService = {
|
|||||||
getAll: async (
|
getAll: async (
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
// scope?: string | null,
|
orderBy?: string[] | null,
|
||||||
orderBy?: string[] | null
|
search?: string | null
|
||||||
): Promise<RolesResponse> => {
|
): Promise<RolesResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('page', String(page));
|
params.append('page', String(page));
|
||||||
params.append('limit', String(limit));
|
params.append('limit', String(limit));
|
||||||
// if (scope) {
|
if (search) {
|
||||||
// params.append('scope', scope);
|
params.append('search', search);
|
||||||
// }
|
}
|
||||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||||
params.append('orderBy[]', orderBy[0]);
|
params.append('orderBy[]', orderBy[0]);
|
||||||
params.append('orderBy[]', orderBy[1]);
|
params.append('orderBy[]', orderBy[1]);
|
||||||
@ -33,16 +33,16 @@ export const roleService = {
|
|||||||
tenantId: string,
|
tenantId: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
// scope?: string | null,
|
orderBy?: string[] | null,
|
||||||
orderBy?: string[] | null
|
search?: string | null
|
||||||
): Promise<RolesResponse> => {
|
): Promise<RolesResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('page', String(page));
|
params.append('page', String(page));
|
||||||
params.append('limit', String(limit));
|
params.append('limit', String(limit));
|
||||||
params.append('tenant_id', tenantId);
|
params.append('tenant_id', tenantId);
|
||||||
// if (scope) {
|
if (search) {
|
||||||
// params.append('scope', scope);
|
params.append('search', search);
|
||||||
// }
|
}
|
||||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||||
params.append('orderBy[]', orderBy[0]);
|
params.append('orderBy[]', orderBy[0]);
|
||||||
params.append('orderBy[]', orderBy[1]);
|
params.append('orderBy[]', orderBy[1]);
|
||||||
|
|||||||
@ -14,7 +14,9 @@ const getAllUsers = async (
|
|||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
status?: string | null,
|
status?: string | null,
|
||||||
orderBy?: string[] | null,
|
orderBy?: string[] | null,
|
||||||
tenantId?: string | null
|
tenantId?: string | null,
|
||||||
|
search?: string | null,
|
||||||
|
roleId?: string | null
|
||||||
): Promise<UsersResponse> => {
|
): Promise<UsersResponse> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('page', String(page));
|
params.append('page', String(page));
|
||||||
@ -25,6 +27,12 @@ const getAllUsers = async (
|
|||||||
if (status) {
|
if (status) {
|
||||||
params.append('status', status);
|
params.append('status', status);
|
||||||
}
|
}
|
||||||
|
if (search) {
|
||||||
|
params.append('search', search);
|
||||||
|
}
|
||||||
|
if (roleId) {
|
||||||
|
params.append('role_id', roleId);
|
||||||
|
}
|
||||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||||
// Send array as orderBy[]=field&orderBy[]=direction
|
// Send array as orderBy[]=field&orderBy[]=direction
|
||||||
params.append('orderBy[]', orderBy[0]);
|
params.append('orderBy[]', orderBy[0]);
|
||||||
@ -35,7 +43,15 @@ const getAllUsers = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
getAll: getAllUsers,
|
getAll: (
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 20,
|
||||||
|
status?: string | null,
|
||||||
|
orderBy?: string[] | null,
|
||||||
|
search?: string | null,
|
||||||
|
roleId?: string | null
|
||||||
|
) => getAllUsers(page, limit, status, orderBy, null, search, roleId),
|
||||||
|
|
||||||
create: async (data: CreateUserRequest): Promise<CreateUserResponse> => {
|
create: async (data: CreateUserRequest): Promise<CreateUserResponse> => {
|
||||||
const response = await apiClient.post<CreateUserResponse>('/users', data);
|
const response = await apiClient.post<CreateUserResponse>('/users', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -49,9 +65,11 @@ export const userService = {
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
status?: string | null,
|
status?: string | null,
|
||||||
orderBy?: string[] | null
|
orderBy?: string[] | null,
|
||||||
|
search?: string | null,
|
||||||
|
roleId?: string | null
|
||||||
): Promise<UsersResponse> => {
|
): Promise<UsersResponse> => {
|
||||||
return getAllUsers(page, limit, status, orderBy, tenantId);
|
return getAllUsers(page, limit, status, orderBy, tenantId, search, roleId);
|
||||||
},
|
},
|
||||||
update: async (id: string, data: UpdateUserRequest): Promise<UpdateUserResponse> => {
|
update: async (id: string, data: UpdateUserRequest): Promise<UpdateUserResponse> => {
|
||||||
const response = await apiClient.put<UpdateUserResponse>(`/users/${id}`, data);
|
const response = await apiClient.put<UpdateUserResponse>(`/users/${id}`, data);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export interface Role {
|
|||||||
module_ids?: string[] | null;
|
module_ids?: string[] | null;
|
||||||
modules?: string[] | null;
|
modules?: string[] | null;
|
||||||
permissions?: Permission[] | null;
|
permissions?: Permission[] | null;
|
||||||
|
user_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user