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, PrimaryButton,
SecondaryButton, SecondaryButton,
PaginatedSelect, PaginatedSelect,
FormTextArea,
} from "@/components/shared"; } from "@/components/shared";
import type { import type {
Department, Department,
@ -140,11 +141,18 @@ export const NewDepartmentModal = ({
/> />
</div> </div>
<FormField {/* <FormField
label="Description" label="Description"
placeholder="Enter department description" placeholder="Enter department description"
error={errors.description?.message} error={errors.description?.message}
{...register("description")} {...register("description")}
/> */}
<FormTextArea
label="Description"
placeholder="Enter department description"
error={errors.description?.message}
{...register("description")}
rows={4}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -320,11 +328,18 @@ export const EditDepartmentModal = ({
/> />
</div> </div>
<FormField {/* <FormField
label="Description" label="Description"
placeholder="Enter department description" placeholder="Enter department description"
error={errors.description?.message} error={errors.description?.message}
{...register("description")} {...register("description")}
/> */}
<FormTextArea
label="Description"
placeholder="Enter department description"
error={errors.description?.message}
{...register("description")}
rows={4}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@ -8,6 +8,7 @@ import {
FormSelect, FormSelect,
PrimaryButton, PrimaryButton,
SecondaryButton, SecondaryButton,
FormTextArea,
} from "@/components/shared"; } from "@/components/shared";
import type { import type {
Designation, Designation,
@ -113,11 +114,18 @@ export const NewDesignationModal = ({
/> />
</div> </div>
<FormField {/* <FormField
label="Description" label="Description"
placeholder="Enter designation description" placeholder="Enter designation description"
error={errors.description?.message} error={errors.description?.message}
{...register("description")} {...register("description")}
/> */}
<FormTextArea
label="Description"
placeholder="Enter designation description"
error={errors.description?.message}
{...register("description")}
rows={4}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -243,11 +251,18 @@ export const EditDesignationModal = ({
/> />
</div> </div>
<FormField {/* <FormField
label="Description" label="Description"
placeholder="Enter designation description" placeholder="Enter designation description"
error={errors.description?.message} error={errors.description?.message}
{...register("description")} {...register("description")}
/> */}
<FormTextArea
label="Description"
placeholder="Enter designation description"
error={errors.description?.message}
{...register("description")}
rows={4}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
FormSelect, FormSelect,
PrimaryButton, PrimaryButton,
SecondaryButton, SecondaryButton,
ActiveOnlyToggle,
} from "@/components/shared"; } from "@/components/shared";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { supplierService } from "@/services/supplier-service"; import { supplierService } from "@/services/supplier-service";
@ -23,12 +24,31 @@ const supplierSchema = z.object({
status: z.string().optional(), status: z.string().optional(),
risk_level: z.string().optional(), risk_level: z.string().optional(),
website: z.string().url("Invalid URL").optional().or(z.literal("")), website: z.string().url("Invalid URL").optional().or(z.literal("")),
products_services_input: z.string().min(1, "At least one product/service is required"), products_services_input: z
.string()
.min(1, "At least one product/service is required"),
// Primary Contact // Primary Contact
primary_contact_name: z.string().optional(), primary_contact_name: z.string().optional(),
primary_contact_email: z.string().email("Invalid email").optional().or(z.literal("")), primary_contact_email: z
primary_contact_phone: z.string().optional(), .string()
.email("Invalid email")
.optional()
.or(z.literal("")),
// primary_contact_phone: z.string().optional(),
primary_contact_phone: z
.string()
.optional()
.nullable()
.refine(
(val) => {
if (!val || val.trim() === "") return true; // Optional field, empty is valid
return /^\d{10}$/.test(val);
},
{
message: "Phone number must be exactly 10 digits",
},
),
// Address // Address
address_line1: z.string().optional(), address_line1: z.string().optional(),
@ -44,15 +64,29 @@ const supplierSchema = z.object({
// Quality Agreement // Quality Agreement
quality_agreement_signed: z.boolean().optional(), quality_agreement_signed: z.boolean().optional(),
quality_agreement_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(), quality_agreement_date: z
.union([z.date(), z.string(), z.null()])
.optional()
.nullable(),
// Certifications // Certifications
certifications: z.array(z.object({ certifications: z
.array(
z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
issuing_body: z.string().optional().or(z.literal("")), issuing_body: z.string().optional().or(z.literal("")),
expiry_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(), expiry_date: z
document_url: z.string().url("Invalid URL").optional().or(z.literal("")), .union([z.date(), z.string(), z.null()])
})).default([]), .optional()
.nullable(),
document_url: z
.string()
.url("Invalid URL")
.optional()
.or(z.literal("")),
}),
)
.default([]),
}); });
type SupplierFormData = z.infer<typeof supplierSchema>; type SupplierFormData = z.infer<typeof supplierSchema>;
@ -91,6 +125,7 @@ export const SupplierModal = ({
handleSubmit, handleSubmit,
control, control,
reset, reset,
setValue,
formState: { errors }, formState: { errors },
} = useForm<SupplierFormData>({ } = useForm<SupplierFormData>({
resolver: zodResolver(supplierSchema) as any, resolver: zodResolver(supplierSchema) as any,
@ -152,7 +187,7 @@ export const SupplierModal = ({
const formatDateForInput = (val: any) => { const formatDateForInput = (val: any) => {
if (!val) return ""; if (!val) return "";
const d = new Date(val); const d = new Date(val);
return isNaN(d.getTime()) ? "" : d.toISOString().split('T')[0]; return isNaN(d.getTime()) ? "" : d.toISOString().split("T")[0];
}; };
useEffect(() => { useEffect(() => {
@ -173,7 +208,9 @@ export const SupplierModal = ({
status: s.status || "pending", status: s.status || "pending",
risk_level: s.risk_level || "low", risk_level: s.risk_level || "low",
website: s.website || "", website: s.website || "",
products_services_input: Array.isArray(s.products_services) ? s.products_services.join(", ") : "", products_services_input: Array.isArray(s.products_services)
? s.products_services.join(", ")
: "",
primary_contact_name: s.contact?.name || "", primary_contact_name: s.contact?.name || "",
primary_contact_email: s.contact?.email || "", primary_contact_email: s.contact?.email || "",
@ -189,8 +226,12 @@ export const SupplierModal = ({
tax_id: s.tax_id || "", tax_id: s.tax_id || "",
duns_number: s.duns_number || "", duns_number: s.duns_number || "",
quality_agreement_signed: s.quality_agreement?.signed || false, quality_agreement_signed: s.quality_agreement?.signed || false,
quality_agreement_date: s.quality_agreement?.date ? new Date(s.quality_agreement.date) : null, quality_agreement_date: s.quality_agreement?.date
certifications: Array.isArray(s.certifications) ? s.certifications : [], ? new Date(s.quality_agreement.date)
: null,
certifications: Array.isArray(s.certifications)
? s.certifications
: [],
}); });
} }
} catch (error: any) { } catch (error: any) {
@ -238,8 +279,11 @@ export const SupplierModal = ({
const payload: any = { const payload: any = {
...data, ...data,
products_services: data.products_services_input products_services: data.products_services_input
? data.products_services_input.split(',').map((item: string) => item.trim()).filter(Boolean) ? data.products_services_input
: [] .split(",")
.map((item: string) => item.trim())
.filter(Boolean)
: [],
}; };
// Remove the UI-specific field before sending // Remove the UI-specific field before sending
@ -258,7 +302,8 @@ export const SupplierModal = ({
showToast.error( showToast.error(
mode === "create" mode === "create"
? error?.response?.data?.error?.message || "Failed to create supplier" ? error?.response?.data?.error?.message || "Failed to create supplier"
: error?.response?.data?.error?.message || "Failed to update supplier", : error?.response?.data?.error?.message ||
"Failed to update supplier",
error?.message, error?.message,
); );
} finally { } finally {
@ -270,7 +315,13 @@ export const SupplierModal = ({
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={mode === "create" ? "Add New Supplier" : mode === "view" ? "View Supplier" : "Edit Supplier"} title={
mode === "create"
? "Add New Supplier"
: mode === "view"
? "View Supplier"
: "Edit Supplier"
}
maxWidth="lg" maxWidth="lg"
footer={ footer={
<div className="p-3 flex justify-end gap-3"> <div className="p-3 flex justify-end gap-3">
@ -300,7 +351,9 @@ export const SupplierModal = ({
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 p-5"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 p-5">
{/* Basic Information */} {/* Basic Information */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Basic Information</h4> <h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
Basic Information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField <FormField
label="Supplier Name" label="Supplier Name"
@ -421,7 +474,9 @@ export const SupplierModal = ({
{/* Products & Services */} {/* Products & Services */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Products & Services</h4> <h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
Products & Services
</h4>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<FormField <FormField
label="Deliverables" label="Deliverables"
@ -436,7 +491,9 @@ export const SupplierModal = ({
{/* Contact Person */} {/* Contact Person */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Primary Contact Person</h4> <h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
Primary Contact Person
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField <FormField
label="Contact Name" label="Contact Name"
@ -452,19 +509,41 @@ export const SupplierModal = ({
{...register("primary_contact_email")} {...register("primary_contact_email")}
error={errors.primary_contact_email?.message} error={errors.primary_contact_email?.message}
/> />
<FormField {/* <FormField
label="Contact Phone" label="Contact Phone"
placeholder="+1 (555) 000-0000" placeholder="+1 (555) 000-0000"
disabled={mode === "view" || isSubmitting} disabled={mode === "view" || isSubmitting}
{...register("primary_contact_phone")} {...register("primary_contact_phone")}
error={errors.primary_contact_phone?.message} error={errors.primary_contact_phone?.message}
/> */}
<FormField
label="Contact Phone"
type="tel"
placeholder="Enter 10-digit phone number"
maxLength={10}
error={
errors.primary_contact_phone?.message
}
{...register("primary_contact_phone", {
onChange: (e) => {
// Only allow digits and limit to 10 characters
const value = e.target.value
.replace(/\D/g, "")
.slice(0, 10);
setValue("primary_contact_phone", value, {
shouldValidate: true,
});
},
})}
/> />
</div> </div>
</div> </div>
{/* Address Information */} {/* Address Information */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Company Address</h4> <h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
Company Address
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField <FormField
label="Address Line 1" label="Address Line 1"
@ -513,19 +592,22 @@ export const SupplierModal = ({
{/* Quality Agreement */} {/* Quality Agreement */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-semibold text-[#112868] border-b pb-2">Quality Agreement</h4> <h4 className="text-sm font-semibold text-[#112868] border-b pb-2">
Quality Agreement
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-end"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<input <Controller
type="checkbox" name="quality_agreement_signed"
id="quality_agreement_signed" control={control}
className="w-4 h-4 text-[#112868] rounded border-gray-300 focus:ring-[#112868]" render={({ field }) => (
disabled={mode === "view" || isSubmitting} <ActiveOnlyToggle
{...register("quality_agreement_signed")} activeOnly={field.value || false}
onChange={field.onChange}
label="Quality Agreement Signed"
/>
)}
/> />
<label htmlFor="quality_agreement_signed" className="text-sm font-medium text-gray-700">
Quality Agreement Signed
</label>
</div> </div>
<Controller <Controller
name="quality_agreement_date" name="quality_agreement_date"
@ -548,11 +630,20 @@ export const SupplierModal = ({
{/* Certifications Section */} {/* Certifications Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between border-b pb-2"> <div className="flex items-center justify-between border-b pb-2">
<h4 className="text-sm font-semibold text-[#112868]">Certifications</h4> <h4 className="text-sm font-semibold text-[#112868]">
Certifications
</h4>
{mode !== "view" && ( {mode !== "view" && (
<button <button
type="button" type="button"
onClick={() => append({ name: "", issuing_body: "", expiry_date: null, document_url: "" })} onClick={() =>
append({
name: "",
issuing_body: "",
expiry_date: null,
document_url: "",
})
}
className="flex items-center gap-1 text-[12px] font-medium text-[#112868] hover:opacity-80 transition-opacity" className="flex items-center gap-1 text-[12px] font-medium text-[#112868] hover:opacity-80 transition-opacity"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
@ -563,10 +654,15 @@ export const SupplierModal = ({
<div className="space-y-4"> <div className="space-y-4">
{fields.length === 0 ? ( {fields.length === 0 ? (
<p className="text-sm text-gray-500 italic py-2">No certifications added.</p> <p className="text-sm text-gray-500 italic py-2">
No certifications added.
</p>
) : ( ) : (
fields.map((field, index) => ( fields.map((field, index) => (
<div key={field.id} className="p-4 bg-gray-50 rounded-lg border border-gray-100 relative group"> <div
key={field.id}
className="p-4 bg-gray-50 rounded-lg border border-gray-100 relative group"
>
{mode !== "view" && ( {mode !== "view" && (
<button <button
type="button" type="button"
@ -583,7 +679,10 @@ export const SupplierModal = ({
required required
disabled={mode === "view" || isSubmitting} disabled={mode === "view" || isSubmitting}
{...register(`certifications.${index}.name`)} {...register(`certifications.${index}.name`)}
error={(errors.certifications?.[index] as any)?.name?.message} error={
(errors.certifications?.[index] as any)?.name
?.message
}
/> />
<FormField <FormField
label="Issuing Body" label="Issuing Body"
@ -601,7 +700,9 @@ export const SupplierModal = ({
disabled={mode === "view" || isSubmitting} disabled={mode === "view" || isSubmitting}
{...dateField} {...dateField}
value={formatDateForInput(dateField.value)} value={formatDateForInput(dateField.value)}
onChange={(e) => dateField.onChange(e.target.value || null)} onChange={(e) =>
dateField.onChange(e.target.value || null)
}
/> />
)} )}
/> />
@ -610,7 +711,10 @@ export const SupplierModal = ({
placeholder="Link to certificate" placeholder="Link to certificate"
disabled={mode === "view" || isSubmitting} disabled={mode === "view" || isSubmitting}
{...register(`certifications.${index}.document_url`)} {...register(`certifications.${index}.document_url`)}
error={(errors.certifications?.[index] as any)?.document_url?.message} error={
(errors.certifications?.[index] as any)
?.document_url?.message
}
/> />
</div> </div>
</div> </div>

View File

@ -5,18 +5,19 @@ import {
ActionDropdown, ActionDropdown,
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, // FilterDropdown,
type Column, type Column,
SupplierModal, SupplierModal,
ViewSupplierModal, ViewSupplierModal,
SupplierContactsModal, SupplierContactsModal,
SupplierScorecardsModal, SupplierScorecardsModal,
SearchBox,
} from "@/components/shared"; } from "@/components/shared";
import { Plus, Building2 } from "lucide-react"; import { Plus, Building2 } from "lucide-react";
import { supplierService } from "@/services/supplier-service"; import { supplierService } from "@/services/supplier-service";
import type { Supplier } from "@/types/supplier"; import type { Supplier } from "@/types/supplier";
// import { formatDate } from "@/utils/format-date"; // import { formatDate } from "@/utils/format-date";
import { useAppTheme } from "@/hooks/useAppTheme"; // import { useAppTheme } from "@/hooks/useAppTheme";
interface SuppliersTableProps { interface SuppliersTableProps {
tenantId?: string | null; tenantId?: string | null;
@ -60,14 +61,14 @@ export const SuppliersTable = ({
showHeader = true, showHeader = true,
compact = false, compact = false,
}: SuppliersTableProps): ReactElement => { }: SuppliersTableProps): ReactElement => {
const { primaryColor } = useAppTheme(); // const { primaryColor } = useAppTheme();
const [suppliers, setSuppliers] = useState<Supplier[]>([]); const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(compact ? 5 : 10); const [limit, setLimit] = useState<number>(compact ? 5 : 10);
const [total, setTotal] = useState<number>(0); const [total, setTotal] = useState<number>(0);
const [statusFilter, setStatusFilter] = useState<string | null>(null); // const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
// Modal state // Modal state
@ -89,7 +90,7 @@ export const SuppliersTable = ({
setError(null); setError(null);
const response = await supplierService.list({ const response = await supplierService.list({
tenantId, tenantId,
status: statusFilter || undefined, // status: statusFilter || undefined,
search: searchQuery || undefined, search: searchQuery || undefined,
limit, limit,
offset: (currentPage - 1) * limit, offset: (currentPage - 1) * limit,
@ -112,7 +113,7 @@ export const SuppliersTable = ({
useEffect(() => { useEffect(() => {
fetchSuppliers(); fetchSuppliers();
}, [tenantId, currentPage, limit, statusFilter, searchQuery]); }, [tenantId, currentPage, limit, searchQuery]);
const handleCreate = () => { const handleCreate = () => {
setModalMode("create"); setModalMode("create");
@ -146,7 +147,7 @@ export const SuppliersTable = ({
const columns: Column<Supplier>[] = [ const columns: Column<Supplier>[] = [
{ {
key: "name", key: "name",
label: "Supplier", label: "Supplier Name & Code",
render: (supplier) => ( render: (supplier) => (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0"> <div className="w-8 h-8 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
@ -185,15 +186,15 @@ export const SuppliersTable = ({
</span> </span>
), ),
}, },
{ // {
key: "status", // key: "status",
label: "Status", // label: "Status",
render: (supplier) => ( // render: (supplier) => (
<StatusBadge variant={getStatusVariant(supplier.status)}> // <StatusBadge variant={getStatusVariant(supplier.status)}>
{supplier.status} // {supplier.status}
</StatusBadge> // </StatusBadge>
), // ),
}, // },
// { // {
// key: "location", // key: "location",
// label: "Location", // label: "Location",
@ -268,38 +269,21 @@ export const SuppliersTable = ({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{showHeader && ( {showHeader && (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 px-4 py-3 bg-white border-b border-[rgba(0,0,0,0.08)]"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 py-3 bg-white border-b border-[rgba(0,0,0,0.08)]">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative"> <SearchBox
<input
type="text"
placeholder="Search suppliers..."
className="pl-3 pr-10 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-xs focus:outline-none focus:ring-2 w-full sm:w-64 transition-all"
style={{
// @ts-ignore
'--tw-ring-color': `${primaryColor}33`,
borderColor: 'rgba(0,0,0,0.08)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(val) => {
setSearchQuery(e.target.value); setSearchQuery(val);
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="Search by name or code..."
/> />
</div> {/* <FilterDropdown
<FilterDropdown
label="Status" label="Status"
options={[ options={[
{ value: "", label: "All Status" }, // { value: "", label: "All Status" },
{ value: "approved", label: "Approved" }, { value: "approved", label: "Approved" },
{ value: "qualified", label: "Qualified" }, { value: "qualified", label: "Qualified" },
{ value: "pending", label: "Pending" }, { value: "pending", label: "Pending" },
@ -311,7 +295,7 @@ export const SuppliersTable = ({
setStatusFilter(val as string); setStatusFilter(val as string);
setCurrentPage(1); setCurrentPage(1);
}} }}
/> /> */}
</div> </div>
<PrimaryButton <PrimaryButton
onClick={handleCreate} onClick={handleCreate}

View File

@ -9,27 +9,27 @@ import {
ShieldCheck, ShieldCheck,
Clock, Clock,
} from "lucide-react"; } from "lucide-react";
import { Modal, SecondaryButton, StatusBadge } from "@/components/shared"; import { Modal, SecondaryButton } from "@/components/shared";
import { supplierService } from "@/services/supplier-service"; import { supplierService } from "@/services/supplier-service";
import type { Supplier } from "@/types/supplier"; import type { Supplier } from "@/types/supplier";
// Helper function to get status badge variant // Helper function to get status badge variant
const getStatusVariant = ( // const getStatusVariant = (
status: string, // status: string,
): "success" | "failure" | "process" | "info" => { // ): "success" | "failure" | "process" | "info" => {
switch (status.toLowerCase()) { // switch (status.toLowerCase()) {
case "approved": // case "approved":
case "active": // case "active":
return "success"; // return "success";
case "rejected": // case "rejected":
case "inactive": // case "inactive":
return "failure"; // return "failure";
case "pending": // case "pending":
return "process"; // return "process";
default: // default:
return "info"; // return "info";
} // }
}; // };
// Helper function to format date // Helper function to format date
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
@ -231,7 +231,7 @@ export const ViewSupplierModal = ({
</div> </div>
</div> </div>
<div className="shrink-0"> {/* <div className="shrink-0">
<StatusBadge <StatusBadge
variant={getStatusVariant(supplier.status)} variant={getStatusVariant(supplier.status)}
className=" shadow-xl !px-6 !py-2 !text-[12px] font-black" className=" shadow-xl !px-6 !py-2 !text-[12px] font-black"
@ -249,7 +249,7 @@ export const ViewSupplierModal = ({
</div> </div>
</div> </div>
)} )}
</div> </div> */}
</div> </div>
</div> </div>
@ -276,16 +276,18 @@ export const ViewSupplierModal = ({
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader icon={MapPin} title="Headquarters" colorClass="bg-red-50 text-red-600" /> <SectionHeader icon={MapPin} title="Headquarters" colorClass="bg-red-50 text-red-600" />
<div className="p-4 bg-gray-50 rounded-xl space-y-2 border border-gray-100"> <div className="p-4 bg-gray-50 rounded-xl space-y-2 border border-gray-100">
{supplier.address ? ( {supplier.address && (supplier.address.line1 || supplier.address.city || supplier.address.country) ? (
<> <>
<p className="text-sm font-bold text-slate-700">{supplier.address.line1}</p> {supplier.address.line1 && <p className="text-sm font-bold text-slate-700">{supplier.address.line1}</p>}
{supplier.address.line2 && <p className="text-sm text-slate-600">{supplier.address.line2}</p>} {supplier.address.line2 && <p className="text-sm text-slate-600">{supplier.address.line2}</p>}
<p className="text-xs font-semibold text-slate-500"> <p className="text-xs font-semibold text-slate-500">
{[supplier.address.city, supplier.address.state, supplier.address.postal_code].filter(Boolean).join(', ')} {[supplier.address.city, supplier.address.state, supplier.address.postal_code].filter(Boolean).join(', ')}
</p> </p>
{supplier.address.country && (
<p className="text-[11px] font-black text-slate-800 uppercase tracking-wider pt-2 border-t mt-2"> <p className="text-[11px] font-black text-slate-800 uppercase tracking-wider pt-2 border-t mt-2">
{supplier.address.country} {supplier.address.country}
</p> </p>
)}
</> </>
) : ( ) : (
<p className="text-sm text-slate-400 italic">No address recorded</p> <p className="text-sm text-slate-400 italic">No address recorded</p>
@ -299,8 +301,8 @@ export const ViewSupplierModal = ({
{supplier.workflow_instance_id && ( {supplier.workflow_instance_id && (
<InfoRow label="Internal Workflow ID" value={supplier.workflow_instance_id} /> <InfoRow label="Internal Workflow ID" value={supplier.workflow_instance_id} />
)} )}
<InfoRow label="Approved On" value={supplier.dates?.approved ? formatDate(supplier.dates.approved) : "Pending Approval"} /> {/* <InfoRow label="Approved On" value={supplier.dates?.approved ? formatDate(supplier.dates.approved) : "Pending Approval"} />
<InfoRow label="Expiry Date" value={supplier.dates?.expiry ? formatDate(supplier.dates.expiry) : null} /> <InfoRow label="Expiry Date" value={supplier.dates?.expiry ? formatDate(supplier.dates.expiry) : null} /> */}
</div> </div>
</div> </div>
</div> </div>
@ -321,10 +323,10 @@ export const ViewSupplierModal = ({
</div> </div>
)} )}
</div> </div>
<div className="space-y-1"> {/* <div className="space-y-1">
<InfoRow label="Last Audit" value={supplier.dates?.last_audit ? formatDate(supplier.dates.last_audit) : null} /> <InfoRow label="Last Audit" value={supplier.dates?.last_audit ? formatDate(supplier.dates.last_audit) : null} />
<InfoRow label="Next Scheduled Audit" value={supplier.dates?.next_audit ? formatDate(supplier.dates.next_audit) : null} /> <InfoRow label="Next Scheduled Audit" value={supplier.dates?.next_audit ? formatDate(supplier.dates.next_audit) : null} />
</div> </div> */}
</div> </div>
</div> </div>
@ -363,7 +365,7 @@ export const ViewSupplierModal = ({
)} )}
{/* Extended Contacts Section */} {/* Extended Contacts Section */}
{supplier.contacts && supplier.contacts.length > 0 && ( {/* {supplier.contacts && supplier.contacts.length > 0 && (
<div className="space-y-4 pt-4 border-t border-gray-100"> <div className="space-y-4 pt-4 border-t border-gray-100">
<SectionHeader icon={Building2} title="Associated Contacts" colorClass="bg-slate-100 text-slate-600" /> <SectionHeader icon={Building2} title="Associated Contacts" colorClass="bg-slate-100 text-slate-600" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -390,7 +392,7 @@ export const ViewSupplierModal = ({
))} ))}
</div> </div>
</div> </div>
)} )} */}
{/* Operational Narrative */} {/* Operational Narrative */}
{/* <div className="space-y-4"> {/* <div className="space-y-4">

View File

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

View File

@ -38,4 +38,6 @@ export { FormSlider } from './FormSlider';
export { RichTextEditor } from './RichTextEditor'; export { RichTextEditor } from './RichTextEditor';
export { FileUploadModal } from './FileUploadModal'; export { FileUploadModal } from './FileUploadModal';
export type { FileUploadModalProps } from './FileUploadModal'; export type { FileUploadModalProps } from './FileUploadModal';
export { FileShareModal } from './FileShareModal';export { SearchBox } from './SearchBox'; export { FileShareModal } from './FileShareModal';
export { ActiveOnlyToggle } from './ActiveOnlyToggle';
export { SearchBox } from './SearchBox';

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

View File

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

View File

@ -3,7 +3,7 @@ import type { ReactElement } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared'; import { Modal, FormField, FormTextArea, PrimaryButton, SecondaryButton } from '@/components/shared';
import { Copy, Check, Loader2 } from 'lucide-react'; import { Copy, Check, Loader2 } from 'lucide-react';
import { showToast } from '@/utils/toast'; import { showToast } from '@/utils/toast';
import type { Module, UpdateModuleRequest } from '@/types/module'; import type { Module, UpdateModuleRequest } from '@/types/module';
@ -204,26 +204,28 @@ export const EditModuleModal = ({
<form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-6"> <form onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-6">
<section> <section>
<h3 className="text-sm font-semibold text-[#0f1724] mb-4">Basic Information</h3> <h3 className="text-sm font-semibold text-[#0f1724] mb-4">Basic Information</h3>
<div className="grid grid-cols-2 gap-5"> {/* <div className="grid grid-cols-2 gap-5"> */}
<FormField <FormField
label="Module Name" label="Module Name"
required required
placeholder="Enter module name" placeholder="Enter module name"
error={errors.name?.message} error={errors.name?.message}
{...register('name')} {...register('name')}
// helperText="Display name (3-100 chars)."
/> />
<div className="flex flex-col gap-2"> {/* <div className="flex flex-col gap-2">
<label className="text-[13px] font-medium text-[#0e1b2a]">Module ID (Read Only)</label> <label className="text-[13px] font-medium text-[#0e1b2a]">Module ID (Read Only)</label>
<div className="h-10 px-3.5 py-2 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-gray-500 font-mono"> <div className="h-10 px-3.5 py-2 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-md text-sm text-gray-500 font-mono">
{module.module_id} {module.module_id}
</div> </div>
</div> </div> */}
</div> {/* </div> */}
<FormField <FormTextArea
label="Description" label="Description"
placeholder="Enter module description" placeholder="Enter module description"
error={errors.description?.message} error={errors.description?.message}
{...register('description')} {...register('description')}
rows={4}
/> />
</section> </section>

View File

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

View File

@ -10,10 +10,13 @@ import {
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
SearchBox,
type Column, type Column,
} from "@/components/shared"; } from "@/components/shared";
import { Plus, Download, ArrowUpDown } from "lucide-react"; import { Plus, Download, ArrowUpDown } from "lucide-react";
import { userService } from "@/services/user-service"; import { userService } from "@/services/user-service";
import { roleService } from "@/services/role-service";
import type { Role } from "@/types/role";
import type { User } from "@/types/user"; import type { User } from "@/types/user";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date"; import { formatDate } from "@/utils/format-date";
@ -80,6 +83,14 @@ export const UsersTable = ({
// Filter state // Filter state
const [statusFilter, setStatusFilter] = useState<string | null>(null); const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null); const [orderBy, setOrderBy] = useState<string[] | null>(null);
const [roleFilter, setRoleFilter] = useState<string | null>(null);
// Search state
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
// Roles list for filter
const [roles, setRoles] = useState<Role[]>([]);
// View, Edit, Delete modals // View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
@ -95,6 +106,8 @@ export const UsersTable = ({
itemsPerPage: number, itemsPerPage: number,
status: string | null = null, status: string | null = null,
sortBy: string[] | null = null, sortBy: string[] | null = null,
searchQuery: string | null = null,
roleId: string | null = null,
): Promise<void> => { ): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -106,8 +119,17 @@ export const UsersTable = ({
itemsPerPage, itemsPerPage,
status, status,
sortBy, sortBy,
searchQuery,
roleId,
) )
: await userService.getAll(page, itemsPerPage, status, sortBy); : await userService.getAll(
page,
itemsPerPage,
status,
sortBy,
searchQuery,
roleId,
);
if (response.success) { if (response.success) {
setUsers(response.data); setUsers(response.data);
setPagination(response.pagination); setPagination(response.pagination);
@ -121,9 +143,35 @@ export const UsersTable = ({
} }
}; };
// Fetch roles for filter
useEffect(() => { useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy); const fetchRoles = async () => {
}, [currentPage, limit, statusFilter, orderBy, tenantId]); try {
const response = tenantId
? await roleService.getByTenant(tenantId, 1, 100)
: await roleService.getAll(1, 100);
if (response.success) {
setRoles(response.data);
}
} catch (err) {
console.error("Failed to fetch roles:", err);
}
};
fetchRoles();
}, [tenantId]);
// Handle search debouncing
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter);
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter, tenantId]);
const handleCreateUser = async (data: { const handleCreateUser = async (data: {
email: string; email: string;
@ -443,9 +491,14 @@ export const UsersTable = ({
return ( return (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<h3 className="text-lg font-semibold text-[#0f1724]"></h3> <h3 className="text-lg font-semibold text-[#0f1724]"></h3>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search..."
/>
<FilterDropdown <FilterDropdown
label="Status" label="Status"
options={[ options={[
@ -461,6 +514,19 @@ export const UsersTable = ({
}} }}
placeholder="Filter by status" placeholder="Filter by status"
/> />
<FilterDropdown
label="Role"
options={[
{ value: "", label: "All Roles" },
...roles.map(role => ({ value: role.id, label: role.name }))
]}
value={roleFilter || ""}
onChange={(value) => {
setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="Filter by role"
/>
<PrimaryButton <PrimaryButton
size="default" size="default"
className="flex items-center gap-2" className="flex items-center gap-2"
@ -557,6 +623,13 @@ export const UsersTable = ({
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or email..."
/>
{/* Status Filter */} {/* Status Filter */}
<FilterDropdown <FilterDropdown
label="Status" label="Status"
@ -578,6 +651,21 @@ export const UsersTable = ({
placeholder="All" placeholder="All"
/> />
{/* Role Filter */}
<FilterDropdown
label="Role"
options={[
{ value: "", label: "All Roles" },
...roles.map(role => ({ value: role.id, label: role.name }))
]}
value={roleFilter || ""}
onChange={(value) => {
setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="Filter by role"
/>
{/* Sort Filter */} {/* Sort Filter */}
<FilterDropdown <FilterDropdown
label="Sort by" label="Sort by"

View File

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

View File

@ -1454,7 +1454,7 @@ const CreateTenantWizard = (): ReactElement => {
</div> </div>
{/* Security Settings */} {/* Security Settings */}
<div className="space-y-4"> {/* <div className="space-y-4">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
<div> <div>
<h4 className="text-xs font-medium text-[#0f1724] mb-1"> <h4 className="text-xs font-medium text-[#0f1724] mb-1">
@ -1522,7 +1522,7 @@ const CreateTenantWizard = (): ReactElement => {
</div> </div>
</label> </label>
</div> </div>
</div> </div>*/}
</div> </div>
)} )}

View File

@ -1570,7 +1570,7 @@ const EditTenant = (): ReactElement => {
</div> </div>
{/* Security Settings */} {/* Security Settings */}
<div className="space-y-4"> {/* <div className="space-y-4">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
<div> <div>
<h4 className="text-xs font-medium text-[#0f1724] mb-1"> <h4 className="text-xs font-medium text-[#0f1724] mb-1">
@ -1638,7 +1638,7 @@ const EditTenant = (): ReactElement => {
</div> </div>
</label> </label>
</div> </div>
</div> </div> */}
</div> </div>
)} )}

View File

@ -7,6 +7,7 @@ import {
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
SearchBox,
type Column, type Column,
ActionDropdown, ActionDropdown,
// SecondaryButton, // SecondaryButton,
@ -79,6 +80,10 @@ const Modules = (): ReactElement => {
const [statusFilter, setStatusFilter] = useState<string | null>(null); const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null); const [orderBy, setOrderBy] = useState<string[] | null>(null);
// Search state
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
// View modal // View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null); const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
@ -93,6 +98,7 @@ const Modules = (): ReactElement => {
itemsPerPage: number, itemsPerPage: number,
status: string | null = null, status: string | null = null,
sortBy: string[] | null = null, sortBy: string[] | null = null,
searchQuery: string | null = null,
): Promise<void> => { ): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -102,6 +108,7 @@ const Modules = (): ReactElement => {
itemsPerPage, itemsPerPage,
status, status,
sortBy, sortBy,
searchQuery,
); );
if (response.success) { if (response.success) {
setModules(response.data); setModules(response.data);
@ -116,10 +123,20 @@ const Modules = (): ReactElement => {
} }
}; };
// Handle search debouncing
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
// We only reset to first page if we are actively searching.
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
// Fetch modules on mount and when pagination/filters change // Fetch modules on mount and when pagination/filters change
useEffect(() => { useEffect(() => {
fetchModules(currentPage, limit, statusFilter, orderBy); fetchModules(currentPage, limit, statusFilter, orderBy, debouncedSearch);
}, [currentPage, limit, statusFilter, orderBy]); }, [currentPage, limit, statusFilter, orderBy, debouncedSearch]);
// View module handler // View module handler
const handleViewModule = (moduleId: string): void => { const handleViewModule = (moduleId: string): void => {
@ -404,6 +421,13 @@ const Modules = (): ReactElement => {
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name, ID or description..."
/>
{/* Status Filter */} {/* Status Filter */}
<FilterDropdown <FilterDropdown
label="Status" label="Status"

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 { useParams, useNavigate } from "react-router-dom";
import { import {
Calendar, Calendar,
@ -22,8 +22,10 @@ import {
WorkflowDefinitionsTable, WorkflowDefinitionsTable,
SuppliersTable, SuppliersTable,
type Column, type Column,
PrimaryButton,
} from "@/components/shared"; } from "@/components/shared";
import { UsersTable, RolesTable } from "@/components/superadmin"; import { UsersTable, RolesTable, DepartmentsTable, type DepartmentsTableRef } from "@/components/superadmin";
import { Plus } from "lucide-react";
import { tenantService } from "@/services/tenant-service"; import { tenantService } from "@/services/tenant-service";
import { moduleService } from "@/services/module-service"; import { moduleService } from "@/services/module-service";
import type { Tenant } from "@/types/tenant"; import type { Tenant } from "@/types/tenant";
@ -31,7 +33,7 @@ import type { MyModule } from "@/types/module";
import { formatDate } from "@/utils/format-date"; import { formatDate } from "@/utils/format-date";
import AuditLogs from "@/pages/tenant/AuditLogs"; import AuditLogs from "@/pages/tenant/AuditLogs";
import TenantSettings from "@/pages/tenant/Settings"; import TenantSettings from "@/pages/tenant/Settings";
import DepartmentsTable from "@/components/superadmin/DepartmentsTable"; // import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
import DesignationsTable from "@/components/superadmin/DesignationsTable"; import DesignationsTable from "@/components/superadmin/DesignationsTable";
type TabType = type TabType =
@ -116,6 +118,9 @@ const TenantDetails = (): ReactElement => {
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Refs for tables to trigger actions from header
const departmentsRef = useRef<DepartmentsTableRef>(null);
// Modules tab state - using assignedModules from tenant response // Modules tab state - using assignedModules from tenant response
// Fetch tenant details // Fetch tenant details
@ -203,6 +208,18 @@ const TenantDetails = (): ReactElement => {
{ label: "Tenant Management", path: "/tenants" }, { label: "Tenant Management", path: "/tenants" },
{ label: "Tenant Details" }, { label: "Tenant Details" },
]} ]}
pageHeader={{
title: tenant.name,
action: activeTab === "departments" ? (
<PrimaryButton
onClick={() => departmentsRef.current?.openNewModal()}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
<span>New Department</span>
</PrimaryButton>
) : undefined
}}
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Tenant Header Card */} {/* Tenant Header Card */}
@ -293,7 +310,7 @@ const TenantDetails = (): ReactElement => {
<RolesTable tenantId={id} compact={true} /> <RolesTable tenantId={id} compact={true} />
)} )}
{activeTab === "departments" && id && ( {activeTab === "departments" && id && (
<DepartmentsTable tenantId={id} compact={true} /> <DepartmentsTable ref={departmentsRef} tenantId={id} compact={true} />
)} )}
{activeTab === "designations" && id && ( {activeTab === "designations" && id && (
<DesignationsTable tenantId={id} compact={true} /> <DesignationsTable tenantId={id} compact={true} />

View File

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

View File

@ -1,17 +1,30 @@
import { type ReactElement } from 'react'; import { type ReactElement, useRef } from 'react';
import { Layout } from '@/components/layout/Layout'; import { Layout } from '@/components/layout/Layout';
import { DepartmentsTable } from '@/components/superadmin'; import { DepartmentsTable, type DepartmentsTableRef } from '@/components/superadmin';
import { PrimaryButton } from '@/components/shared';
import { Plus } from 'lucide-react';
const Departments = (): ReactElement => { const Departments = (): ReactElement => {
const tableRef = useRef<DepartmentsTableRef>(null);
return ( return (
<Layout <Layout
currentPage="Departments" currentPage="Departments"
pageHeader={{ pageHeader={{
title: 'Department Management', title: 'Department Management',
description: 'View and manage all departments within your organization.', description: 'View and manage all departments within your organization.',
action: (
<PrimaryButton
onClick={() => tableRef.current?.openNewModal()}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
<span>New Department</span>
</PrimaryButton>
)
}} }}
> >
<DepartmentsTable /> <DepartmentsTable ref={tableRef} />
</Layout> </Layout>
); );
}; };

View File

@ -11,6 +11,7 @@ import {
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
SearchBox,
type Column, type Column,
} from '@/components/shared'; } from '@/components/shared';
import { Plus, ArrowUpDown } from 'lucide-react'; import { Plus, ArrowUpDown } from 'lucide-react';
@ -19,6 +20,7 @@ import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
import { showToast } from '@/utils/toast'; import { showToast } from '@/utils/toast';
import { NewRoleModal } from '@/components/shared/NewRoleModal'; import { NewRoleModal } from '@/components/shared/NewRoleModal';
import { usePermissions } from '@/hooks/usePermissions'; import { usePermissions } from '@/hooks/usePermissions';
import CodeBadge from '@/components/shared/CodeBadge';
// Helper function to format date // Helper function to format date
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
@ -69,6 +71,10 @@ const Roles = (): ReactElement => {
// const [scopeFilter, setScopeFilter] = useState<string | null>(null); // const [scopeFilter, setScopeFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null); const [orderBy, setOrderBy] = useState<string[] | null>(null);
// Search state
const [search, setSearch] = useState<string>('');
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
// View, Edit, Delete modals // View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false); const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
@ -82,12 +88,13 @@ const Roles = (): ReactElement => {
page: number, page: number,
itemsPerPage: number, itemsPerPage: number,
// scope: string | null = null, // scope: string | null = null,
sortBy: string[] | null = null sortBy: string[] | null = null,
searchQuery: string | null = null
): Promise<void> => { ): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const response = await roleService.getAll(page, itemsPerPage, sortBy); const response = await roleService.getAll(page, itemsPerPage, sortBy, searchQuery);
if (response.success) { if (response.success) {
setRoles(response.data); setRoles(response.data);
setPagination(response.pagination); setPagination(response.pagination);
@ -101,9 +108,18 @@ const Roles = (): ReactElement => {
} }
}; };
// Handle search debouncing
useEffect(() => { useEffect(() => {
fetchRoles(currentPage, limit, orderBy); const timer = setTimeout(() => {
}, [currentPage, limit, orderBy]); setDebouncedSearch(search);
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
fetchRoles(currentPage, limit, orderBy, debouncedSearch);
}, [currentPage, limit, orderBy, debouncedSearch]);
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => { const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
try { try {
@ -199,7 +215,7 @@ const Roles = (): ReactElement => {
key: 'code', key: 'code',
label: 'Code', label: 'Code',
render: (role) => ( render: (role) => (
<span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span> <CodeBadge label={role.code} />
), ),
}, },
{ {
@ -209,6 +225,15 @@ const Roles = (): ReactElement => {
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge> <StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
), ),
}, },
{
key: 'user_count',
label: 'Users',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">
{role.user_count || 0}
</span>
),
},
{ {
key: 'description', key: 'description',
label: 'Description', label: 'Description',
@ -271,6 +296,10 @@ const Roles = (): ReactElement => {
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge> <StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
</div> </div>
</div> </div>
<div>
<span className="text-[#9aa6b2]">Users:</span>
<p className="text-[#0f1724] font-normal mt-1">{role.user_count || 0}</p>
</div>
<div> <div>
<span className="text-[#9aa6b2]">Created:</span> <span className="text-[#9aa6b2]">Created:</span>
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p> <p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
@ -299,6 +328,12 @@ const Roles = (): ReactElement => {
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name, code or description..."
/>
{/* Scope Filter */} {/* Scope Filter */}
{/* <FilterDropdown {/* <FilterDropdown
label="Scope" label="Scope"

View File

@ -12,7 +12,7 @@ const Suppliers = (): ReactElement => {
}} }}
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg bg-white overflow-hidden p-4 md:p-6"> <div className="border border-[rgba(0,0,0,0.08)] rounded-lg bg-white overflow-hidden p-2 md:p-6">
{/* <div className="flex flex-col gap-4"> */} {/* <div className="flex flex-col gap-4"> */}
{/* <div className="flex items-center justify-between"> {/* <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[#0f1724]"> <h2 className="text-lg font-semibold text-[#0f1724]">

View File

@ -12,11 +12,14 @@ import {
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
SearchBox,
type Column, type Column,
} from "@/components/shared"; } from "@/components/shared";
import { Plus, ArrowUpDown } from "lucide-react"; import { Plus, ArrowUpDown } from "lucide-react";
import { userService } from "@/services/user-service"; import { userService } from "@/services/user-service";
import { roleService } from "@/services/role-service";
import type { User } from "@/types/user"; import type { User } from "@/types/user";
import type { Role } from "@/types/role";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { usePermissions } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { useAppTheme } from "@/hooks/useAppTheme"; import { useAppTheme } from "@/hooks/useAppTheme";
@ -87,6 +90,14 @@ const Users = (): ReactElement => {
// Filter state // Filter state
const [statusFilter, setStatusFilter] = useState<string | null>(null); const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null); const [orderBy, setOrderBy] = useState<string[] | null>(null);
const [roleFilter, setRoleFilter] = useState<string | null>(null);
// Search state
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
// Roles list for filter
const [roles, setRoles] = useState<Role[]>([]);
// View, Edit, Delete modals // View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
@ -102,6 +113,8 @@ const Users = (): ReactElement => {
itemsPerPage: number, itemsPerPage: number,
status: string | null = null, status: string | null = null,
sortBy: string[] | null = null, sortBy: string[] | null = null,
searchQuery: string | null = null,
roleId: string | null = null,
): Promise<void> => { ): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -111,6 +124,8 @@ const Users = (): ReactElement => {
itemsPerPage, itemsPerPage,
status, status,
sortBy, sortBy,
searchQuery,
roleId,
); );
if (response.success) { if (response.success) {
setUsers(response.data); setUsers(response.data);
@ -125,9 +140,35 @@ const Users = (): ReactElement => {
} }
}; };
// Handle search debouncing
useEffect(() => { useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy); const timer = setTimeout(() => {
}, [currentPage, limit, statusFilter, orderBy]); setDebouncedSearch(search);
// We only reset to first page if we are actively searching.
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
// Fetch roles for filter
useEffect(() => {
const fetchRoles = async () => {
try {
const response = await roleService.getAll(1, 100);
if (response.success) {
setRoles(response.data);
}
} catch (err) {
console.error("Failed to fetch roles:", err);
}
};
fetchRoles();
}, []);
// Fetch users on mount and when pagination/filters change
useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter);
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter]);
const handleCreateUser = async (data: { const handleCreateUser = async (data: {
email: string; email: string;
@ -436,6 +477,13 @@ const Users = (): ReactElement => {
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or email..."
/>
{/* Status Filter */} {/* Status Filter */}
<FilterDropdown <FilterDropdown
label="Status" label="Status"
@ -457,6 +505,21 @@ const Users = (): ReactElement => {
placeholder="All" placeholder="All"
/> />
{/* Role Filter */}
<FilterDropdown
label="Role"
options={[
// { value: "", label: "All" },
...roles.map(role => ({ value: role.id, label: role.name }))
]}
value={roleFilter || ""}
onChange={(value) => {
setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="All"
/>
{/* Sort Filter */} {/* Sort Filter */}
<FilterDropdown <FilterDropdown
label="Sort by" label="Sort by"

View File

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

View File

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

View File

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

View File

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