feat: Introduce new modals for supplier contacts, scorecards, and workflow definition viewing, complementing an enhanced supplier data structure.
This commit is contained in:
parent
c9503c78be
commit
4e83f55800
@ -1,13 +1,15 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { MoreVertical, Eye, Edit, Trash2 } from 'lucide-react';
|
||||
import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ActionDropdownProps {
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onContacts?: () => void;
|
||||
onScorecards?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@ -15,6 +17,8 @@ export const ActionDropdown = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onContacts,
|
||||
onScorecards,
|
||||
className,
|
||||
}: ActionDropdownProps): ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
@ -149,6 +153,26 @@ export const ActionDropdown = ({
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
)}
|
||||
{onContacts && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(onContacts)}
|
||||
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Contacts</span>
|
||||
</button>
|
||||
)}
|
||||
{onScorecards && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(onScorecards)}
|
||||
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
<span>Scorecards</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
60
src/components/shared/FormTextArea.tsx
Normal file
60
src/components/shared/FormTextArea.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import type { ReactElement, TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FormTextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
export const FormTextArea = ({
|
||||
label,
|
||||
required = false,
|
||||
error,
|
||||
helperText,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}: FormTextAreaProps): ReactElement => {
|
||||
const fieldId = id || `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const hasError = Boolean(error);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424]">*</span>}
|
||||
</label>
|
||||
<textarea
|
||||
id={fieldId}
|
||||
className={cn(
|
||||
'w-full px-3.5 py-2 bg-white border rounded-md text-sm transition-colors min-h-[100px]',
|
||||
'placeholder:text-[#9aa6b2] text-[#0e1b2a]',
|
||||
hasError
|
||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
||||
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
props.disabled && 'bg-[#f3f4f6] cursor-not-allowed opacity-60',
|
||||
className
|
||||
)}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={`${fieldId}-helper`} className="text-sm text-[#6b7280]">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
257
src/components/shared/SupplierContactsModal.tsx
Normal file
257
src/components/shared/SupplierContactsModal.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
PrimaryButton,
|
||||
DataTable,
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { supplierService } from "@/services/supplier-service";
|
||||
import type { SupplierContact } from "@/types/supplier";
|
||||
import { showToast } from "@/utils/toast";
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
title: z.string().optional().or(z.literal("")),
|
||||
email: z.string().email("Invalid email").optional().or(z.literal("")),
|
||||
phone: z.string().optional().or(z.literal("")),
|
||||
mobile: z.string().optional().or(z.literal("")),
|
||||
is_primary: z.boolean(),
|
||||
});
|
||||
|
||||
type ContactFormData = z.infer<typeof contactSchema>;
|
||||
|
||||
interface SupplierContactsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
supplierId: string | null;
|
||||
supplierName?: string;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
|
||||
export const SupplierContactsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
supplierId,
|
||||
supplierName,
|
||||
tenantId,
|
||||
}: SupplierContactsModalProps): ReactElement => {
|
||||
const [contacts, setContacts] = useState<SupplierContact[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<ContactFormData>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
title: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
mobile: "",
|
||||
is_primary: false,
|
||||
},
|
||||
});
|
||||
|
||||
const fetchContacts = async () => {
|
||||
if (!supplierId || !isOpen) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await supplierService.getContacts(supplierId, tenantId);
|
||||
if (response.success) {
|
||||
setContacts(response.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast.error("Failed to load contacts", error?.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && supplierId) {
|
||||
fetchContacts();
|
||||
setShowAddForm(false);
|
||||
reset();
|
||||
}
|
||||
}, [isOpen, supplierId, tenantId]);
|
||||
|
||||
const onSubmit = async (data: ContactFormData) => {
|
||||
if (!supplierId) return;
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const response = await supplierService.addContact(supplierId, data, tenantId);
|
||||
if (response.success) {
|
||||
showToast.success("Contact added successfully");
|
||||
setShowAddForm(false);
|
||||
reset();
|
||||
fetchContacts();
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast.error("Failed to add contact", error?.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Column<SupplierContact>[] = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Name",
|
||||
render: (contact) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-[#0f1724]">{contact.name}</span>
|
||||
{contact.is_primary && (
|
||||
<span className="text-[10px] text-[#084cc8] bg-blue-50 px-1.5 py-0.5 rounded w-fit mt-1">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "title",
|
||||
label: "Title/Role",
|
||||
render: (contact) => <span className="text-[#6b7280]">{contact.title || "-"}</span>,
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "Email",
|
||||
render: (contact) => <span className="text-[#6b7280]">{contact.email || "-"}</span>,
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
label: "Phone/Mobile",
|
||||
render: (contact) => (
|
||||
<div className="flex flex-col text-[#6b7280]">
|
||||
<span>{contact.phone || "-"}</span>
|
||||
{contact.mobile && <span className="text-[10px]">{contact.mobile} (M)</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Contacts - ${supplierName || "Supplier"}`}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<div className="p-5 space-y-6">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-semibold text-[#112868]">
|
||||
{showAddForm ? "Add New Contact" : "Supplier Contacts"}
|
||||
</h3>
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
if (showAddForm) {
|
||||
setShowAddForm(false);
|
||||
reset();
|
||||
} else {
|
||||
setShowAddForm(true);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showAddForm ? (
|
||||
<>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">Cancel</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">Add Contact</span>
|
||||
</>
|
||||
)}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* Add Form */}
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="bg-gray-50 p-4 rounded-lg space-y-4 border border-gray-100">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Contact Name"
|
||||
placeholder="Full name"
|
||||
required
|
||||
{...register("name")}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Job Title / Role"
|
||||
placeholder="e.g. Sales Manager"
|
||||
{...register("title")}
|
||||
error={errors.title?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
{...register("email")}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
label="Phone"
|
||||
placeholder="Office phone"
|
||||
{...register("phone")}
|
||||
error={errors.phone?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Mobile"
|
||||
placeholder="Personal mobile"
|
||||
{...register("mobile")}
|
||||
error={errors.mobile?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_primary"
|
||||
className="w-4 h-4 text-[#112868] rounded border-gray-300"
|
||||
{...register("is_primary")}
|
||||
/>
|
||||
<label htmlFor="is_primary" className="text-sm text-gray-700">
|
||||
Set as primary contact
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Save Contact"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* List Table */}
|
||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden bg-white">
|
||||
<DataTable
|
||||
data={contacts}
|
||||
columns={columns}
|
||||
keyExtractor={(c) => c.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No contacts found for this supplier"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
@ -9,24 +9,50 @@ import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from "@/components/shared";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { supplierService } from "@/services/supplier-service";
|
||||
import type { CreateSupplierData } from "@/types/supplier";
|
||||
import { showToast } from "@/utils/toast";
|
||||
|
||||
const supplierSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
code: z.string().optional(),
|
||||
legal_name: z.string().optional(),
|
||||
supplier_code: z.string().optional(),
|
||||
supplier_type: z.string().min(1, "Supplier type is required"),
|
||||
category: z.string().min(1, "Category is required"),
|
||||
status: z.string().optional(),
|
||||
risk_level: z.string().optional(),
|
||||
website: z.string().url("Invalid URL").optional().or(z.literal("")),
|
||||
description: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
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(),
|
||||
|
||||
// Address
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
zip_code: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
|
||||
// Business IDs
|
||||
tax_id: z.string().optional(),
|
||||
duns_number: z.string().optional(),
|
||||
|
||||
// Quality Agreement
|
||||
quality_agreement_signed: z.boolean().optional(),
|
||||
quality_agreement_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(),
|
||||
|
||||
// Certifications
|
||||
certifications: z.array(z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
issuing_body: z.string().optional().or(z.literal("")),
|
||||
expiry_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(),
|
||||
document_url: z.string().url("Invalid URL").optional().or(z.literal("")),
|
||||
})).default([]),
|
||||
});
|
||||
|
||||
type SupplierFormData = z.infer<typeof supplierSchema>;
|
||||
@ -67,16 +93,39 @@ export const SupplierModal = ({
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<SupplierFormData>({
|
||||
resolver: zodResolver(supplierSchema),
|
||||
resolver: zodResolver(supplierSchema) as any,
|
||||
defaultValues: {
|
||||
name: "",
|
||||
legal_name: "",
|
||||
supplier_code: "",
|
||||
supplier_type: "",
|
||||
category: "",
|
||||
status: "pending",
|
||||
risk_level: "low",
|
||||
website: "",
|
||||
products_services_input: "",
|
||||
primary_contact_name: "",
|
||||
primary_contact_email: "",
|
||||
primary_contact_phone: "",
|
||||
address_line1: "",
|
||||
address_line2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
country: "",
|
||||
postal_code: "",
|
||||
tax_id: "",
|
||||
duns_number: "",
|
||||
quality_agreement_signed: false,
|
||||
quality_agreement_date: null,
|
||||
certifications: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "certifications",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
@ -100,14 +149,49 @@ export const SupplierModal = ({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const formatDateForInput = (val: any) => {
|
||||
if (!val) return "";
|
||||
const d = new Date(val);
|
||||
return isNaN(d.getTime()) ? "" : d.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSupplier = async () => {
|
||||
if (mode !== "create" && supplierId && isOpen) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await supplierService.getById(supplierId, tenantId);
|
||||
if (response.success) {
|
||||
reset(response.data as any);
|
||||
if (response.success && response.data) {
|
||||
const s = response.data as any;
|
||||
// Map nested backend structure to flat form structure
|
||||
reset({
|
||||
name: s.name || "",
|
||||
legal_name: s.legal_name || "",
|
||||
supplier_code: s.supplier_code || "",
|
||||
supplier_type: s.supplier_type || "",
|
||||
category: s.category || "",
|
||||
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(", ") : "",
|
||||
|
||||
primary_contact_name: s.contact?.name || "",
|
||||
primary_contact_email: s.contact?.email || "",
|
||||
primary_contact_phone: s.contact?.phone || "",
|
||||
|
||||
address_line1: s.address?.line1 || "",
|
||||
address_line2: s.address?.line2 || "",
|
||||
city: s.address?.city || "",
|
||||
state: s.address?.state || "",
|
||||
country: s.address?.country || "",
|
||||
postal_code: s.address?.postal_code || "",
|
||||
|
||||
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 : [],
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast.error("Failed to load supplier", error?.message);
|
||||
@ -118,18 +202,27 @@ export const SupplierModal = ({
|
||||
} else if (mode === "create" && isOpen) {
|
||||
reset({
|
||||
name: "",
|
||||
legal_name: "",
|
||||
supplier_code: "",
|
||||
supplier_type: "",
|
||||
category: "",
|
||||
status: "pending",
|
||||
risk_level: "low",
|
||||
code: "",
|
||||
website: "",
|
||||
description: "",
|
||||
address: "",
|
||||
products_services_input: "",
|
||||
primary_contact_name: "",
|
||||
primary_contact_email: "",
|
||||
primary_contact_phone: "",
|
||||
address_line1: "",
|
||||
address_line2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
country: "",
|
||||
zip_code: "",
|
||||
postal_code: "",
|
||||
tax_id: "",
|
||||
duns_number: "",
|
||||
quality_agreement_date: null,
|
||||
certifications: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -137,14 +230,26 @@ export const SupplierModal = ({
|
||||
fetchSupplier();
|
||||
}, [mode, supplierId, isOpen, reset, tenantId]);
|
||||
|
||||
const onSubmit = async (data: SupplierFormData) => {
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Map form fields to backend structure
|
||||
const payload: any = {
|
||||
...data,
|
||||
products_services: data.products_services_input
|
||||
? data.products_services_input.split(',').map((item: string) => item.trim()).filter(Boolean)
|
||||
: []
|
||||
};
|
||||
|
||||
// Remove the UI-specific field before sending
|
||||
delete payload.products_services_input;
|
||||
|
||||
if (mode === "create") {
|
||||
await supplierService.create(data as CreateSupplierData, tenantId);
|
||||
await supplierService.create(payload as CreateSupplierData, tenantId);
|
||||
showToast.success("Supplier created successfully");
|
||||
} else if (mode === "edit" && supplierId) {
|
||||
await supplierService.update(supplierId, data, tenantId);
|
||||
await supplierService.update(supplierId, payload, tenantId);
|
||||
showToast.success("Supplier updated successfully");
|
||||
}
|
||||
onSuccess();
|
||||
@ -165,7 +270,7 @@ export const SupplierModal = ({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={mode === "create" ? "Add New 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">
|
||||
@ -174,173 +279,348 @@ export const SupplierModal = ({
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
Cancel
|
||||
{mode === "view" ? "Close" : "Cancel"}
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
{mode === "create" ? "Create Supplier" : "Save Changes"}
|
||||
</PrimaryButton>
|
||||
{mode !== "view" && (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
{mode === "create" ? "Create Supplier" : "Save Changes"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 p-5">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-[#6b7280]">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Supplier Name"
|
||||
placeholder="Enter supplier name"
|
||||
required
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("name")}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Supplier Code"
|
||||
placeholder="e.g. SUP-001 (Optional)"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("code")}
|
||||
error={errors.code?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="supplier_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Supplier Type"
|
||||
required
|
||||
options={metadata.types.map((t) => ({
|
||||
value: t.code,
|
||||
label: t.name,
|
||||
}))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
error={errors.supplier_type?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Category (Criticaility)"
|
||||
required
|
||||
options={metadata.categories.map((c) => ({
|
||||
value: c.code,
|
||||
label: c.name,
|
||||
}))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
error={errors.category?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Status"
|
||||
options={metadata.statuses.map((s) => ({
|
||||
value: s.code,
|
||||
label: s.name,
|
||||
}))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="risk_level"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Risk Level"
|
||||
options={[
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
]}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
error={errors.risk_level?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Website"
|
||||
placeholder="https://example.com"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("website")}
|
||||
error={errors.website?.message}
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<FormField
|
||||
label="Description"
|
||||
placeholder="Brief description of products/services..."
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("description")}
|
||||
error={errors.description?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[rgba(0,0,0,0.08)] pt-4 mt-4">
|
||||
<h4 className="text-sm font-medium mb-4">Contact Information</h4>
|
||||
<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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<FormField
|
||||
label="Address"
|
||||
placeholder="Street address"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("address")}
|
||||
error={errors.address?.message}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="City"
|
||||
label="Supplier Name"
|
||||
placeholder="Enter supplier name"
|
||||
required
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("city")}
|
||||
{...register("name")}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="State/Province"
|
||||
label="Legal Name"
|
||||
placeholder="Official registered name"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("state")}
|
||||
{...register("legal_name")}
|
||||
error={errors.legal_name?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Country"
|
||||
label="Supplier Code"
|
||||
placeholder="e.g. SUP-001 (Optional)"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("country")}
|
||||
{...register("supplier_code")}
|
||||
error={errors.supplier_code?.message}
|
||||
/>
|
||||
<Controller
|
||||
name="supplier_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Supplier Type"
|
||||
required
|
||||
options={metadata.types.map((t) => ({
|
||||
value: t.code,
|
||||
label: t.name,
|
||||
}))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
error={errors.supplier_type?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Category (Criticaility)"
|
||||
required
|
||||
options={metadata.categories.map((c) => ({
|
||||
value: c.code,
|
||||
label: c.name,
|
||||
}))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
error={errors.category?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="risk_level"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Risk Level"
|
||||
options={[
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
]}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
error={errors.risk_level?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Status"
|
||||
options={metadata.statuses.map((s) => ({
|
||||
value: s.code,
|
||||
label: s.name,
|
||||
}))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
)}
|
||||
/> */}
|
||||
<FormField
|
||||
label="Tax ID"
|
||||
placeholder="GST/VAT/Tax Number"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("tax_id")}
|
||||
error={errors.tax_id?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Zip/Postal Code"
|
||||
label="DUNS Number"
|
||||
placeholder="9-digit DUNS number"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("zip_code")}
|
||||
{...register("duns_number")}
|
||||
error={errors.duns_number?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Website"
|
||||
placeholder="https://example.com"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("website")}
|
||||
error={errors.website?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Products & Services */}
|
||||
<div className="space-y-4">
|
||||
<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"
|
||||
placeholder="e.g. Raw Material, Testing, Logistics (Comma separated)"
|
||||
required
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("products_services_input")}
|
||||
error={errors.products_services_input?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Person */}
|
||||
<div className="space-y-4">
|
||||
<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"
|
||||
placeholder="Full name"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("primary_contact_name")}
|
||||
error={errors.primary_contact_name?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Contact Email"
|
||||
placeholder="email@example.com"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("primary_contact_email")}
|
||||
error={errors.primary_contact_email?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Contact Phone"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("primary_contact_phone")}
|
||||
error={errors.primary_contact_phone?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Information */}
|
||||
<div className="space-y-4">
|
||||
<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"
|
||||
placeholder="Street address, P.O. box"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("address_line1")}
|
||||
error={errors.address_line1?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Address Line 2"
|
||||
placeholder="Apartment, suite, unit, building, floor, etc."
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("address_line2")}
|
||||
error={errors.address_line2?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="City"
|
||||
placeholder="City"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("city")}
|
||||
error={errors.city?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="State/Province"
|
||||
placeholder="State/Province"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("state")}
|
||||
error={errors.state?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Country"
|
||||
placeholder="Country"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("country")}
|
||||
error={errors.country?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Postal Code"
|
||||
placeholder="ZIP/Postal Code"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register("postal_code")}
|
||||
error={errors.postal_code?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Agreement */}
|
||||
<div className="space-y-4">
|
||||
<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")}
|
||||
/>
|
||||
<label htmlFor="quality_agreement_signed" className="text-sm font-medium text-gray-700">
|
||||
Quality Agreement Signed
|
||||
</label>
|
||||
</div>
|
||||
<Controller
|
||||
name="quality_agreement_date"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
label="Agreement Date"
|
||||
type="date"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...field}
|
||||
value={formatDateForInput(field.value)}
|
||||
error={errors.quality_agreement_date?.message}
|
||||
onChange={(e) => field.onChange(e.target.value || null)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{mode !== "view" && (
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
Add Certification
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{fields.length === 0 ? (
|
||||
<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">
|
||||
{mode !== "view" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="absolute -top-2 -right-2 p-1.5 bg-white border border-red-100 rounded-full text-red-500 hover:bg-red-50 shadow-sm opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Certification Name"
|
||||
placeholder="e.g. ISO 9001:2015"
|
||||
required
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register(`certifications.${index}.name`)}
|
||||
error={(errors.certifications?.[index] as any)?.name?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="Issuing Body"
|
||||
placeholder="e.g. TÜV SÜD"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register(`certifications.${index}.issuing_body`)}
|
||||
/>
|
||||
<Controller
|
||||
name={`certifications.${index}.expiry_date`}
|
||||
control={control}
|
||||
render={({ field: dateField }) => (
|
||||
<FormField
|
||||
label="Expiry Date"
|
||||
type="date"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...dateField}
|
||||
value={formatDateForInput(dateField.value)}
|
||||
onChange={(e) => dateField.onChange(e.target.value || null)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
label="Document URL"
|
||||
placeholder="Link to certificate"
|
||||
disabled={mode === "view" || isSubmitting}
|
||||
{...register(`certifications.${index}.document_url`)}
|
||||
error={(errors.certifications?.[index] as any)?.document_url?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
376
src/components/shared/SupplierScorecardsModal.tsx
Normal file
376
src/components/shared/SupplierScorecardsModal.tsx
Normal file
@ -0,0 +1,376 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
PrimaryButton,
|
||||
DataTable,
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import { Plus, X, BarChart3, TrendingUp, AlertCircle } from "lucide-react";
|
||||
import { supplierService } from "@/services/supplier-service";
|
||||
import type { SupplierScorecard } from "@/types/supplier";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
|
||||
const scorecardSchema = z.object({
|
||||
period_start: z.string().min(1, "Period start is required"),
|
||||
period_end: z.string().min(1, "Period end is required"),
|
||||
|
||||
// Scores (0-100)
|
||||
quality_score: z.coerce.number().min(0).max(100),
|
||||
delivery_score: z.coerce.number().min(0).max(100),
|
||||
service_score: z.coerce.number().min(0).max(100),
|
||||
cost_score: z.coerce.number().min(0).max(100),
|
||||
|
||||
// Specific metrics (0-100 recommended)
|
||||
defect_rate: z.coerce.number().min(0).optional().default(0),
|
||||
lot_acceptance_rate: z.coerce.number().min(0).optional().default(0),
|
||||
complaints_count: z.coerce.number().min(0).optional().default(0),
|
||||
on_time_delivery_rate: z.coerce.number().min(0).optional().default(0),
|
||||
lead_time_adherence: z.coerce.number().min(0).optional().default(0),
|
||||
responsiveness_score: z.coerce.number().min(0).optional().default(0),
|
||||
documentation_score: z.coerce.number().min(0).optional().default(0),
|
||||
price_competitiveness: z.coerce.number().min(0).optional().default(0),
|
||||
|
||||
notes: z.string().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
type ScorecardFormData = z.infer<typeof scorecardSchema>;
|
||||
|
||||
interface SupplierScorecardsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
supplierId: string | null;
|
||||
supplierName?: string;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
|
||||
export const SupplierScorecardsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
supplierId,
|
||||
supplierName,
|
||||
tenantId,
|
||||
}: SupplierScorecardsModalProps): ReactElement => {
|
||||
const [scorecards, setScorecards] = useState<SupplierScorecard[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<ScorecardFormData>({
|
||||
resolver: zodResolver(scorecardSchema) as any,
|
||||
defaultValues: {
|
||||
period_start: "",
|
||||
period_end: "",
|
||||
quality_score: 0,
|
||||
delivery_score: 0,
|
||||
service_score: 0,
|
||||
cost_score: 0,
|
||||
defect_rate: 0,
|
||||
lot_acceptance_rate: 100,
|
||||
complaints_count: 0,
|
||||
on_time_delivery_rate: 100,
|
||||
lead_time_adherence: 100,
|
||||
responsiveness_score: 100,
|
||||
documentation_score: 100,
|
||||
price_competitiveness: 100,
|
||||
notes: "",
|
||||
},
|
||||
});
|
||||
|
||||
const fetchScorecards = async () => {
|
||||
if (!supplierId || !isOpen) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await supplierService.getScorecards(supplierId, tenantId);
|
||||
if (response.success) {
|
||||
setScorecards(response.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast.error("Failed to load scorecards", error?.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && supplierId) {
|
||||
fetchScorecards();
|
||||
setShowAddForm(false);
|
||||
reset();
|
||||
}
|
||||
}, [isOpen, supplierId, tenantId]);
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
if (!supplierId) return;
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const response = await supplierService.createScorecard(supplierId, data, tenantId);
|
||||
if (response.success) {
|
||||
showToast.success("Scorecard created successfully");
|
||||
setShowAddForm(false);
|
||||
reset();
|
||||
fetchScorecards();
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast.error("Failed to create scorecard", error?.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getGradeColor = (grade: string) => {
|
||||
switch (grade) {
|
||||
case 'A': return 'text-emerald-600 bg-emerald-50 border-emerald-100';
|
||||
case 'B': return 'text-blue-600 bg-blue-50 border-blue-100';
|
||||
case 'C': return 'text-amber-600 bg-amber-50 border-amber-100';
|
||||
case 'D': return 'text-orange-600 bg-orange-50 border-orange-100';
|
||||
case 'F': return 'text-red-600 bg-red-50 border-red-100';
|
||||
default: return 'text-gray-600 bg-gray-50 border-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Column<SupplierScorecard>[] = [
|
||||
{
|
||||
key: "period",
|
||||
label: "Evaluation Period",
|
||||
render: (sc) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-[#112868]">
|
||||
{formatDate(sc.period?.start)} - {formatDate(sc.period?.end)}
|
||||
</span>
|
||||
{/* <span className="text-[10px] text-gray-400">Created on {formatDate(sc.created_at)}</span> */}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "overall_score",
|
||||
label: "Performance",
|
||||
render: (sc) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-center justify-center min-w-[40px] h-10 border rounded-lg bg-white shadow-sm font-black">
|
||||
<span className="text-[10px] text-gray-400 uppercase leading-none mb-1">Score</span>
|
||||
<span className="text-sm text-[#112868]">{Math.round(sc.overall?.score)}</span>
|
||||
</div>
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full border-2 font-black text-sm shadow-sm ${getGradeColor(sc.overall?.grade)}`}>
|
||||
{sc.overall?.grade}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "metrics",
|
||||
label: "Key Metrics",
|
||||
render: (sc) => (
|
||||
<div className="flex gap-4 text-[10px] uppercase font-bold tracking-tight">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-400">Quality</span>
|
||||
<span className="text-slate-700">{sc.quality_score}%</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-400">Delivery</span>
|
||||
<span className="text-slate-700">{sc.delivery_score}%</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-400">Service</span>
|
||||
<span className="text-slate-700">{sc.service_score}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Supplier Scorecards - ${supplierName || "Supplier"}`}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
<div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-indigo-600" />
|
||||
<h3 className="text-[15px] font-black text-[#112868] uppercase tracking-wide">
|
||||
{showAddForm ? "Generate New Evaluation" : "Performance History"}
|
||||
</h3>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
if (showAddForm) {
|
||||
setShowAddForm(false);
|
||||
reset();
|
||||
} else {
|
||||
setShowAddForm(true);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 h-9"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showAddForm ? (
|
||||
<>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-bold">Exit Evaluation</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">New Scorecard</span>
|
||||
</>
|
||||
)}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* Add Form */}
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="bg-white p-6 rounded-2xl space-y-8 border-2 border-indigo-50 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-2 h-full bg-indigo-500"></div>
|
||||
|
||||
{/* Period Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-indigo-700">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span className="text-xs font-black uppercase">Evaluation Period</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
label="Start Date"
|
||||
type="date"
|
||||
required
|
||||
{...register("period_start")}
|
||||
error={errors.period_start?.message}
|
||||
/>
|
||||
<FormField
|
||||
label="End Date"
|
||||
type="date"
|
||||
required
|
||||
{...register("period_end")}
|
||||
error={errors.period_end?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Quality Section */}
|
||||
<div className="p-4 bg-slate-50/50 rounded-xl border border-slate-100 space-y-4">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block border-b pb-2">Quality Assurance (40%)</span>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
label="Overall Quality Score (0-100)"
|
||||
type="number"
|
||||
required
|
||||
{...register("quality_score")}
|
||||
error={errors.quality_score?.message}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Defect Rate %" type="number" {...register("defect_rate")} />
|
||||
<FormField label="Acceptance Rate %" type="number" {...register("lot_acceptance_rate")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Section */}
|
||||
<div className="p-4 bg-slate-50/50 rounded-xl border border-slate-100 space-y-4">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block border-b pb-2">Logistics & Delivery (30%)</span>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
label="Overall Delivery Score (0-100)"
|
||||
type="number"
|
||||
required
|
||||
{...register("delivery_score")}
|
||||
error={errors.delivery_score?.message}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="On-Time Rate %" type="number" {...register("on_time_delivery_rate")} />
|
||||
<FormField label="Lead Time Adherence %" type="number" {...register("lead_time_adherence")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service & Support */}
|
||||
<div className="p-4 bg-slate-50/50 rounded-xl border border-slate-100 space-y-4">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block border-b pb-2">Service & Support (20%)</span>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
label="Overall Service Score (0-100)"
|
||||
type="number"
|
||||
required
|
||||
{...register("service_score")}
|
||||
error={errors.service_score?.message}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Responsiveness %" type="number" {...register("responsiveness_score")} />
|
||||
<FormField label="Documentation %" type="number" {...register("documentation_score")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Competitiveness */}
|
||||
<div className="p-4 bg-slate-50/50 rounded-xl border border-slate-100 space-y-4">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block border-b pb-2">Cost & Commercial (10%)</span>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
label="Overall Cost Score (0-100)"
|
||||
type="number"
|
||||
required
|
||||
{...register("cost_score")}
|
||||
error={errors.cost_score?.message}
|
||||
/>
|
||||
<FormField label="Price Competitiveness %" type="number" {...register("price_competitiveness")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Evaluation Notes"
|
||||
placeholder="Provide context for this scorecard..."
|
||||
{...register("notes")}
|
||||
error={errors.notes?.message}
|
||||
/>
|
||||
|
||||
<div className="pt-4 border-t flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||
<p className="text-[11px] text-amber-800 leading-relaxed font-medium">
|
||||
The overall performance score and grade will be automatically calculated based on the weighted average of the Quality, Delivery, Service, and Cost scores.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="min-w-[150px] font-black uppercase h-11"
|
||||
>
|
||||
{isSubmitting ? "Calculating..." : "Submit Evaluation"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* List Table */}
|
||||
<div className="bg-white rounded-2xl border-2 border-slate-50 shadow-sm overflow-hidden">
|
||||
<DataTable
|
||||
data={scorecards}
|
||||
columns={columns}
|
||||
keyExtractor={(sc) => sc.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No performance evaluations recorded yet."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@ -9,6 +9,8 @@ import {
|
||||
type Column,
|
||||
SupplierModal,
|
||||
ViewSupplierModal,
|
||||
SupplierContactsModal,
|
||||
SupplierScorecardsModal,
|
||||
} from "@/components/shared";
|
||||
import { Plus, Building2 } from "lucide-react";
|
||||
import { supplierService } from "@/services/supplier-service";
|
||||
@ -74,7 +76,10 @@ export const SuppliersTable = ({
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedSupplierName, setSelectedSupplierName] = useState<string>("");
|
||||
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||||
const [contactsModalOpen, setContactsModalOpen] = useState(false);
|
||||
const [scorecardsModalOpen, setScorecardsModalOpen] = useState(false);
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
@ -124,6 +129,18 @@ export const SuppliersTable = ({
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleContacts = (id: string, name: string) => {
|
||||
setSelectedSupplierId(id);
|
||||
setSelectedSupplierName(name);
|
||||
setContactsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleScorecards = (id: string, name: string) => {
|
||||
setSelectedSupplierId(id);
|
||||
setSelectedSupplierName(name);
|
||||
setScorecardsModalOpen(true);
|
||||
};
|
||||
|
||||
const columns: Column<Supplier>[] = [
|
||||
{
|
||||
key: "name",
|
||||
@ -137,9 +154,9 @@ export const SuppliersTable = ({
|
||||
<span className="text-sm font-medium text-[#0f1724]">
|
||||
{supplier.name}
|
||||
</span>
|
||||
{supplier.code && (
|
||||
{supplier.supplier_code && (
|
||||
<span className="text-[10px] text-[#6b7280] font-mono">
|
||||
{supplier.code}
|
||||
{supplier.supplier_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -180,9 +197,9 @@ export const SuppliersTable = ({
|
||||
label: "Location",
|
||||
render: (supplier) => (
|
||||
<span className="text-sm text-[#6b7280]">
|
||||
{supplier.city
|
||||
? `${supplier.city}, ${supplier.country}`
|
||||
: supplier.country || "-"}
|
||||
{supplier.address?.city
|
||||
? `${supplier.address.city}, ${supplier.address.country}`
|
||||
: supplier.address?.country || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@ -204,6 +221,8 @@ export const SuppliersTable = ({
|
||||
<ActionDropdown
|
||||
onView={() => handleView(supplier.id)}
|
||||
onEdit={() => handleEdit(supplier.id)}
|
||||
// onContacts={() => handleContacts(supplier.id, supplier.name)}
|
||||
// onScorecards={() => handleScorecards(supplier.id, supplier.name)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@ -227,6 +246,8 @@ export const SuppliersTable = ({
|
||||
<ActionDropdown
|
||||
onView={() => handleView(supplier.id)}
|
||||
onEdit={() => handleEdit(supplier.id)}
|
||||
onContacts={() => handleContacts(supplier.id, supplier.name)}
|
||||
onScorecards={() => handleScorecards(supplier.id, supplier.name)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
@ -325,6 +346,22 @@ export const SuppliersTable = ({
|
||||
supplierId={selectedSupplierId}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
|
||||
<SupplierContactsModal
|
||||
isOpen={contactsModalOpen}
|
||||
onClose={() => setContactsModalOpen(false)}
|
||||
supplierId={selectedSupplierId}
|
||||
supplierName={selectedSupplierName}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
|
||||
<SupplierScorecardsModal
|
||||
isOpen={scorecardsModalOpen}
|
||||
onClose={() => setScorecardsModalOpen(false)}
|
||||
supplierId={selectedSupplierId}
|
||||
supplierName={selectedSupplierName}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -116,7 +116,7 @@ export const ViewSupplierModal = ({
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | ReactElement | null;
|
||||
value: React.ReactNode | string | null;
|
||||
icon?: any;
|
||||
}) => (
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-xl hover:bg-gray-50 transition-all duration-300">
|
||||
@ -134,12 +134,23 @@ export const ViewSupplierModal = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ icon: Icon, title, colorClass }: { icon: any, title: string, colorClass: string }) => (
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`w-8 h-8 rounded-lg ${colorClass} flex items-center justify-center`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<h4 className="text-xs font-black text-[#1e293b] uppercase tracking-[0.2em]">
|
||||
{title}
|
||||
</h4>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Supplier Profile"
|
||||
maxWidth="lg"
|
||||
maxWidth="2xl"
|
||||
footer={
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
@ -150,7 +161,7 @@ export const ViewSupplierModal = ({
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className="p-0 overflow-hidden">
|
||||
<div className="p-0">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<Loader2 className="w-10 h-10 text-[#112868] animate-spin opacity-25" />
|
||||
@ -180,19 +191,12 @@ export const ViewSupplierModal = ({
|
||||
|
||||
{!isLoading && !error && supplier && (
|
||||
<div className="flex flex-col animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* Legend Header */}
|
||||
<div className="relative px-8 py-12 bg-gradient-to-br from-[#112868] via-[#1a365d] to-[#1e3a8a] text-white">
|
||||
{/* Mesh Gradient Overlay */}
|
||||
{/* Header Section */}
|
||||
<div className="relative px-8 py-10 bg-gradient-to-br from-[#112868] via-[#1a365d] to-[#1e3a8a] text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(35,220,225,0.15),transparent)] pointer-events-none" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-px bg-white/10" />
|
||||
|
||||
<div className="relative flex flex-col md:flex-row items-center md:items-start gap-8">
|
||||
{/* Visual Identity */}
|
||||
<div className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-[#23dce1] to-[#112868] rounded-2xl blur opacity-25 group-hover:opacity-40 transition duration-1000 group-hover:duration-200" />
|
||||
<div className="relative w-24 h-24 rounded-2xl bg-white/10 backdrop-blur-xl border border-white/20 flex items-center justify-center shadow-2xl shrink-0 ring-8 ring-white/5">
|
||||
<Building2 className="w-12 h-12 text-[#23dce1]" />
|
||||
</div>
|
||||
<div className="relative w-24 h-24 rounded-2xl bg-white/10 backdrop-blur-xl border border-white/20 flex items-center justify-center shadow-2xl shrink-0 ring-8 ring-white/5">
|
||||
<Building2 className="w-12 h-12 text-[#23dce1]" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 text-center md:text-left">
|
||||
@ -203,188 +207,200 @@ export const ViewSupplierModal = ({
|
||||
</h2>
|
||||
{getRiskBadge(supplier.risk_level)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center md:justify-start gap-2">
|
||||
<span className="px-2 py-0.5 bg-white/10 rounded font-mono text-xs text-[#23dce1] border border-white/10 lowercase tracking-widest">
|
||||
ref: {supplier.code || "unassigned"}
|
||||
</span>
|
||||
</div>
|
||||
{supplier.legal_name && (
|
||||
<p className="text-sm text-white/60 italic">{supplier.legal_name}</p>
|
||||
)}
|
||||
<span className="inline-block px-2 py-0.5 bg-white/10 rounded font-mono text-[10px] text-[#23dce1] border border-white/10 tracking-widest mt-2">
|
||||
REF: {supplier.supplier_code || "UNASSIGNED"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-5">
|
||||
<div className="flex items-center gap-2 text-white/80">
|
||||
<Tag className="w-4 h-4 text-[#23dce1]" />
|
||||
<span className="text-xs font-black uppercase tracking-widest whitespace-nowrap">
|
||||
<span className="text-xs font-black uppercase tracking-widest">
|
||||
{supplier.supplier_type.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-1 rounded-full bg-white/20 hidden md:block" />
|
||||
<div className="flex items-center gap-2 text-white/80">
|
||||
<Globe className="w-4 h-4 text-[#23dce1]" />
|
||||
<span className="text-xs font-black uppercase tracking-widest whitespace-nowrap">
|
||||
{supplier.category} level
|
||||
<span className="text-xs font-black uppercase tracking-widest">
|
||||
{supplier.category} UNIT
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 pt-2">
|
||||
<div className="shrink-0">
|
||||
<StatusBadge
|
||||
variant={getStatusVariant(supplier.status)}
|
||||
className="shadow-2xl !px-6 !py-2 !text-[12px] font-black border border-white/10"
|
||||
className=" shadow-xl !px-6 !py-2 !text-[12px] font-black"
|
||||
>
|
||||
{supplier.status}
|
||||
</StatusBadge>
|
||||
{supplier.score?.current && (
|
||||
<div className="mt-4 flex flex-col items-center md:items-end">
|
||||
<span className="text-[10px] font-bold text-white/40 uppercase tracking-widest">Compliance Score</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-black text-[#23dce1]">{supplier.score.current}%</span>
|
||||
{supplier.score.grade && (
|
||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded font-bold text-white">{supplier.score.grade} Grade</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Registry Layout */}
|
||||
<div className="p-8 space-y-10 bg-white">
|
||||
{/* Detailed Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
|
||||
{/* Information Cluster */}
|
||||
{/* Detailed Info Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Column 1: General & Legal */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center text-[#112868]">
|
||||
<Tag className="w-4 h-4" />
|
||||
</div>
|
||||
<h4 className="text-xs font-black text-[#1e293b] uppercase tracking-[0.2em]">
|
||||
Profile Data
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<SectionHeader icon={ShieldCheck} title="Registry Info" colorClass="bg-blue-50 text-blue-600" />
|
||||
<div className="space-y-1">
|
||||
<InfoRow label="Legal Entity" value={supplier.legal_name} />
|
||||
<InfoRow label="Tax ID / VAT" value={supplier.tax_id} />
|
||||
<InfoRow label="DUNS Number" value={supplier.duns_number} />
|
||||
<InfoRow
|
||||
label="Criticality Rating"
|
||||
value={supplier.category}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Digital Presence"
|
||||
icon={Globe}
|
||||
value={
|
||||
supplier.website ? (
|
||||
<a
|
||||
href={supplier.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#112868] hover:text-[#23dce1] hover:underline flex items-center gap-2 transition-all font-bold group"
|
||||
>
|
||||
<span>
|
||||
{supplier.website.replace(/^https?:\/\//, "")}
|
||||
</span>
|
||||
<Globe className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
) : null
|
||||
}
|
||||
label="Website"
|
||||
value={supplier.website ? (
|
||||
<a href={supplier.website.startsWith('http') ? supplier.website : `https://${supplier.website}`} target="_blank" className="text-blue-600 hover:underline">{supplier.website}</a>
|
||||
) : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logistics Cluster */}
|
||||
{/* Column 2: Location & Address */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-cyan-50 flex items-center justify-center text-[#23dce1]">
|
||||
<MapPin className="w-4 h-4" />
|
||||
</div>
|
||||
<h4 className="text-xs font-black text-[#1e293b] uppercase tracking-[0.2em]">
|
||||
Regional presence
|
||||
</h4>
|
||||
<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 ? (
|
||||
<>
|
||||
<p className="text-sm font-bold text-slate-700">{supplier.address.line1}</p>
|
||||
{supplier.address.line2 && <p className="text-sm text-slate-600">{supplier.address.line2}</p>}
|
||||
<p className="text-xs font-semibold text-slate-500">
|
||||
{[supplier.address.city, supplier.address.state, supplier.address.postal_code].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
<p className="text-[11px] font-black text-slate-800 uppercase tracking-wider pt-2 border-t mt-2">
|
||||
{supplier.address.country}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400 italic">No address recorded</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 bg-gray-50/50 rounded-2xl border border-gray-100 flex items-start gap-4 hover:bg-gray-50 transition-colors duration-300">
|
||||
<div className="w-10 h-10 rounded-xl bg-white shadow-sm flex items-center justify-center text-[#94a3b8] shrink-0">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<div className="pt-2">
|
||||
<SectionHeader icon={Clock} title="Lifecycle" colorClass="bg-amber-50 text-amber-600" />
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
<InfoRow label="Ingested On" value={formatDate(supplier.created_at)} />
|
||||
<InfoRow label="Created By" value={supplier.created_by} />
|
||||
{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} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(() => {
|
||||
const addr: any = supplier.address;
|
||||
const isObj = typeof addr === "object" && addr !== null;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const street = isObj
|
||||
? [addr.line1, addr.line2].filter(Boolean).join(", ")
|
||||
: (addr as string);
|
||||
{/* Column 3: Audit & Compliance */}
|
||||
<div className="space-y-6">
|
||||
<SectionHeader icon={ShieldCheck} title="Compliance" colorClass="bg-emerald-50 text-emerald-600" />
|
||||
<div className="bg-emerald-50/30 p-4 rounded-xl border border-emerald-100/50 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-emerald-700 uppercase tracking-widest">Quality Agreement</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-black ${supplier.quality_agreement?.signed ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{supplier.quality_agreement?.signed ? 'SIGNED' : 'UNSIGNED'}
|
||||
</span>
|
||||
</div>
|
||||
{supplier.quality_agreement?.date && (
|
||||
<div className="text-[11px] text-emerald-800">
|
||||
Effective since: <b>{new Date(supplier.quality_agreement.date).toLocaleDateString()}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
const city = isObj ? addr.city : supplier.city;
|
||||
const state = isObj ? addr.state : supplier.state;
|
||||
const country = isObj ? addr.country : supplier.country;
|
||||
const zip = isObj
|
||||
? addr.postal_code
|
||||
: supplier.zip_code;
|
||||
{/* Products & Services Section */}
|
||||
{supplier.products_services && supplier.products_services.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader icon={Tag} title="Deliverables" colorClass="bg-indigo-50 text-indigo-600" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{supplier.products_services.map((item, idx) => (
|
||||
<span key={idx} className="px-3 py-1 bg-indigo-50 text-indigo-700 text-xs font-bold rounded-lg border border-indigo-100">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm font-black text-[#334155] leading-tight">
|
||||
{street || "Street address not provided"}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-[#64748b]">
|
||||
{city && `${city}, `}
|
||||
{state && `${state} `}
|
||||
{zip && `[${zip}]`}
|
||||
</p>
|
||||
{country && (
|
||||
<span className="mt-1 text-[10px] font-black text-[#475569] uppercase tracking-tighter opacity-60">
|
||||
Primary HQ: {country}
|
||||
</span>
|
||||
{/* Certifications Section */}
|
||||
{supplier.certifications && supplier.certifications.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader icon={ShieldCheck} title="Certifications" colorClass="bg-cyan-50 text-cyan-600" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{supplier.certifications.map((cert, idx) => (
|
||||
<div key={idx} className="p-4 rounded-xl border border-gray-100 bg-gray-50 flex flex-col gap-1">
|
||||
<span className="text-sm font-black text-slate-800 uppercase leading-tight">{cert.name}</span>
|
||||
{cert.issuing_body && <span className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">{cert.issuing_body}</span>}
|
||||
{cert.expiry_date && (
|
||||
<span className={`text-[10px] font-bold mt-2 ${new Date(cert.expiry_date) < new Date() ? 'text-red-500' : 'text-emerald-600'}`}>
|
||||
Expires: {new Date(cert.expiry_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extended Contacts Section */}
|
||||
{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">
|
||||
{supplier.contacts.map((contact) => (
|
||||
<div key={contact.id} className="group p-4 rounded-2xl border border-gray-100 hover:border-slate-200 transition-all flex items-start gap-4 hover:shadow-sm">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-50 flex items-center justify-center text-slate-400 group-hover:bg-[#112868] group-hover:text-white transition-all">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-slate-800">{contact.name}</span>
|
||||
{contact.is_primary && (
|
||||
<span className="text-[9px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded-full font-black">PRIMARY</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-tighter">{contact.title || 'Contact'}</p>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{contact.email && <div className="text-[11px] text-slate-600 truncate">{contact.email}</div>}
|
||||
{contact.phone && <div className="text-[11px] text-slate-500">{contact.phone} (O)</div>}
|
||||
{contact.mobile && <div className="text-[11px] text-slate-400">{contact.mobile} (M)</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Narrative Section */}
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-50 flex items-center justify-center text-[#94a3b8]">
|
||||
<Building2 className="w-4 h-4" />
|
||||
</div>
|
||||
<h4 className="text-xs font-black text-[#1e293b] uppercase tracking-[0.2em]">
|
||||
Operational Overview
|
||||
</h4>
|
||||
</div>
|
||||
<div className="relative group p-8 bg-[#fdfdfd] rounded-3xl border border-gray-100/80 shadow-[0_8px_30px_rgb(0,0,0,0.02)]">
|
||||
<div className="absolute top-0 left-0 p-4 -mt-3 -ml-3 text-4xl text-gray-100 font-serif select-none pointer-events-none">
|
||||
"
|
||||
</div>
|
||||
<p className="relative text-[15px] font-medium text-[#475569] leading-relaxed italic pr-4">
|
||||
{supplier.description ||
|
||||
"Detailed operational description is currently unavailable for this record. Please contact the administrator for further details regarding this supplier's service scope."}
|
||||
{/* Operational Narrative */}
|
||||
{/* <div className="space-y-4">
|
||||
<SectionHeader icon={Building2} title="Operational Scope" colorClass="bg-slate-50 text-slate-500" />
|
||||
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100 shadow-inner">
|
||||
<p className="text-[14px] font-medium text-slate-600 italic leading-relaxed">
|
||||
{supplier.description || "No operational description available for this profile."}
|
||||
</p>
|
||||
<div className="absolute bottom-0 right-0 p-4 -mb-3 -mr-3 text-4xl text-gray-100 font-serif rotate-180 select-none pointer-events-none">
|
||||
"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security & Lifecycle */}
|
||||
<div className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="flex items-center gap-4 p-5 rounded-2xl bg-white border border-gray-100 shadow-sm group hover:border-[#112868]/20 transition-all duration-300">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#112868]/5 flex items-center justify-center text-[#112868] group-hover:bg-[#112868] group-hover:text-white transition-all">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black text-[#94a3b8] uppercase tracking-widest">
|
||||
System Ingestion
|
||||
</span>
|
||||
<span className="text-sm font-black text-[#1e293b] tracking-tight">
|
||||
{formatDate(supplier.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-5 rounded-2xl bg-white border border-gray-100 shadow-sm group hover:border-[#23dce1]/20 transition-all duration-300">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#23dce1]/5 flex items-center justify-center text-[#23dce1] group-hover:bg-[#23dce1] group-hover:text-white transition-all">
|
||||
<ShieldCheck className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black text-[#94a3b8] uppercase tracking-widest">
|
||||
Last Compliance Check
|
||||
</span>
|
||||
<span className="text-sm font-black text-[#1e293b] tracking-tight">
|
||||
{formatDate(supplier.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -393,6 +393,14 @@ export const WorkflowDefinitionModal = ({
|
||||
...data,
|
||||
source_module: selectedModuleNames,
|
||||
tenantId: tenantId || undefined,
|
||||
// Strip terminal-step-only-hidden fields from the payload
|
||||
steps: data.steps?.map((step: any) => {
|
||||
if (step.step_type === "terminal") {
|
||||
const { assignee_type, assignee_role, assignee_id, available_actions, ...rest } = step;
|
||||
return rest;
|
||||
}
|
||||
return step;
|
||||
}),
|
||||
};
|
||||
|
||||
// In edit mode, if steps or transitions are not edited (hidden in UI)
|
||||
@ -756,7 +764,16 @@ export const WorkflowDefinitionModal = ({
|
||||
{ value: "terminal", label: "Terminal" },
|
||||
]}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
// When switching TO terminal, clear all hidden fields
|
||||
if (val === "terminal") {
|
||||
setValue(`steps.${index}.assignee_type`, undefined);
|
||||
setValue(`steps.${index}.assignee_role`, []);
|
||||
setValue(`steps.${index}.assignee_id`, "");
|
||||
setValue(`steps.${index}.available_actions`, []);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
(errors.steps as any)?.[index]?.step_type?.message
|
||||
}
|
||||
|
||||
352
src/components/shared/WorkflowDefinitionViewModal.tsx
Normal file
352
src/components/shared/WorkflowDefinitionViewModal.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Modal, StatusBadge } from "@/components/shared";
|
||||
import { workflowService } from "@/services/workflow-service";
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
User,
|
||||
Users,
|
||||
Fingerprint,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
|
||||
interface WorkflowDefinitionViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
definitionId: string | null;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
const STEP_TYPE_COLORS: Record<string, string> = {
|
||||
initial: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
task: "bg-purple-100 text-purple-700 border-purple-200",
|
||||
approval: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
terminal: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
};
|
||||
|
||||
const STEP_CONNECTOR_COLORS: Record<string, string> = {
|
||||
initial: "bg-blue-400",
|
||||
task: "bg-purple-400",
|
||||
approval: "bg-amber-400",
|
||||
terminal: "bg-slate-300",
|
||||
};
|
||||
|
||||
const AssigneeIcon = ({ type }: { type: string | null }) => {
|
||||
if (type === "role") return <Users className="w-3.5 h-3.5" />;
|
||||
if (type === "user") return <User className="w-3.5 h-3.5" />;
|
||||
if (type === "originator") return <Fingerprint className="w-3.5 h-3.5" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const WorkflowDefinitionViewModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
definitionId,
|
||||
tenantId,
|
||||
}: WorkflowDefinitionViewModalProps): ReactElement | null => {
|
||||
const [definition, setDefinition] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && definitionId) {
|
||||
const load = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setDefinition(null);
|
||||
const res = await workflowService.getDefinition(definitionId, tenantId);
|
||||
if (res.success) {
|
||||
setDefinition(res.data);
|
||||
} else {
|
||||
setError("Failed to load workflow definition");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.response?.data?.error?.message || "Failed to load workflow definition"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}
|
||||
if (!isOpen) {
|
||||
setDefinition(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, definitionId, tenantId]);
|
||||
|
||||
const getStatusVariant = (status: string): "success" | "failure" | "info" | "process" => {
|
||||
if (status === "active") return "success";
|
||||
if (status === "deprecated") return "failure";
|
||||
if (status === "draft") return "process";
|
||||
return "info";
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Workflow Definition"
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="p-6 min-h-[400px]">
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-3">
|
||||
<Loader2 className="w-8 h-8 text-[#112868] animate-spin" />
|
||||
<p className="text-sm text-[#64748b]">Loading workflow details...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{definition && !isLoading && (
|
||||
<div className="space-y-6">
|
||||
{/* ── Header Info ─────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 p-4 bg-[#f8fafc] rounded-xl border border-[rgba(0,0,0,0.06)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Name</p>
|
||||
<p className="text-sm font-semibold text-[#0f1724]">{definition.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Code</p>
|
||||
<p className="text-sm font-mono text-[#475569]">{definition.code}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Version</p>
|
||||
<p className="text-sm text-[#475569]">v{definition.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Entity Type</p>
|
||||
<p className="text-sm text-[#475569] capitalize">{definition.entity_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Status</p>
|
||||
<StatusBadge variant={getStatusVariant(definition.status)}>
|
||||
{definition.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Module(s)</p>
|
||||
<p className="text-sm text-[#475569]">
|
||||
{definition.source_module?.length
|
||||
? definition.source_module.join(", ")
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
{definition.description && (
|
||||
<div className="col-span-2 md:col-span-3">
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Description</p>
|
||||
<p className="text-sm text-[#475569]">{definition.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Created By</p>
|
||||
<p className="text-sm text-[#475569]">{definition.created_by || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-[#94a3b8] uppercase tracking-wide mb-1">Created At</p>
|
||||
<p className="text-sm text-[#475569]">{formatDate(definition.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Steps ───────────────────────────────────── */}
|
||||
{definition.steps?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-3 flex items-center gap-2">
|
||||
<span className="w-5 h-5 rounded-full bg-[#112868] text-white text-[10px] flex items-center justify-center font-bold">
|
||||
{definition.steps.length}
|
||||
</span>
|
||||
Workflow Steps
|
||||
</h3>
|
||||
<div className="space-y-0">
|
||||
{definition.steps
|
||||
.slice()
|
||||
.sort((a: any, b: any) => a.sequence - b.sequence)
|
||||
.map((step: any, idx: number) => (
|
||||
<div key={step.id} className="flex gap-3">
|
||||
{/* Connector column */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full border-2 border-white shadow flex items-center justify-center text-[10px] font-bold text-white shrink-0 ${
|
||||
step.step_type === "initial"
|
||||
? "bg-blue-500"
|
||||
: step.step_type === "task"
|
||||
? "bg-purple-500"
|
||||
: step.step_type === "approval"
|
||||
? "bg-amber-500"
|
||||
: "bg-slate-400"
|
||||
}`}
|
||||
>
|
||||
{step.sequence}
|
||||
</div>
|
||||
{idx < definition.steps.length - 1 && (
|
||||
<div
|
||||
className={`w-0.5 flex-1 min-h-[16px] ${
|
||||
STEP_CONNECTOR_COLORS[step.step_type] ?? "bg-slate-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step card */}
|
||||
<div
|
||||
className={`flex-1 mb-3 p-3 rounded-lg border ${
|
||||
STEP_TYPE_COLORS[step.step_type] ?? "bg-gray-50 text-gray-700 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{step.name}</p>
|
||||
<p className="text-[11px] font-mono opacity-70">{step.step_code}</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full bg-white/60 border border-current/20">
|
||||
{step.step_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
{/* Assignee */}
|
||||
{step.assignee?.type && (
|
||||
<div className="flex items-center gap-1 text-[11px] opacity-80">
|
||||
<AssigneeIcon type={step.assignee.type} />
|
||||
<span className="capitalize">{step.assignee.type}</span>
|
||||
{step.assignee.role && (
|
||||
<span className="font-medium">: {step.assignee.role}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SLA */}
|
||||
{step.sla?.hours != null && (
|
||||
<div className="flex items-center gap-1 text-[11px] opacity-80">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>SLA: {step.sla.hours}h</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirements */}
|
||||
{step.requirements?.signature && (
|
||||
<div className="flex items-center gap-1 text-[11px] opacity-80">
|
||||
<Fingerprint className="w-3 h-3" />
|
||||
<span>Signature</span>
|
||||
</div>
|
||||
)}
|
||||
{step.requirements?.comment && (
|
||||
<div className="flex items-center gap-1 text-[11px] opacity-80">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span>Comment</span>
|
||||
</div>
|
||||
)}
|
||||
{step.requirements?.attachment && (
|
||||
<div className="flex items-center gap-1 text-[11px] opacity-80">
|
||||
<Paperclip className="w-3 h-3" />
|
||||
<span>Attachment</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Available actions */}
|
||||
{step.available_actions?.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{step.available_actions.map((action: string) => (
|
||||
<span
|
||||
key={action}
|
||||
className="px-2 py-0.5 text-[11px] font-medium rounded bg-white/50 border border-current/20"
|
||||
>
|
||||
{action}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Transitions ─────────────────────────────── */}
|
||||
{definition.transitions?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#0f1724] mb-3 flex items-center gap-2">
|
||||
<span className="w-5 h-5 rounded-full bg-[#112868] text-white text-[10px] flex items-center justify-center font-bold">
|
||||
{definition.transitions.length}
|
||||
</span>
|
||||
Transitions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{definition.transitions.map((t: any) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-2 p-3 bg-[#f8fafc] rounded-lg border border-[rgba(0,0,0,0.06)] flex-wrap"
|
||||
>
|
||||
{/* From */}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold text-[#1e293b] truncate">
|
||||
{t.from_step_name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[#94a3b8]">{t.from_step_code}</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow + action */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-[#112868]/10 rounded-full shrink-0">
|
||||
<ChevronRight className="w-3 h-3 text-[#112868]" />
|
||||
<span className="text-[11px] font-semibold text-[#112868]">{t.action}</span>
|
||||
<ArrowRight className="w-3 h-3 text-[#112868]" />
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold text-[#1e293b] truncate">
|
||||
{t.to_step_name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[#94a3b8]">{t.to_step_code}</span>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<span className="ml-auto text-[11px] text-[#64748b] hidden sm:block truncate max-w-[140px]">
|
||||
{t.name}
|
||||
</span>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{t.requires_comment && (
|
||||
<span className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-amber-100 text-amber-700 rounded-full">
|
||||
<MessageSquare className="w-2.5 h-2.5" />
|
||||
Comment
|
||||
</span>
|
||||
)}
|
||||
{t.requires_signature && (
|
||||
<span className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-blue-100 text-blue-700 rounded-full">
|
||||
<Fingerprint className="w-2.5 h-2.5" />
|
||||
Signature
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@ -8,9 +8,10 @@ import {
|
||||
FilterDropdown,
|
||||
DeleteConfirmationModal,
|
||||
WorkflowDefinitionModal,
|
||||
WorkflowDefinitionViewModal,
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import { Plus, GitBranch, Play, Power, Trash2, Copy, Edit } from "lucide-react";
|
||||
import { Plus, GitBranch, Play, Power, Trash2, Copy, Edit, Eye } from "lucide-react";
|
||||
import { workflowService } from "@/services/workflow-service";
|
||||
import type { WorkflowDefinition } from "@/types/workflow";
|
||||
import { showToast } from "@/utils/toast";
|
||||
@ -52,6 +53,8 @@ const WorkflowDefinitionsTable = ({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDefinition, setSelectedDefinition] =
|
||||
useState<WorkflowDefinition | null>(null);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
const [viewDefinitionId, setViewDefinitionId] = useState<string | null>(null);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
|
||||
const fetchDefinitions = async () => {
|
||||
@ -256,6 +259,18 @@ const WorkflowDefinitionsTable = ({
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewDefinitionId(wf.id);
|
||||
setIsViewModalOpen(true);
|
||||
}}
|
||||
disabled={isActionLoading}
|
||||
className="p-1 hover:bg-slate-100 rounded-md transition-colors text-slate-600"
|
||||
title="View Details"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDefinition(wf);
|
||||
@ -410,6 +425,16 @@ const WorkflowDefinitionsTable = ({
|
||||
onSuccess={fetchDefinitions}
|
||||
initialEntityType={entityType}
|
||||
/>
|
||||
|
||||
<WorkflowDefinitionViewModal
|
||||
isOpen={isViewModalOpen}
|
||||
onClose={() => {
|
||||
setIsViewModalOpen(false);
|
||||
setViewDefinitionId(null);
|
||||
}}
|
||||
definitionId={viewDefinitionId}
|
||||
tenantId={effectiveTenantId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -27,6 +27,10 @@ export * from './DepartmentModals';
|
||||
export * from './DesignationModals';
|
||||
export { default as WorkflowDefinitionsTable } from './WorkflowDefinitionsTable';
|
||||
export { WorkflowDefinitionModal } from './WorkflowDefinitionModal';
|
||||
export { WorkflowDefinitionViewModal } from './WorkflowDefinitionViewModal';
|
||||
export { SuppliersTable } from './SuppliersTable';
|
||||
export { SupplierModal } from './SupplierModal';
|
||||
export { ViewSupplierModal } from './ViewSupplierModal';
|
||||
export { SupplierContactsModal } from './SupplierContactsModal';
|
||||
export { SupplierScorecardsModal } from './SupplierScorecardsModal';
|
||||
export { FormTextArea } from './FormTextArea';
|
||||
@ -14,7 +14,6 @@ import {
|
||||
Image as ImageIcon,
|
||||
Building2,
|
||||
BadgeCheck,
|
||||
UserCog,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
@ -36,7 +35,6 @@ import type { MyModule } from "@/types/module";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
|
||||
import DesignationsTable from "@/components/superadmin/DesignationsTable";
|
||||
import UserCategoriesTable from "@/components/superadmin/UserCategoriesTable";
|
||||
|
||||
type TabType =
|
||||
| "overview"
|
||||
@ -44,7 +42,7 @@ type TabType =
|
||||
| "roles"
|
||||
| "departments"
|
||||
| "designations"
|
||||
| "user-categories"
|
||||
// | "user-categories"
|
||||
| "workflow-definitions"
|
||||
| "suppliers"
|
||||
| "modules"
|
||||
@ -67,11 +65,11 @@ const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [
|
||||
label: "Designations",
|
||||
icon: <BadgeCheck className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: "user-categories",
|
||||
label: "User Categories",
|
||||
icon: <UserCog className="w-4 h-4" />,
|
||||
},
|
||||
// {
|
||||
// id: "user-categories",
|
||||
// label: "User Categories",
|
||||
// icon: <UserCog className="w-4 h-4" />,
|
||||
// },
|
||||
{
|
||||
id: "workflow-definitions",
|
||||
label: "Workflow Definitions",
|
||||
@ -351,9 +349,9 @@ const TenantDetails = (): ReactElement => {
|
||||
{activeTab === "designations" && id && (
|
||||
<DesignationsTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === "user-categories" && id && (
|
||||
{/* {activeTab === "user-categories" && id && (
|
||||
<UserCategoriesTable tenantId={id} compact={true} />
|
||||
)}
|
||||
)} */}
|
||||
{activeTab === "workflow-definitions" && id && (
|
||||
<WorkflowDefinitionsTable tenantId={id} compact={true} />
|
||||
)}
|
||||
|
||||
@ -3,6 +3,9 @@ import type {
|
||||
SuppliersResponse,
|
||||
SupplierResponse,
|
||||
SupplierContactsResponse,
|
||||
SupplierContact,
|
||||
SupplierScorecard,
|
||||
SupplierScorecardsResponse,
|
||||
CreateSupplierData,
|
||||
UpdateSupplierData
|
||||
} from '@/types/supplier';
|
||||
@ -56,6 +59,12 @@ export const supplierService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addContact: async (id: string, data: any, tenantId?: string | null): Promise<{ success: boolean; data: SupplierContact }> => {
|
||||
const url = tenantId ? `/suppliers/${id}/contacts?tenantId=${tenantId}` : `/suppliers/${id}/contacts`;
|
||||
const response = await apiClient.post<{ success: boolean; data: SupplierContact }>(url, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTypes: async (): Promise<{ success: boolean, data: { code: string, name: string }[] }> => {
|
||||
const response = await apiClient.get('/suppliers/types');
|
||||
return response.data;
|
||||
@ -69,5 +78,17 @@ export const supplierService = {
|
||||
getStatuses: async (): Promise<{ success: boolean, data: { code: string, name: string }[] }> => {
|
||||
const response = await apiClient.get('/suppliers/statuses');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getScorecards: async (id: string, tenantId?: string | null): Promise<SupplierScorecardsResponse> => {
|
||||
const url = tenantId ? `/suppliers/${id}/scorecards?tenantId=${tenantId}` : `/suppliers/${id}/scorecards`;
|
||||
const response = await apiClient.get<SupplierScorecardsResponse>(url);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createScorecard: async (id: string, data: any, tenantId?: string | null): Promise<{ success: boolean; data: SupplierScorecard }> => {
|
||||
const url = tenantId ? `/suppliers/${id}/scorecards?tenantId=${tenantId}` : `/suppliers/${id}/scorecards`;
|
||||
const response = await apiClient.post<{ success: boolean; data: SupplierScorecard }>(url, data);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,18 +1,60 @@
|
||||
export interface Supplier {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
legal_name?: string | null;
|
||||
supplier_code?: string;
|
||||
supplier_type: string;
|
||||
category: string;
|
||||
status: string;
|
||||
risk_level?: string;
|
||||
website?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
zip_code?: string;
|
||||
website?: string | null;
|
||||
tax_id?: string | null;
|
||||
duns_number?: string | null;
|
||||
|
||||
contact?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
|
||||
address?: {
|
||||
line1?: string | null;
|
||||
line2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
country?: string | null;
|
||||
postal_code?: string | null;
|
||||
};
|
||||
|
||||
quality_agreement?: {
|
||||
signed: boolean;
|
||||
date?: string | null;
|
||||
};
|
||||
|
||||
dates?: {
|
||||
approved?: string | null;
|
||||
expiry?: string | null;
|
||||
last_audit?: string | null;
|
||||
next_audit?: string | null;
|
||||
};
|
||||
|
||||
score?: {
|
||||
current?: number | null;
|
||||
grade?: string | null;
|
||||
};
|
||||
|
||||
products_services?: string[];
|
||||
certifications?: Array<{
|
||||
name: string;
|
||||
issuing_body?: string;
|
||||
expiry_date?: string | null;
|
||||
document_url?: string | null;
|
||||
}>;
|
||||
|
||||
contacts?: SupplierContact[];
|
||||
|
||||
workflow_instance_id?: string | null;
|
||||
created_by?: string | null;
|
||||
tenant_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@ -31,6 +73,34 @@ export interface SupplierContact {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SupplierScorecard {
|
||||
id: string;
|
||||
supplier_id: string;
|
||||
period: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
quality_score: number;
|
||||
defect_rate: number;
|
||||
lot_acceptance_rate: number;
|
||||
complaints_count: number;
|
||||
delivery_score: number;
|
||||
on_time_delivery_rate: number;
|
||||
lead_time_adherence: number;
|
||||
service_score: number;
|
||||
responsiveness_score: number;
|
||||
documentation_score: number;
|
||||
cost_score: number;
|
||||
price_competitiveness: number;
|
||||
overall: {
|
||||
score: number;
|
||||
grade: string;
|
||||
};
|
||||
notes?: string;
|
||||
calculated_by?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SuppliersResponse {
|
||||
success: boolean;
|
||||
data: Supplier[];
|
||||
@ -51,6 +121,11 @@ export interface SupplierContactsResponse {
|
||||
data: SupplierContact[];
|
||||
}
|
||||
|
||||
export interface SupplierScorecardsResponse {
|
||||
success: boolean;
|
||||
data: SupplierScorecard[];
|
||||
}
|
||||
|
||||
export interface CreateSupplierData {
|
||||
name: string;
|
||||
code?: string;
|
||||
@ -59,12 +134,29 @@ export interface CreateSupplierData {
|
||||
status?: string;
|
||||
risk_level?: string;
|
||||
website?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
zip_code?: string;
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
postal_code?: string;
|
||||
tax_id?: string;
|
||||
duns_number?: string;
|
||||
primary_contact_name?: string;
|
||||
primary_contact_email?: string;
|
||||
primary_contact_phone?: string;
|
||||
products_services?: string[];
|
||||
certifications?: Array<{
|
||||
name: string;
|
||||
issuing_body?: string;
|
||||
expiry_date?: string | null;
|
||||
document_url?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UpdateSupplierData extends Partial<CreateSupplierData> {}
|
||||
export interface UpdateSupplierData extends Partial<CreateSupplierData> {
|
||||
quality_agreement_signed?: boolean;
|
||||
quality_agreement_date?: string | Date | null;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user