refactor: standardize UI components with SearchBox, ActiveOnlyToggle, CodeBadge, and FormTextArea, while updating associated services and pages.

This commit is contained in:
Yashwin 2026-04-30 16:02:04 +05:30
parent 1b97371f73
commit 901dde3362
32 changed files with 1352 additions and 463 deletions

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

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

View File

@ -9,6 +9,7 @@ import {
PrimaryButton,
SecondaryButton,
PaginatedSelect,
FormTextArea,
} from "@/components/shared";
import type {
Department,
@ -140,11 +141,18 @@ export const NewDepartmentModal = ({
/>
</div>
<FormField
{/* <FormField
label="Description"
placeholder="Enter department description"
error={errors.description?.message}
{...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">
@ -320,11 +328,18 @@ export const EditDepartmentModal = ({
/>
</div>
<FormField
{/* <FormField
label="Description"
placeholder="Enter department description"
error={errors.description?.message}
{...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">

View File

@ -8,6 +8,7 @@ import {
FormSelect,
PrimaryButton,
SecondaryButton,
FormTextArea,
} from "@/components/shared";
import type {
Designation,
@ -113,11 +114,18 @@ export const NewDesignationModal = ({
/>
</div>
<FormField
{/* <FormField
label="Description"
placeholder="Enter designation description"
error={errors.description?.message}
{...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">
@ -243,11 +251,18 @@ export const EditDesignationModal = ({
/>
</div>
<FormField
{/* <FormField
label="Description"
placeholder="Enter designation description"
error={errors.description?.message}
{...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">

View File

@ -9,6 +9,7 @@ import {
FormField,
PrimaryButton,
SecondaryButton,
FormTextArea,
} from "@/components/shared";
import type { Role, UpdateRoleRequest } from "@/types/role";
import { useAppSelector } from "@/hooks/redux-hooks";
@ -486,12 +487,20 @@ export const EditRoleModal = ({
</div>
{/* Description */}
<FormField
{/* <FormField
label="Description"
required
placeholder="Enter Text Here"
error={errors.description?.message}
{...register("description")}
/> */}
<FormTextArea
label="Description"
required
placeholder="Enter Text Here"
error={errors.description?.message}
{...register("description")}
rows={4}
/>
{/* Permissions Section */}

View File

@ -9,6 +9,7 @@ import {
FormField,
PrimaryButton,
SecondaryButton,
FormTextArea,
} from "@/components/shared";
import type { CreateRoleRequest } from "@/types/role";
import { useAppSelector } from "@/hooks/redux-hooks";
@ -378,12 +379,20 @@ export const NewRoleModal = ({
</div>
{/* Description */}
<FormField
{/* <FormField
label="Description"
required
placeholder="Enter Text Here"
error={errors.description?.message}
{...register("description")}
/> */}
<FormTextArea
label="Description"
required
placeholder="Enter Text Here"
error={errors.description?.message}
{...register("description")}
rows={4}
/>
{/* Permissions Section */}

View File

@ -8,6 +8,7 @@ import {
FormSelect,
PrimaryButton,
SecondaryButton,
ActiveOnlyToggle,
} from "@/components/shared";
import { Plus, Trash2 } from "lucide-react";
import { supplierService } from "@/services/supplier-service";
@ -23,12 +24,31 @@ const supplierSchema = z.object({
status: z.string().optional(),
risk_level: z.string().optional(),
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_name: z.string().optional(),
primary_contact_email: z.string().email("Invalid email").optional().or(z.literal("")),
primary_contact_phone: z.string().optional(),
primary_contact_email: z
.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_line1: z.string().optional(),
@ -44,15 +64,29 @@ const supplierSchema = z.object({
// Quality Agreement
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: z.array(z.object({
name: z.string().min(1, "Name is required"),
issuing_body: z.string().optional().or(z.literal("")),
expiry_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(),
document_url: z.string().url("Invalid URL").optional().or(z.literal("")),
})).default([]),
certifications: z
.array(
z.object({
name: z.string().min(1, "Name is required"),
issuing_body: z.string().optional().or(z.literal("")),
expiry_date: z
.union([z.date(), z.string(), z.null()])
.optional()
.nullable(),
document_url: z
.string()
.url("Invalid URL")
.optional()
.or(z.literal("")),
}),
)
.default([]),
});
type SupplierFormData = z.infer<typeof supplierSchema>;
@ -91,6 +125,7 @@ export const SupplierModal = ({
handleSubmit,
control,
reset,
setValue,
formState: { errors },
} = useForm<SupplierFormData>({
resolver: zodResolver(supplierSchema) as any,
@ -152,7 +187,7 @@ export const SupplierModal = ({
const formatDateForInput = (val: any) => {
if (!val) return "";
const d = new Date(val);
return isNaN(d.getTime()) ? "" : d.toISOString().split('T')[0];
return isNaN(d.getTime()) ? "" : d.toISOString().split("T")[0];
};
useEffect(() => {
@ -173,7 +208,9 @@ export const SupplierModal = ({
status: s.status || "pending",
risk_level: s.risk_level || "low",
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_email: s.contact?.email || "",
@ -188,9 +225,13 @@ export const SupplierModal = ({
tax_id: s.tax_id || "",
duns_number: s.duns_number || "",
quality_agreement_signed: s.quality_agreement?.signed || false,
quality_agreement_date: s.quality_agreement?.date ? new Date(s.quality_agreement.date) : null,
certifications: Array.isArray(s.certifications) ? s.certifications : [],
quality_agreement_signed: s.quality_agreement?.signed || false,
quality_agreement_date: s.quality_agreement?.date
? new Date(s.quality_agreement.date)
: null,
certifications: Array.isArray(s.certifications)
? s.certifications
: [],
});
}
} catch (error: any) {
@ -221,7 +262,7 @@ export const SupplierModal = ({
postal_code: "",
tax_id: "",
duns_number: "",
quality_agreement_date: null,
quality_agreement_date: null,
certifications: [],
});
}
@ -238,8 +279,11 @@ export const SupplierModal = ({
const payload: any = {
...data,
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
@ -258,7 +302,8 @@ export const SupplierModal = ({
showToast.error(
mode === "create"
? 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,
);
} finally {
@ -270,7 +315,13 @@ export const SupplierModal = ({
<Modal
isOpen={isOpen}
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"
footer={
<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">
{/* Basic Information */}
<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">
<FormField
label="Supplier Name"
@ -421,7 +474,9 @@ export const SupplierModal = ({
{/* Products & Services */}
<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">
<FormField
label="Deliverables"
@ -436,7 +491,9 @@ export const SupplierModal = ({
{/* Contact Person */}
<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">
<FormField
label="Contact Name"
@ -452,19 +509,41 @@ export const SupplierModal = ({
{...register("primary_contact_email")}
error={errors.primary_contact_email?.message}
/>
<FormField
{/* <FormField
label="Contact Phone"
placeholder="+1 (555) 000-0000"
disabled={mode === "view" || isSubmitting}
{...register("primary_contact_phone")}
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>
{/* Address Information */}
<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">
<FormField
label="Address Line 1"
@ -513,19 +592,22 @@ export const SupplierModal = ({
{/* Quality Agreement */}
<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="flex items-center gap-3 mb-2">
<input
type="checkbox"
id="quality_agreement_signed"
className="w-4 h-4 text-[#112868] rounded border-gray-300 focus:ring-[#112868]"
disabled={mode === "view" || isSubmitting}
{...register("quality_agreement_signed")}
<Controller
name="quality_agreement_signed"
control={control}
render={({ field }) => (
<ActiveOnlyToggle
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>
<Controller
name="quality_agreement_date"
@ -545,14 +627,23 @@ export const SupplierModal = ({
</div>
</div>
{/* Certifications Section */}
{/* Certifications Section */}
<div className="space-y-4">
<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" && (
<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"
>
<Plus className="w-3.5 h-3.5" />
@ -563,10 +654,15 @@ export const SupplierModal = ({
<div className="space-y-4">
{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) => (
<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" && (
<button
type="button"
@ -583,7 +679,10 @@ export const SupplierModal = ({
required
disabled={mode === "view" || isSubmitting}
{...register(`certifications.${index}.name`)}
error={(errors.certifications?.[index] as any)?.name?.message}
error={
(errors.certifications?.[index] as any)?.name
?.message
}
/>
<FormField
label="Issuing Body"
@ -601,7 +700,9 @@ export const SupplierModal = ({
disabled={mode === "view" || isSubmitting}
{...dateField}
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"
disabled={mode === "view" || isSubmitting}
{...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>

View File

@ -5,18 +5,19 @@ import {
ActionDropdown,
DataTable,
Pagination,
FilterDropdown,
// FilterDropdown,
type Column,
SupplierModal,
ViewSupplierModal,
SupplierContactsModal,
SupplierScorecardsModal,
SearchBox,
} from "@/components/shared";
import { Plus, Building2 } from "lucide-react";
import { supplierService } from "@/services/supplier-service";
import type { Supplier } from "@/types/supplier";
// import { formatDate } from "@/utils/format-date";
import { useAppTheme } from "@/hooks/useAppTheme";
// import { useAppTheme } from "@/hooks/useAppTheme";
interface SuppliersTableProps {
tenantId?: string | null;
@ -60,14 +61,14 @@ export const SuppliersTable = ({
showHeader = true,
compact = false,
}: SuppliersTableProps): ReactElement => {
const { primaryColor } = useAppTheme();
// const { primaryColor } = useAppTheme();
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(compact ? 5 : 10);
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>("");
// Modal state
@ -89,7 +90,7 @@ export const SuppliersTable = ({
setError(null);
const response = await supplierService.list({
tenantId,
status: statusFilter || undefined,
// status: statusFilter || undefined,
search: searchQuery || undefined,
limit,
offset: (currentPage - 1) * limit,
@ -112,7 +113,7 @@ export const SuppliersTable = ({
useEffect(() => {
fetchSuppliers();
}, [tenantId, currentPage, limit, statusFilter, searchQuery]);
}, [tenantId, currentPage, limit, searchQuery]);
const handleCreate = () => {
setModalMode("create");
@ -146,7 +147,7 @@ export const SuppliersTable = ({
const columns: Column<Supplier>[] = [
{
key: "name",
label: "Supplier",
label: "Supplier Name & Code",
render: (supplier) => (
<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">
@ -185,15 +186,15 @@ export const SuppliersTable = ({
</span>
),
},
{
key: "status",
label: "Status",
render: (supplier) => (
<StatusBadge variant={getStatusVariant(supplier.status)}>
{supplier.status}
</StatusBadge>
),
},
// {
// key: "status",
// label: "Status",
// render: (supplier) => (
// <StatusBadge variant={getStatusVariant(supplier.status)}>
// {supplier.status}
// </StatusBadge>
// ),
// },
// {
// key: "location",
// label: "Location",
@ -268,38 +269,21 @@ export const SuppliersTable = ({
return (
<div className="flex flex-col gap-4">
{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="relative">
<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}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
/>
</div>
<FilterDropdown
<SearchBox
value={searchQuery}
onChange={(val) => {
setSearchQuery(val);
setCurrentPage(1);
}}
placeholder="Search by name or code..."
/>
{/* <FilterDropdown
label="Status"
options={[
{ value: "", label: "All Status" },
// { value: "", label: "All Status" },
{ value: "approved", label: "Approved" },
{ value: "qualified", label: "Qualified" },
{ value: "pending", label: "Pending" },
@ -311,7 +295,7 @@ export const SuppliersTable = ({
setStatusFilter(val as string);
setCurrentPage(1);
}}
/>
/> */}
</div>
<PrimaryButton
onClick={handleCreate}

View File

@ -9,27 +9,27 @@ import {
ShieldCheck,
Clock,
} from "lucide-react";
import { Modal, SecondaryButton, StatusBadge } from "@/components/shared";
import { Modal, SecondaryButton } from "@/components/shared";
import { supplierService } from "@/services/supplier-service";
import type { Supplier } from "@/types/supplier";
// Helper function to get status badge variant
const getStatusVariant = (
status: string,
): "success" | "failure" | "process" | "info" => {
switch (status.toLowerCase()) {
case "approved":
case "active":
return "success";
case "rejected":
case "inactive":
return "failure";
case "pending":
return "process";
default:
return "info";
}
};
// const getStatusVariant = (
// status: string,
// ): "success" | "failure" | "process" | "info" => {
// switch (status.toLowerCase()) {
// case "approved":
// case "active":
// return "success";
// case "rejected":
// case "inactive":
// return "failure";
// case "pending":
// return "process";
// default:
// return "info";
// }
// };
// Helper function to format date
const formatDate = (dateString: string): string => {
@ -231,7 +231,7 @@ export const ViewSupplierModal = ({
</div>
</div>
<div className="shrink-0">
{/* <div className="shrink-0">
<StatusBadge
variant={getStatusVariant(supplier.status)}
className=" shadow-xl !px-6 !py-2 !text-[12px] font-black"
@ -249,7 +249,7 @@ export const ViewSupplierModal = ({
</div>
</div>
)}
</div>
</div> */}
</div>
</div>
@ -276,16 +276,18 @@ export const ViewSupplierModal = ({
<div className="space-y-6">
<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">
{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>}
<p className="text-xs font-semibold text-slate-500">
{[supplier.address.city, supplier.address.state, supplier.address.postal_code].filter(Boolean).join(', ')}
</p>
<p className="text-[11px] font-black text-slate-800 uppercase tracking-wider pt-2 border-t mt-2">
{supplier.address.country}
</p>
{supplier.address.country && (
<p className="text-[11px] font-black text-slate-800 uppercase tracking-wider pt-2 border-t mt-2">
{supplier.address.country}
</p>
)}
</>
) : (
<p className="text-sm text-slate-400 italic">No address recorded</p>
@ -299,8 +301,8 @@ export const ViewSupplierModal = ({
{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="Expiry Date" value={supplier.dates?.expiry ? formatDate(supplier.dates.expiry) : null} />
{/* <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} /> */}
</div>
</div>
</div>
@ -321,10 +323,10 @@ export const ViewSupplierModal = ({
</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="Next Scheduled Audit" value={supplier.dates?.next_audit ? formatDate(supplier.dates.next_audit) : null} />
</div>
</div> */}
</div>
</div>
@ -363,7 +365,7 @@ export const ViewSupplierModal = ({
)}
{/* 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">
<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">
@ -390,7 +392,7 @@ export const ViewSupplierModal = ({
))}
</div>
</div>
)}
)} */}
{/* Operational Narrative */}
{/* <div className="space-y-4">

View File

@ -17,6 +17,7 @@ import type { WorkflowDefinition } from "@/types/workflow";
import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store";
import { formatDate } from "@/utils/format-date";
import CodeBadge from "./CodeBadge";
interface WorkflowDefinitionsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
@ -206,7 +207,7 @@ const WorkflowDefinitionsTable = ({
key: "entity_type",
label: "Entity Type",
render: (wf) => (
<span className="text-sm text-[#6b7280]">{wf.entity_type}</span>
<CodeBadge label={wf.entity_type} />
),
},
{

View File

@ -38,4 +38,6 @@ export { FormSlider } from './FormSlider';
export { RichTextEditor } from './RichTextEditor';
export { FileUploadModal } 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';

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

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

View File

@ -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 {
PrimaryButton,
// PrimaryButton,
StatusBadge,
ActionDropdown,
DataTable,
Pagination,
FilterDropdown,
// DataTable,
// Pagination,
// FilterDropdown,
// DeleteConfirmationModal,
SearchBox,
ActiveOnlyToggle,
type Column,
} from "@/components/shared";
import {
@ -15,8 +17,10 @@ import {
EditDepartmentModal,
ViewDepartmentModal,
} from "@/components/shared/DepartmentModals";
import { Plus, Search } from "lucide-react";
// import { Plus } from "lucide-react";
import { departmentService } from "@/services/department-service";
import { DepartmentListView } from "./DepartmentListView";
import { DepartmentTreeView } from "./DepartmentTreeView";
import type {
Department,
CreateDepartmentRequest,
@ -25,6 +29,7 @@ import type {
import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store";
import { useAppTheme } from "@/hooks/useAppTheme";
import CodeBadge from "../shared/CodeBadge";
interface DepartmentsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
@ -32,18 +37,24 @@ interface DepartmentsTableProps {
showHeader?: boolean;
}
const DepartmentsTable = ({
export interface DepartmentsTableRef {
openNewModal: () => void;
}
export const DepartmentsTable = forwardRef<DepartmentsTableRef, DepartmentsTableProps>(({
tenantId: propsTenantId,
compact = false,
showHeader = true,
}: DepartmentsTableProps): ReactElement => {
}: DepartmentsTableProps, ref): ReactElement => {
const { primaryColor } = useAppTheme();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId;
const [departments, setDepartments] = useState<Department[]>([]);
const [treeData, setTreeData] = useState<Department[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
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)
const [currentPage, setCurrentPage] = useState<number>(1);
@ -63,18 +74,32 @@ const DepartmentsTable = ({
useState<Department | null>(null);
const [isActionLoading, setIsActionLoading] = useState(false);
// Expose methods to parent
useImperativeHandle(ref, () => ({
openNewModal: () => setIsNewModalOpen(true),
}));
const fetchDepartments = async () => {
try {
setIsLoading(true);
setError(null);
const response = await departmentService.list(effectiveTenantId, {
active_only: activeOnly,
search: debouncedSearchQuery,
});
if (response.success) {
setDepartments(response.data);
if (viewMode === 'list') {
const response = await departmentService.list(effectiveTenantId, {
active_only: activeOnly,
search: debouncedSearchQuery,
});
if (response.success) {
setDepartments(response.data);
} else {
setError("Failed to load departments");
}
} else {
setError("Failed to load departments");
const response = await departmentService.getTree(effectiveTenantId, activeOnly);
if (response.success) {
setTreeData(response.data);
} else {
setError("Failed to load department tree");
}
}
} catch (err: any) {
setError(
@ -96,7 +121,7 @@ const DepartmentsTable = ({
useEffect(() => {
fetchDepartments();
}, [effectiveTenantId, activeOnly, debouncedSearchQuery]);
}, [effectiveTenantId, activeOnly, debouncedSearchQuery, viewMode]);
const handleCreate = async (data: CreateDepartmentRequest) => {
try {
@ -176,6 +201,13 @@ const DepartmentsTable = ({
<span className="text-sm font-medium text-[#0f1724]">{dept.name}</span>
),
},
{
key: "code",
label: "Code",
render: (dept) => (
<CodeBadge label={dept.code} />
),
},
{
key: "parent_name",
label: "Parent",
@ -237,10 +269,6 @@ const DepartmentsTable = ({
setSelectedDepartment(dept);
setIsEditModalOpen(true);
}}
// onDelete={() => {
// setSelectedDepartment(dept);
// setIsDeleteModalOpen(true);
// }}
/>
</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" : ""}`}
>
{showHeader && (
<div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className="relative flex-1 sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
<input
type="text"
placeholder="Search departments..."
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-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}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="flex flex-col border-b border-[rgba(0,0,0,0.08)]">
{/* Tabs */}
<div className="px-4 pt-3 flex items-center justify-between border-b border-transparent">
<div className="flex items-center gap-6">
<button
className="pb-3 text-sm font-medium transition-all relative"
style={{ color: viewMode === 'list' ? primaryColor : '#64748b' }}
onClick={() => setViewMode('list')}
>
List View
{viewMode === 'list' && (
<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>
<FilterDropdown
label="Status"
options={[
{ value: "all", label: "All Status" },
{ value: "active", label: "Active Only" },
]}
value={activeOnly ? "active" : "all"}
onChange={(value) => setActiveOnly(value === "active")}
{/* Active Only Toggle */}
<ActiveOnlyToggle
activeOnly={activeOnly}
onChange={setActiveOnly}
className="pb-3"
/>
</div>
<PrimaryButton
size="default"
className="flex items-center gap-2 w-full sm:w-auto"
onClick={() => setIsNewModalOpen(true)}
>
<Plus className="w-4 h-4" />
<span>New Department</span>
</PrimaryButton>
<div 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>
)}
<DataTable
data={paginatedData}
columns={columns}
keyExtractor={(dept) => dept.id}
isLoading={isLoading}
error={error}
emptyMessage="No departments found"
/>
{totalItems > 0 && (
<Pagination
{viewMode === 'list' ? (
<DepartmentListView
data={paginatedData}
columns={columns}
isLoading={isLoading}
error={error}
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
@ -319,6 +350,20 @@ const DepartmentsTable = ({
setCurrentPage(1);
}}
/>
) : (
<DepartmentTreeView
data={treeData}
isLoading={isLoading}
error={error}
onAddSub={(item) => {
setSelectedDepartment(item);
setIsNewModalOpen(true);
}}
onEdit={(item) => {
setSelectedDepartment(item);
setIsEditModalOpen(true);
}}
/>
)}
<NewDepartmentModal
@ -364,6 +409,4 @@ const DepartmentsTable = ({
/> */}
</div>
);
};
export default DepartmentsTable;
});

View File

@ -6,16 +6,19 @@ import {
ActionDropdown,
DataTable,
Pagination,
FilterDropdown,
// DeleteConfirmationModal,
type Column,
SearchBox,
ActiveOnlyToggle,
} from "@/components/shared";
import {
NewDesignationModal,
EditDesignationModal,
ViewDesignationModal,
} from "@/components/shared/DesignationModals";
import { Plus, Search } from "lucide-react";
import {
Plus,
// , Search
} from "lucide-react";
import { designationService } from "@/services/designation-service";
import type {
Designation,
@ -24,7 +27,8 @@ import type {
} from "@/types/designation";
import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store";
import { useAppTheme } from "@/hooks/useAppTheme";
// import { useAppTheme } from "@/hooks/useAppTheme";
import CodeBadge from "../shared/CodeBadge";
interface DesignationsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
@ -37,7 +41,7 @@ const DesignationsTable = ({
compact = false,
showHeader = true,
}: DesignationsTableProps): ReactElement => {
const { primaryColor } = useAppTheme();
// const { primaryColor } = useAppTheme();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId;
@ -179,9 +183,7 @@ const DesignationsTable = ({
{
key: "code",
label: "Code",
render: (desig) => (
<span className="text-sm text-[#6b7280]">{desig.code}</span>
),
render: (desig) => <CodeBadge label={desig.code} />,
},
{
key: "level",
@ -245,37 +247,14 @@ const DesignationsTable = ({
{showHeader && (
<div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className="relative flex-1 sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
<input
type="text"
placeholder="Search designations..."
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-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}
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")}
<SearchBox
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search designations..."
/>
<ActiveOnlyToggle
activeOnly={activeOnly}
onChange={(val) => setActiveOnly(val)}
/>
</div>
<PrimaryButton

View File

@ -3,7 +3,7 @@ import type { ReactElement } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/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 { showToast } from '@/utils/toast';
import type { Module, UpdateModuleRequest } from '@/types/module';
@ -204,26 +204,28 @@ export const EditModuleModal = ({
<form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-6">
<section>
<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
label="Module Name"
required
placeholder="Enter module name"
error={errors.name?.message}
{...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>
<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}
</div>
</div>
</div>
<FormField
</div> */}
{/* </div> */}
<FormTextArea
label="Description"
placeholder="Enter module description"
error={errors.description?.message}
{...register('description')}
rows={4}
/>
</section>

View File

@ -1,71 +1,122 @@
import { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared';
import { Copy, Check } from 'lucide-react';
import { showToast } from '@/utils/toast';
import { useEffect, useState } from "react";
import type { ReactElement } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Modal,
FormField,
PrimaryButton,
SecondaryButton,
FormTextArea,
} from "@/components/shared";
import { Copy, Check } from "lucide-react";
import { showToast } from "@/utils/toast";
// Validation schema - matches backend validation
const newModuleSchema = z.object({
module_id: z
.string()
.min(1, 'module_id is required')
.min(3, 'module_id must be at least 3 characters')
.max(100, 'module_id must be at most 100 characters'),
.min(1, "module_id is required")
.min(3, "module_id must be at least 3 characters")
.max(100, "module_id must be at most 100 characters"),
name: z
.string()
.min(1, 'name is required')
.min(3, 'name must be at least 3 characters')
.max(100, 'name must be at most 100 characters'),
description: z.string().max(1000, 'description must be at most 1000 characters').optional().nullable(),
.min(1, "name is required")
.min(3, "name must be at least 3 characters")
.max(100, "name must be at most 100 characters"),
description: z
.string()
.max(1000, "description must be at most 1000 characters")
.optional()
.nullable(),
version: z
.string()
.min(1, 'version is required')
.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)'),
.min(1, "version is required")
.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)",
),
runtime_language: z
.string()
.min(1, 'runtime_language is required')
.max(50, 'runtime_language must be at most 50 characters'),
framework: z.string().max(50, 'framework must be at most 50 characters').optional().nullable(),
.min(1, "runtime_language is required")
.max(50, "runtime_language must be at most 50 characters"),
framework: z
.string()
.max(50, "framework must be at most 50 characters")
.optional()
.nullable(),
webhookurl: z
.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.null(),
])
.optional(),
sync_webhook_url: z
.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.null(),
])
.optional(),
frontend_base_url: z
.string()
.min(1, 'frontend_base_url is required')
.max(255, 'frontend_base_url must be at most 255 characters')
.url('Invalid URL format'),
.min(1, "frontend_base_url is required")
.max(255, "frontend_base_url must be at most 255 characters")
.url("Invalid URL format"),
backend_base_url: z
.string()
.min(1, 'backend_base_url is required')
.max(255, 'backend_base_url must be at most 255 characters')
.url('Invalid URL format'),
.min(1, "backend_base_url is required")
.max(255, "backend_base_url must be at most 255 characters")
.url("Invalid URL format"),
health_endpoint: z
.string()
.min(1, 'health_endpoint is required')
.max(255, 'health_endpoint must be at most 255 characters'),
.min(1, "health_endpoint is required")
.max(255, "health_endpoint must be at most 255 characters"),
endpoints: 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_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(),
cpu_request: z
.string()
.max(20, "cpu_request must be at most 20 characters")
.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(),
consecutive_failures: z.number().int().optional().nullable(),
registered_by: z.uuid().optional().nullable(),
@ -125,16 +176,16 @@ export const NewModuleModal = ({
useEffect(() => {
if (!isOpen) {
reset({
module_id: '',
name: '',
module_id: "",
name: "",
description: null,
version: '',
runtime_language: '',
version: "",
runtime_language: "",
framework: null,
webhookurl: null,
frontend_base_url: '',
backend_base_url: '',
health_endpoint: '',
frontend_base_url: "",
backend_base_url: "",
health_endpoint: "",
endpoints: null,
kafka_topics: null,
cpu_request: null,
@ -164,38 +215,75 @@ export const NewModuleModal = ({
if (response?.api_key?.key) {
setApiKey(response.api_key.key);
showToast.success(
'Module registered successfully',
'Your API key has been generated. Please copy and store it securely - this is the only time you will see it.'
"Module registered successfully",
"Your API key has been generated. Please copy and store it securely - this is the only time you will see it.",
);
} else {
showToast.success('Module registered successfully');
showToast.success("Module registered successfully");
onClose();
}
} catch (error: any) {
// Handle validation errors from API
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
if (
error?.response?.data?.details &&
Array.isArray(error.response.data.details)
) {
const validationErrors = error.response.data.details;
validationErrors.forEach((detail: { path: string; message: string }) => {
if (detail.path === '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, {
type: 'server',
message: detail.message,
});
}
});
validationErrors.forEach(
(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, {
type: "server",
message: detail.message,
});
}
},
);
} else {
// Handle general errors
// Check for nested error object with message property
const errorObj = error?.response?.data?.error;
const errorMessage =
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
(typeof errorObj === 'string' ? errorObj : null) ||
(typeof errorObj === "object" &&
errorObj !== null &&
"message" in errorObj
? errorObj.message
: null) ||
(typeof errorObj === "string" ? errorObj : null) ||
error?.response?.data?.message ||
error?.message ||
'Failed to create module. Please try again.';
setError('root', {
type: 'server',
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create module. Please try again.',
"Failed to create module. Please try again.";
setError("root", {
type: "server",
message:
typeof errorMessage === "string"
? errorMessage
: "Failed to create module. Please try again.",
});
}
}
@ -207,9 +295,9 @@ export const NewModuleModal = ({
await navigator.clipboard.writeText(apiKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
showToast.success('API key copied to clipboard');
showToast.success("API key copied to clipboard");
} 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}
className="px-4 py-2.5 text-sm"
>
{apiKey ? 'Close' : 'Cancel'}
{apiKey ? "Close" : "Cancel"}
</SecondaryButton>
{!apiKey && (
<PrimaryButton
@ -245,7 +333,7 @@ export const NewModuleModal = ({
size="default"
className="px-4 py-2.5 text-sm"
>
{isLoading ? 'Registering...' : 'Register Module'}
{isLoading ? "Registering..." : "Register Module"}
</PrimaryButton>
)}
</>
@ -260,11 +348,16 @@ export const NewModuleModal = ({
Important: Save Your API Key
</h3>
<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>
</div>
<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
type="button"
onClick={handleCopyApiKey}
@ -296,7 +389,9 @@ export const NewModuleModal = ({
<div className="flex flex-col gap-0">
{/* Basic Information Section */}
<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 gap-5">
<div className="flex-1">
@ -305,26 +400,25 @@ export const NewModuleModal = ({
required
placeholder="Enter module ID (e.g., my-module)"
error={errors.module_id?.message}
{...register('module_id')}
{...register("module_id")}
/>
</div>
<div className="flex-1">
<FormField
label="Module Name"
required
placeholder="Enter module name"
error={errors.name?.message}
{...register('name')}
{...register("name")}
/>
</div>
</div>
<FormField
<FormTextArea
label="Description"
placeholder="Enter module description (optional)"
placeholder="Enter module description"
error={errors.description?.message}
{...register('description')}
{...register("description")}
rows={4}
/>
{/* <div className="flex gap-5">
@ -334,7 +428,7 @@ export const NewModuleModal = ({
required
placeholder="e.g., 1.0.0"
error={errors.version?.message}
{...register('version')}
{...register("version")}
/>
{/* </div> */}
{/* <div className="flex-1">
@ -353,7 +447,9 @@ export const NewModuleModal = ({
{/* Runtime Information Section */}
<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-1">
<FormField
@ -361,7 +457,7 @@ export const NewModuleModal = ({
required
placeholder="e.g., Node.js, Python, Java"
error={errors.runtime_language?.message}
{...register('runtime_language')}
{...register("runtime_language")}
/>
</div>
<div className="flex-1">
@ -369,7 +465,7 @@ export const NewModuleModal = ({
label="Framework"
placeholder="e.g., Express, Django, Spring (optional)"
error={errors.framework?.message}
{...register('framework')}
{...register("framework")}
/>
</div>
</div>
@ -380,7 +476,7 @@ export const NewModuleModal = ({
placeholder="e.g., https://example.com/webhook/api-key"
error={errors.webhookurl?.message}
helperText="URL to receive the system API key"
{...register('webhookurl')}
{...register("webhookurl")}
/>
</div>
<div className="flex-1">
@ -389,7 +485,7 @@ export const NewModuleModal = ({
placeholder="e.g., https://example.com/webhook/sync"
error={errors.sync_webhook_url?.message}
helperText="URL to receive identity data (tenants, users, etc.)"
{...register('sync_webhook_url')}
{...register("sync_webhook_url")}
/>
</div>
</div>
@ -397,7 +493,9 @@ export const NewModuleModal = ({
{/* URL Configuration Section */}
<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 gap-5">
<div className="flex-1">
@ -407,7 +505,7 @@ export const NewModuleModal = ({
type="url"
placeholder="https://frontend.example.com"
error={errors.frontend_base_url?.message}
{...register('frontend_base_url')}
{...register("frontend_base_url")}
/>
</div>
<div className="flex-1">
@ -417,7 +515,7 @@ export const NewModuleModal = ({
type="url"
placeholder="https://backend.example.com"
error={errors.backend_base_url?.message}
{...register('backend_base_url')}
{...register("backend_base_url")}
/>
</div>
</div>
@ -427,14 +525,16 @@ export const NewModuleModal = ({
required
placeholder="/health"
error={errors.health_endpoint?.message}
{...register('health_endpoint')}
{...register("health_endpoint")}
/>
</div>
</div>
{/* Resource Configuration Section */}
<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 gap-5">
<div className="flex-1">
@ -442,7 +542,7 @@ export const NewModuleModal = ({
label="CPU Request"
placeholder="e.g., 100m, 0.5"
error={errors.cpu_request?.message}
{...register('cpu_request')}
{...register("cpu_request")}
/>
</div>
<div className="flex-1">
@ -450,7 +550,7 @@ export const NewModuleModal = ({
label="CPU Limit"
placeholder="e.g., 500m, 1"
error={errors.cpu_limit?.message}
{...register('cpu_limit')}
{...register("cpu_limit")}
/>
</div>
</div>
@ -461,7 +561,7 @@ export const NewModuleModal = ({
label="Memory Request"
placeholder="e.g., 128Mi, 512Mi"
error={errors.memory_request?.message}
{...register('memory_request')}
{...register("memory_request")}
/>
</div>
<div className="flex-1">
@ -469,7 +569,7 @@ export const NewModuleModal = ({
label="Memory Limit"
placeholder="e.g., 256Mi, 1Gi"
error={errors.memory_limit?.message}
{...register('memory_limit')}
{...register("memory_limit")}
/>
</div>
</div>
@ -484,9 +584,14 @@ export const NewModuleModal = ({
step="1"
placeholder="1"
error={errors.min_replicas?.message}
{...register('min_replicas', {
{...register("min_replicas", {
setValueAs: (value) => {
if (value === '' || value === null || value === undefined) return null;
if (
value === "" ||
value === null ||
value === undefined
)
return null;
const num = Number(value);
return isNaN(num) ? null : num;
},
@ -502,9 +607,14 @@ export const NewModuleModal = ({
step="1"
placeholder="5"
error={errors.max_replicas?.message}
{...register('max_replicas', {
{...register("max_replicas", {
setValueAs: (value) => {
if (value === '' || value === null || value === undefined) return null;
if (
value === "" ||
value === null ||
value === undefined
)
return null;
const num = Number(value);
return isNaN(num) ? null : num;
},
@ -517,6 +627,6 @@ export const NewModuleModal = ({
</div>
)}
</form>
</Modal >
</Modal>
);
};

View File

@ -10,10 +10,13 @@ import {
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
} from "@/components/shared";
import { Plus, Download, ArrowUpDown } from "lucide-react";
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 { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date";
@ -80,6 +83,14 @@ export const UsersTable = ({
// Filter state
const [statusFilter, setStatusFilter] = 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
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
@ -95,6 +106,8 @@ export const UsersTable = ({
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null,
searchQuery: string | null = null,
roleId: string | null = null,
): Promise<void> => {
try {
setIsLoading(true);
@ -106,8 +119,17 @@ export const UsersTable = ({
itemsPerPage,
status,
sortBy,
searchQuery,
roleId,
)
: await userService.getAll(page, itemsPerPage, status, sortBy);
: await userService.getAll(
page,
itemsPerPage,
status,
sortBy,
searchQuery,
roleId,
);
if (response.success) {
setUsers(response.data);
setPagination(response.pagination);
@ -121,9 +143,35 @@ export const UsersTable = ({
}
};
// Fetch roles for filter
useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy);
}, [currentPage, limit, statusFilter, orderBy, tenantId]);
const fetchRoles = async () => {
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: {
email: string;
@ -443,9 +491,14 @@ export const UsersTable = ({
return (
<>
<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>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search..."
/>
<FilterDropdown
label="Status"
options={[
@ -461,6 +514,19 @@ export const UsersTable = ({
}}
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
size="default"
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">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or email..."
/>
{/* Status Filter */}
<FilterDropdown
label="Status"
@ -578,6 +651,21 @@ export const UsersTable = ({
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 */}
<FilterDropdown
label="Sort by"

View File

@ -8,5 +8,5 @@ export { WebhookSyncModal } from './WebhookSyncModal';
export { ApikeyReissueModal } from './ApikeyReissueModal';
export { UsersTable } from './UsersTable';
export { RolesTable } from './RolesTable';
export { default as DepartmentsTable } from './DepartmentsTable';
export { DepartmentsTable, type DepartmentsTableRef } from './DepartmentsTable';
export { default as DesignationsTable } from './DesignationsTable';

View File

@ -1454,7 +1454,7 @@ const CreateTenantWizard = (): ReactElement => {
</div>
{/* 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>
<h4 className="text-xs font-medium text-[#0f1724] mb-1">
@ -1522,7 +1522,7 @@ const CreateTenantWizard = (): ReactElement => {
</div>
</label>
</div>
</div>
</div>*/}
</div>
)}

View File

@ -1570,7 +1570,7 @@ const EditTenant = (): ReactElement => {
</div>
{/* 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>
<h4 className="text-xs font-medium text-[#0f1724] mb-1">
@ -1638,7 +1638,7 @@ const EditTenant = (): ReactElement => {
</div>
</label>
</div>
</div>
</div> */}
</div>
)}

View File

@ -7,6 +7,7 @@ import {
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
ActionDropdown,
// SecondaryButton,
@ -79,6 +80,10 @@ const Modules = (): ReactElement => {
const [statusFilter, setStatusFilter] = 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
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
@ -93,6 +98,7 @@ const Modules = (): ReactElement => {
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null,
searchQuery: string | null = null,
): Promise<void> => {
try {
setIsLoading(true);
@ -102,6 +108,7 @@ const Modules = (): ReactElement => {
itemsPerPage,
status,
sortBy,
searchQuery,
);
if (response.success) {
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
useEffect(() => {
fetchModules(currentPage, limit, statusFilter, orderBy);
}, [currentPage, limit, statusFilter, orderBy]);
fetchModules(currentPage, limit, statusFilter, orderBy, debouncedSearch);
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch]);
// View module handler
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">
{/* Filters */}
<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 */}
<FilterDropdown
label="Status"

View File

@ -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 {
Calendar,
@ -22,8 +22,10 @@ import {
WorkflowDefinitionsTable,
SuppliersTable,
type Column,
PrimaryButton,
} 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 { moduleService } from "@/services/module-service";
import type { Tenant } from "@/types/tenant";
@ -31,7 +33,7 @@ import type { MyModule } from "@/types/module";
import { formatDate } from "@/utils/format-date";
import AuditLogs from "@/pages/tenant/AuditLogs";
import TenantSettings from "@/pages/tenant/Settings";
import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
// import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
import DesignationsTable from "@/components/superadmin/DesignationsTable";
type TabType =
@ -116,6 +118,9 @@ const TenantDetails = (): ReactElement => {
const [isLoading, setIsLoading] = useState<boolean>(true);
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
// Fetch tenant details
@ -203,6 +208,18 @@ const TenantDetails = (): ReactElement => {
{ label: "Tenant Management", path: "/tenants" },
{ 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">
{/* Tenant Header Card */}
@ -293,7 +310,7 @@ const TenantDetails = (): ReactElement => {
<RolesTable tenantId={id} compact={true} />
)}
{activeTab === "departments" && id && (
<DepartmentsTable tenantId={id} compact={true} />
<DepartmentsTable ref={departmentsRef} tenantId={id} compact={true} />
)}
{activeTab === "designations" && id && (
<DesignationsTable tenantId={id} compact={true} />

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
PrimaryButton,
StatusBadge,
@ -11,12 +11,12 @@ import {
FilterDropdown,
SearchBox,
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)
import { Plus, ArrowUpDown } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { tenantService } from '@/services/tenant-service';
import type { Tenant } from '@/types/tenant';
import { Plus, ArrowUpDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { tenantService } from "@/services/tenant-service";
import type { Tenant } from "@/types/tenant";
// Helper function to get tenant initials
const getTenantInitials = (name: string): string => {
const words = name.trim().split(/\s+/);
@ -29,26 +29,32 @@ const getTenantInitials = (name: string): string => {
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
// Helper function to get status badge variant
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
const getStatusVariant = (
status: string,
): "success" | "failure" | "process" => {
switch (status.toLowerCase()) {
case 'active':
return 'success';
case 'deleted':
return 'failure';
case 'suspended':
return 'process';
case "active":
return "success";
case "deleted":
return "failure";
case "suspended":
return "process";
default:
return 'success';
return "success";
}
};
// Helper function to format subscription tier
const formatSubscriptionTier = (tier: string | null): string => {
if (!tier) return 'N/A';
if (!tier) return "N/A";
return tier.charAt(0).toUpperCase() + tier.slice(1);
};
@ -82,15 +88,15 @@ const Tenants = (): ReactElement => {
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// Search state
const [search, setSearch] = useState<string>('');
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
// View, Edit, Delete modals
// 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 [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
const [selectedTenantName, setSelectedTenantName] = useState<string>('');
const [selectedTenantName, setSelectedTenantName] = useState<string>("");
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchTenants = async (
@ -98,20 +104,26 @@ const Tenants = (): ReactElement => {
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null,
searchQuery: string | null = null
searchQuery: string | null = null,
): Promise<void> => {
try {
setIsLoading(true);
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) {
setTenants(response.data);
setPagination(response.pagination);
} else {
setError('Failed to load tenants');
setError("Failed to load tenants");
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load tenants');
setError(err?.response?.data?.error?.message || "Failed to load tenants");
} finally {
setIsLoading(false);
}
@ -184,7 +196,7 @@ const Tenants = (): ReactElement => {
await tenantService.delete(selectedTenantId);
setDeleteModalOpen(false);
setSelectedTenantId(null);
setSelectedTenantName('');
setSelectedTenantName("");
await fetchTenants(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err; // Let the modal handle the error display
@ -196,25 +208,43 @@ const Tenants = (): ReactElement => {
// Define table columns
const columns: Column<Tenant>[] = [
{
key: 'name',
label: 'Tenant Name',
key: "name",
label: "Tenant Name",
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">
<span className="text-xs font-normal text-[#9aa6b2]">
{getTenantInitials(tenant.name)}
{tenant.name.substring(0, 2).toUpperCase()}
</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-[#112868]">
{tenant.name}
</span>
<span className="text-[10px] text-[#6b7280] font-mono leading-none mt-0.5">
{tenant?.slug}
</span>
</div>
<span className="text-sm font-normal text-[#0f1724]">
{tenant.name}
</span>
</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',
label: 'Status',
key: "status",
label: "Status",
render: (tenant) => (
<StatusBadge variant={getStatusVariant(tenant.status)}>
{tenant.status}
@ -222,17 +252,17 @@ const Tenants = (): ReactElement => {
),
},
{
key: 'max_users',
label: 'Users',
key: "max_users",
label: "Users",
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{tenant.max_users ?? 'N/A'}
{tenant.max_users ?? "N/A"}
</span>
),
},
{
key: 'subscription_tier',
label: 'Plan',
key: "subscription_tier",
label: "Plan",
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{formatSubscriptionTier(tenant.subscription_tier)}
@ -240,28 +270,28 @@ const Tenants = (): ReactElement => {
),
},
{
key: 'max_modules',
label: 'Modules',
key: "max_modules",
label: "Modules",
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{tenant.max_modules ?? 'N/A'}
{tenant.max_modules ?? "N/A"}
</span>
),
},
{
key: 'created_at',
label: 'Joined Date',
key: "created_at",
label: "Joined Date",
render: (tenant) => (
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(tenant.created_at)}
</span>
),
mobileLabel: 'Joined',
mobileLabel: "Joined",
},
{
key: 'actions',
label: 'Actions',
align: 'right',
key: "actions",
label: "Actions",
align: "right",
render: (tenant) => (
<div className="flex justify-end">
<ActionDropdown
@ -278,14 +308,17 @@ const Tenants = (): ReactElement => {
const mobileCardRenderer = (tenant: Tenant) => (
<div className="p-4">
<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">
<span className="text-xs font-normal text-[#9aa6b2]">
{getTenantInitials(tenant.name)}
</span>
</div>
<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}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5">
@ -317,13 +350,13 @@ const Tenants = (): ReactElement => {
<div>
<span className="text-[#9aa6b2]">Users:</span>
<p className="text-[#0f1724] font-normal mt-1">
{tenant.max_users ?? 'N/A'}
{tenant.max_users ?? "N/A"}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Modules:</span>
<p className="text-[#0f1724] font-normal mt-1">
{tenant.max_modules ?? 'N/A'}
{tenant.max_modules ?? "N/A"}
</p>
</div>
</div>
@ -334,11 +367,11 @@ const Tenants = (): ReactElement => {
<Layout
currentPage="Tenants"
pageHeader={{
title: 'Tenant List',
description: 'View and manage all tenants in your QAssure platform from a single place.',
title: "Tenant List",
description:
"View and manage all tenants in your QAssure platform from a single place.",
}}
>
{/* 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">
{/* Table Header with Filters */}
@ -349,16 +382,16 @@ const Tenants = (): ReactElement => {
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search tenants..."
placeholder="Search by name or slug..."
/>
{/* Status Filter */}
<FilterDropdown
label="Status"
options={[
{ value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'deleted', label: 'Deleted' },
{ value: "active", label: "Active" },
{ value: "suspended", label: "Suspended" },
{ value: "deleted", label: "Deleted" },
]}
value={statusFilter}
onChange={(value) => {
@ -372,12 +405,12 @@ const Tenants = (): ReactElement => {
<FilterDropdown
label="Sort by"
options={[
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
{ value: ["name", "asc"], label: "Name (A-Z)" },
{ value: ["name", "desc"], label: "Name (Z-A)" },
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
{ value: ["created_at", "desc"], label: "Created (Newest)" },
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
]}
value={orderBy}
onChange={(value) => {
@ -415,7 +448,7 @@ const Tenants = (): ReactElement => {
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => navigate('/tenants/create-wizard')}
onClick={() => navigate("/tenants/create-wizard")}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">Add Tenant</span>
@ -479,7 +512,7 @@ const Tenants = (): ReactElement => {
onClose={() => {
setDeleteModalOpen(false);
setSelectedTenantId(null);
setSelectedTenantName('');
setSelectedTenantName("");
}}
onConfirm={handleConfirmDelete}
title="Delete Tenant"

View File

@ -1,17 +1,30 @@
import { type ReactElement } from 'react';
import { type ReactElement, useRef } from 'react';
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 tableRef = useRef<DepartmentsTableRef>(null);
return (
<Layout
currentPage="Departments"
pageHeader={{
title: 'Department Management',
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>
);
};

View File

@ -11,6 +11,7 @@ import {
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
} from '@/components/shared';
import { Plus, ArrowUpDown } from 'lucide-react';
@ -19,6 +20,7 @@ import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
import { showToast } from '@/utils/toast';
import { NewRoleModal } from '@/components/shared/NewRoleModal';
import { usePermissions } from '@/hooks/usePermissions';
import CodeBadge from '@/components/shared/CodeBadge';
// Helper function to format date
const formatDate = (dateString: string): string => {
@ -69,6 +71,10 @@ const Roles = (): ReactElement => {
// const [scopeFilter, setScopeFilter] = 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
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
@ -82,12 +88,13 @@ const Roles = (): ReactElement => {
page: number,
itemsPerPage: number,
// scope: string | null = null,
sortBy: string[] | null = null
sortBy: string[] | null = null,
searchQuery: string | null = null
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await roleService.getAll(page, itemsPerPage, sortBy);
const response = await roleService.getAll(page, itemsPerPage, sortBy, searchQuery);
if (response.success) {
setRoles(response.data);
setPagination(response.pagination);
@ -101,9 +108,18 @@ const Roles = (): ReactElement => {
}
};
// Handle search debouncing
useEffect(() => {
fetchRoles(currentPage, limit, orderBy);
}, [currentPage, limit, orderBy]);
const timer = setTimeout(() => {
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> => {
try {
@ -199,7 +215,7 @@ const Roles = (): ReactElement => {
key: 'code',
label: 'Code',
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>
),
},
{
key: 'user_count',
label: 'Users',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">
{role.user_count || 0}
</span>
),
},
{
key: 'description',
label: 'Description',
@ -271,6 +296,10 @@ const Roles = (): ReactElement => {
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Users:</span>
<p className="text-[#0f1724] font-normal mt-1">{role.user_count || 0}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Created:</span>
<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">
{/* Filters */}
<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 */}
{/* <FilterDropdown
label="Scope"

View File

@ -12,7 +12,7 @@ const Suppliers = (): ReactElement => {
}}
>
<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 items-center justify-between">
<h2 className="text-lg font-semibold text-[#0f1724]">

View File

@ -12,11 +12,14 @@ import {
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
} from "@/components/shared";
import { Plus, ArrowUpDown } from "lucide-react";
import { userService } from "@/services/user-service";
import { roleService } from "@/services/role-service";
import type { User } from "@/types/user";
import type { Role } from "@/types/role";
import { showToast } from "@/utils/toast";
import { usePermissions } from "@/hooks/usePermissions";
import { useAppTheme } from "@/hooks/useAppTheme";
@ -87,6 +90,14 @@ const Users = (): ReactElement => {
// Filter state
const [statusFilter, setStatusFilter] = 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
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
@ -102,6 +113,8 @@ const Users = (): ReactElement => {
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null,
searchQuery: string | null = null,
roleId: string | null = null,
): Promise<void> => {
try {
setIsLoading(true);
@ -111,6 +124,8 @@ const Users = (): ReactElement => {
itemsPerPage,
status,
sortBy,
searchQuery,
roleId,
);
if (response.success) {
setUsers(response.data);
@ -125,9 +140,35 @@ const Users = (): ReactElement => {
}
};
// Handle search debouncing
useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy);
}, [currentPage, limit, statusFilter, orderBy]);
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 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: {
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">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or email..."
/>
{/* Status Filter */}
<FilterDropdown
label="Status"
@ -457,6 +505,21 @@ const Users = (): ReactElement => {
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 */}
<FilterDropdown
label="Sort by"

View File

@ -10,7 +10,8 @@ export const moduleService = {
page: number = 1,
limit: number = 20,
status?: string | null,
orderBy?: string[] | null
orderBy?: string[] | null,
search?: string | null
): Promise<ModulesResponse> => {
const params = new URLSearchParams();
params.append('page', String(page));
@ -18,6 +19,9 @@ export const moduleService = {
if (status) {
params.append('status', status);
}
if (search) {
params.append('search', search);
}
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
params.append('orderBy[]', orderBy[0]);
params.append('orderBy[]', orderBy[1]);

View File

@ -13,15 +13,15 @@ export const roleService = {
getAll: async (
page: number = 1,
limit: number = 20,
// scope?: string | null,
orderBy?: string[] | null
orderBy?: string[] | null,
search?: string | null
): Promise<RolesResponse> => {
const params = new URLSearchParams();
params.append('page', String(page));
params.append('limit', String(limit));
// if (scope) {
// params.append('scope', scope);
// }
if (search) {
params.append('search', search);
}
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
params.append('orderBy[]', orderBy[0]);
params.append('orderBy[]', orderBy[1]);
@ -33,16 +33,16 @@ export const roleService = {
tenantId: string,
page: number = 1,
limit: number = 20,
// scope?: string | null,
orderBy?: string[] | null
orderBy?: string[] | null,
search?: string | null
): Promise<RolesResponse> => {
const params = new URLSearchParams();
params.append('page', String(page));
params.append('limit', String(limit));
params.append('tenant_id', tenantId);
// if (scope) {
// params.append('scope', scope);
// }
if (search) {
params.append('search', search);
}
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
params.append('orderBy[]', orderBy[0]);
params.append('orderBy[]', orderBy[1]);

View File

@ -14,7 +14,9 @@ const getAllUsers = async (
limit: number = 20,
status?: string | null,
orderBy?: string[] | null,
tenantId?: string | null
tenantId?: string | null,
search?: string | null,
roleId?: string | null
): Promise<UsersResponse> => {
const params = new URLSearchParams();
params.append('page', String(page));
@ -25,6 +27,12 @@ const getAllUsers = async (
if (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) {
// Send array as orderBy[]=field&orderBy[]=direction
params.append('orderBy[]', orderBy[0]);
@ -35,7 +43,15 @@ const getAllUsers = async (
};
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> => {
const response = await apiClient.post<CreateUserResponse>('/users', data);
return response.data;
@ -49,9 +65,11 @@ export const userService = {
page: number = 1,
limit: number = 20,
status?: string | null,
orderBy?: string[] | null
orderBy?: string[] | null,
search?: string | null,
roleId?: string | null
): 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> => {
const response = await apiClient.put<UpdateUserResponse>(`/users/${id}`, data);

View File

@ -9,6 +9,7 @@ export interface Role {
module_ids?: string[] | null;
modules?: string[] | null;
permissions?: Permission[] | null;
user_count?: number;
created_at: string;
updated_at: string;
}