feat: Introduce new modals for supplier contacts, scorecards, and workflow definition viewing, complementing an enhanced supplier data structure.

This commit is contained in:
Yashwin 2026-03-17 18:47:32 +05:30
parent c9503c78be
commit 4e83f55800
14 changed files with 1902 additions and 343 deletions

View File

@ -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

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

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

View File

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

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

View File

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

View File

@ -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">
<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
}
<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="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>
)}

View File

@ -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
}

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

View File

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

View File

@ -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 { ViewSupplierModal } from './ViewSupplierModal';
export { SupplierContactsModal } from './SupplierContactsModal';
export { SupplierScorecardsModal } from './SupplierScorecardsModal';
export { FormTextArea } from './FormTextArea';

View File

@ -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} />
)}

View File

@ -3,6 +3,9 @@ import type {
SuppliersResponse,
SupplierResponse,
SupplierContactsResponse,
SupplierContact,
SupplierScorecard,
SupplierScorecardsResponse,
CreateSupplierData,
UpdateSupplierData
} from '@/types/supplier';
@ -55,6 +58,12 @@ export const supplierService = {
const response = await apiClient.get<SupplierContactsResponse>(url);
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');
@ -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;
}
};

View File

@ -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;
}