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