29/6/2026 v1.1 correction in ui

This commit is contained in:
MohamedHasan07
2026-06-29 15:30:49 +05:30
parent d6f983cf16
commit 75a6d72771
29 changed files with 965 additions and 459 deletions
+64
View File
@@ -8,6 +8,7 @@
"name": "pim",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.17",
"@radix-ui/react-checkbox": "^1.3.5",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.10",
@@ -639,6 +640,33 @@
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.17.tgz",
"integrity": "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-dialog": "1.1.17",
"@radix-ui/react-primitive": "2.1.6"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz",
@@ -748,6 +776,42 @@
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz",
"integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-dismissable-layer": "1.1.13",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.10",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-portal": "1.1.12",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-controllable-state": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.17",
"@radix-ui/react-checkbox": "^1.3.5",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.10",
+9 -3
View File
@@ -14,11 +14,13 @@ export type BadgeVariant =
| "info"
| "success"
| "warning"
| "neutral";
| "neutral"
| "review"
| "approval";
const BADGE_STYLES: Record<BadgeVariant, string> = {
active: "bg-green-100 text-green-700 border border-green-200",
draft: "bg-blue-100 text-blue-700 border border-blue-200",
draft: "bg-slate-100 text-slate-700 border border-slate-200",
disabled: "bg-gray-100 text-gray-500 border border-gray-200",
published: "bg-purple-100 text-purple-700 border border-purple-200",
pending: "bg-amber-100 text-amber-700 border border-amber-200",
@@ -29,11 +31,13 @@ const BADGE_STYLES: Record<BadgeVariant, string> = {
success: "bg-emerald-100 text-emerald-700 border border-emerald-200",
warning: "bg-orange-100 text-orange-700 border border-orange-200",
neutral: "bg-gray-50 text-gray-600 border border-gray-200",
review: "bg-blue-100 text-blue-700 border border-blue-200",
approval: "bg-yellow-100 text-yellow-700 border border-yellow-200",
};
const DOT_COLORS: Record<BadgeVariant, string> = {
active: "bg-green-500",
draft: "bg-blue-400",
draft: "bg-slate-400",
disabled: "bg-gray-400",
published: "bg-purple-500",
pending: "bg-amber-500",
@@ -44,6 +48,8 @@ const DOT_COLORS: Record<BadgeVariant, string> = {
success: "bg-emerald-500",
warning: "bg-orange-500",
neutral: "bg-gray-400",
review: "bg-blue-400",
approval: "bg-yellow-400",
};
interface StatusBadgeProps {
+2 -2
View File
@@ -156,9 +156,9 @@ export function Sidebar() {
{/* Footer */}
<div className="p-2 border-t border-border mt-auto">
<div className="px-2 py-1.5 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer">
<div className="px-2 py-1 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm flex-shrink-0">
<div className="w-7 h-6 rounded-full bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm flex-shrink-0">
AC
</div>
<div className="flex-1 min-w-0">
+108
View File
@@ -0,0 +1,108 @@
import React from 'react';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { AlertTriangle, X, Loader2 } from 'lucide-react';
interface ConfirmationModalProps {
isOpen: boolean;
title: string;
description: React.ReactNode;
itemName?: string;
loading?: boolean;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
variant?: 'danger' | 'warning' | 'info';
}
export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
isOpen,
title,
description,
itemName,
loading = false,
confirmText = 'Delete',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'danger',
}) => {
const isDanger = variant === 'danger';
return (
<AlertDialog.Root open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-xl shadow-black/10 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-2xl dark:bg-slate-900 dark:border-slate-800">
<div className="flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left">
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full ${
isDanger ? 'bg-red-100 text-red-600 dark:bg-red-900/20' : 'bg-amber-100 text-amber-600 dark:bg-amber-900/20'
}`}
>
<AlertTriangle className="h-6 w-6" aria-hidden="true" />
</div>
<div className="flex-1">
<AlertDialog.Title className="text-lg font-semibold text-slate-900 dark:text-slate-50">
{title}
</AlertDialog.Title>
<AlertDialog.Description className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{description}
{itemName && (
<span className="block mt-1 font-medium text-slate-900 dark:text-slate-200">
"{itemName}"
</span>
)}
</AlertDialog.Description>
</div>
</div>
<div className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<AlertDialog.Cancel asChild>
<button
type="button"
disabled={loading}
onClick={onCancel}
className="mt-2 inline-flex h-10 items-center justify-center rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 sm:mt-0 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
>
{cancelText}
</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button
type="button"
disabled={loading}
onClick={(e) => {
e.preventDefault(); // Prevent closing until done
onConfirm();
}}
className={`inline-flex h-10 items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 ${
isDanger
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-amber-600 hover:bg-amber-700 focus:ring-amber-500'
}`}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
confirmText
)}
</button>
</AlertDialog.Action>
</div>
<button
type="button"
onClick={onCancel}
disabled={loading}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none dark:ring-offset-slate-900"
>
<X className="h-4 w-4 text-slate-500 dark:text-slate-400" />
<span className="sr-only">Close</span>
</button>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
};
@@ -10,10 +10,14 @@ import { useAttribute } from "../hook/useAttribute";
import type { Attribute } from "../types/attribute.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { AttributeCard } from "../components/AttributeCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
export default function AttributeList() {
const navigate = useNavigate();
const { attributes, fetchAttributes, deleteAttribute } = useAttribute();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchAttributes();
@@ -34,9 +38,16 @@ export default function AttributeList() {
return { total, active, variantEligible, mostUsed };
}, [attributes]);
const handleDelete = async (id: string, name: string) => {
if (confirm(`Are you sure you want to delete attribute "${name}"?`)) {
await deleteAttribute(id);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteAttribute(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Error handled in hook
} finally {
setIsDeleting(false);
}
};
@@ -219,11 +230,21 @@ export default function AttributeList() {
<Eye className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Edit2 className="w-4 h-4 cursor-pointer hover:text-blue-600 transition-colors" onClick={(e) => { e.stopPropagation(); navigate(`/attributes/${row.id}/edit`); }} />
<Copy className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Trash2 className="w-4 h-4 cursor-pointer hover:text-red-500 transition-colors" onClick={(e) => { e.stopPropagation(); handleDelete(row.id, row.name); }} />
<Trash2 className="w-4 h-4 cursor-pointer hover:text-red-500 transition-colors" onClick={(e) => { e.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }} />
</div>
)}
/>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Attribute"
description="Are you sure you want to delete this attribute? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
@@ -124,6 +124,7 @@ export default function NewAttribute() {
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/attributes')}>Cancel</Button>
<Button variant="outline" size="md" type="button">Save as Draft</Button>
<Button variant="primary" size="md" type="submit" form="attribute-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Save Attribute'}</Button>
</>
}
@@ -314,11 +315,6 @@ export default function NewAttribute() {
</div>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate("/attributes")}>Cancel</Button>
<Button variant="primary" size="md" type="submit" loading={formik.isSubmitting}>{isEdit ? "Save Changes" : "Save Attribute"}</Button>
</div>
</form>
</PageWrapper>
</ProtectedRoute>
+25 -4
View File
@@ -10,18 +10,29 @@ import { useBrand } from "../hook/useBrand";
import type { Brand } from "../types/brand.types";
import { formatDate } from "../../../utils/formatters";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
export default function BrandList() {
const navigate = useNavigate();
const { brands, fetchBrands, deleteBrand } = useBrand();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchBrands();
}, [fetchBrands]);
const handleDelete = async (id: string, name: string) => {
if (confirm(`Are you sure you want to delete brand "${name}"?`)) {
await deleteBrand(id);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteBrand(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Error handled in hook
} finally {
setIsDeleting(false);
}
};
@@ -82,13 +93,23 @@ export default function BrandList() {
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
onClick={(e) => {
e?.stopPropagation();
handleDelete(row.id, row.name);
setDeleteModal({ isOpen: true, id: row.id, name: row.name });
}}
/>
</div>
)}
/>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Brand"
description="Are you sure you want to delete this brand? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
+106 -117
View File
@@ -1,49 +1,54 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useBrand } from "../hook/useBrand";
import { brandSchema } from "../validation/brand.schema";
import type { BrandCreateRequest } from "../types/brand.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const brandSchema = Yup.object().shape({
code: Yup.string()
.required("Brand code is required")
.matches(/^[a-z0-9_]+$/, "Code can only contain lowercase letters, numbers, and underscores"),
name: Yup.string().required("Brand name is required"),
description: Yup.string(),
status: Yup.string().oneOf(["active", "inactive"]),
});
const inputClass = (error?: boolean) =>
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
const textareaClass = `w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 min-h-[100px] resize-y`;
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
export default function NewBrand() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createBrand, updateBrand, fetchBrands, brands } = useBrand();
const { createBrand, updateBrand } = useBrand();
const [isActive, setIsActive] = useState(true);
const formik = useFormik({
initialValues: {
code: "",
name: "",
code: "",
description: "",
status: "active" as "active" | "inactive",
website: "",
country: "",
},
validationSchema: brandSchema,
onSubmit: async (values, { setSubmitting }) => {
const payload: BrandCreateRequest = {
name: values.name,
code: values.code,
description: values.description || undefined,
website: values.website || undefined,
country: values.country || undefined,
status: isActive ? "active" : "inactive",
};
try {
if (isEdit && id) {
await updateBrand(id, values);
await updateBrand(id, payload);
} else {
await createBrand(values as BrandCreateRequest);
await createBrand(payload);
}
navigate("/brands");
} catch {
@@ -55,129 +60,113 @@ export default function NewBrand() {
});
useEffect(() => {
fetchBrands();
}, [fetchBrands]);
useEffect(() => {
if (isEdit && brands.length > 0) {
const match = brands.find((item) => item.id === id);
if (match) {
formik.setValues({
code: match.code,
name: match.name,
description: match.description || "",
status: match.status,
});
}
if (isEdit && id) {
// Fetch existing brand for edit
// brandService.getById(id).then(...);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, brands]);
}, [isEdit, id]);
return (
<ProtectedRoute node="masters.brands">
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Brands', href: '/brands' }, { label: isEdit ? 'Edit Brand' : 'Create Brand' }]}
backTo="/brands"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/brands')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="brand-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Brand'}</Button>
</>
}
/>
<form id="brand-form" onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Brand Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Code */}
<div>
<label className={labelClass}>Brand Code *</label>
<input
type="text"
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g. nike"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-gray-50 disabled:text-gray-400`}
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Brands', href: '/brands' }, { label: isEdit ? 'Edit Brand' : 'Create Brand' }]}
backTo="/brands"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/brands')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="outline" size="md" type="button">Save as Draft</Button>
<Button variant="primary" size="md" type="submit" form="brand-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Brand'}</Button>
</>
}
/>
<div className="max-w-4xl mx-auto">
<form id="brand-form" onSubmit={formik.handleSubmit} className="space-y-10">
{/* Basic Information */}
<div className="bg-white border border-gray-200 rounded-xl p-8">
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
<p className="text-sm text-gray-500">Enter the basic details for this brand</p>
</div>
{/* Name */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Brand Name *</label>
<label className={labelClass}>Brand Name <span className="text-red-500">*</span></label>
<input
type="text"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Nike"
placeholder="e.g., Apple"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
{/* Description */}
<div>
<label className={labelClass}>Internal Code <span className="text-red-500">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., APPLE"
className={inputClass(formik.touched.code && Boolean(formik.errors.code))}
/>
</div>
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Athletic apparel and accessories manufacturer"
rows={3}
placeholder="Describe the brand..."
className={textareaClass}
/>
</div>
<div>
<label className={labelClass}>Website</label>
<input
name="website"
type="url"
value={formik.values.website}
onChange={formik.handleChange}
placeholder="https://www.example.com"
className={inputClass()}
/>
</div>
{/* Status */}
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
<div>
<label className={labelClass}>Country</label>
<input
name="country"
value={formik.values.country}
onChange={formik.handleChange}
placeholder="e.g., United States"
className={inputClass()}
/>
</div>
<div className="md:col-span-2 pt-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600"
/>
<span className="text-sm font-medium text-gray-700">
Active <span className="text-gray-500 font-normal">(Unchecked brands will be saved as inactive)</span>
</span>
</label>
</div>
</div>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate("/brands")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" loading={formik.isSubmitting}>
{isEdit ? "Save Changes" : "Create Brand"}
</Button>
</div>
</form>
</PageWrapper>
</ProtectedRoute>
</div>
</PageWrapper>
);
}
}
+2
View File
@@ -5,6 +5,8 @@ export interface Brand {
code: string;
name: string;
description?: string;
website?: string;
country?: string;
status: BrandStatus;
lastUpdated: string;
createdBy: string;
@@ -0,0 +1,11 @@
import * as Yup from 'yup';
export const brandSchema = Yup.object().shape({
code: Yup.string()
.required("Brand code is required")
.matches(/^[A-Za-z0-9_]+$/, "Code can only contain letters, numbers, and underscores"),
name: Yup.string().required("Brand name is required"),
description: Yup.string(),
website: Yup.string().url("Must be a valid URL").optional(),
country: Yup.string().optional(),
});
+25 -4
View File
@@ -10,10 +10,14 @@ import { useCategory } from "../hook/useCategory";
import type { Category } from "../types/category.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { CategoryCard } from "../components/CategoryCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
export default function CategoryList() {
const navigate = useNavigate();
const { categories, fetchCategories, deleteCategory } = useCategory();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchCategories();
@@ -27,9 +31,16 @@ export default function CategoryList() {
families: 406
};
const handleDelete = async (id: string, name: string) => {
if (confirm(`Are you sure you want to delete category "${name}"?`)) {
await deleteCategory(id);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteCategory(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Error handled in hook
} finally {
setIsDeleting(false);
}
};
@@ -120,11 +131,21 @@ export default function CategoryList() {
<div className="flex items-center justify-end gap-3 text-gray-400 px-2">
<Eye className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Edit2 className="w-4 h-4 cursor-pointer hover:text-blue-600 transition-colors" onClick={(e) => { e.stopPropagation(); navigate(`/categories/${row.id}/edit`); }} />
<Trash2 className="w-4 h-4 cursor-pointer hover:text-red-500 transition-colors" onClick={(e) => { e.stopPropagation(); handleDelete(row.id, row.name); }} />
<Trash2 className="w-4 h-4 cursor-pointer hover:text-red-500 transition-colors" onClick={(e) => { e.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }} />
</div>
)}
/>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Category"
description="Are you sure you want to delete this category? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
+24 -4
View File
@@ -10,10 +10,14 @@ import { useFamily } from "../hook/useFamily";
import type { Family } from "../types/family.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { FamilyCard } from "../components/FamilyCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
export default function FamilyList() {
const navigate = useNavigate();
const { families, fetchFamilies, deleteFamily } = useFamily();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchFamilies();
@@ -29,9 +33,16 @@ export default function FamilyList() {
return { total: families.length, published, totalProducts, avgCompleteness };
}, [families]);
const handleDelete = async (id: string, name: string) => {
if (confirm(`Are you sure you want to delete product family "${name}"?`)) {
await deleteFamily(id);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteFamily(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Error handled in hook
} finally {
setIsDeleting(false);
}
};
@@ -182,11 +193,20 @@ export default function FamilyList() {
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={() => handleDelete(row.id, row.name)} // ← Fixed
onClick={(e) => { e?.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }}
/>
</div>
)}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Family"
description="Are you sure you want to delete this product family? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
+29 -11
View File
@@ -10,6 +10,8 @@ import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../../../components/customs/DataTable";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { useFormik } from "formik";
import { familySchema } from "../validation/family.schema";
const TABS = [
{ id: 'basic', label: 'Basic Information', icon: FileText },
@@ -34,6 +36,22 @@ export default function NewFamily() {
const [activeTab, setActiveTab] = useState('basic');
const [selectedWorkflow, setSelectedWorkflow] = useState('standard');
const formik = useFormik({
initialValues: {
name: '',
code: '',
description: '',
status: 'draft',
category: '',
attributes: [],
variantAxes: [],
},
validationSchema: familySchema,
onSubmit: async (values) => {
console.log('Family submitted', values);
}
});
const inputClass = "w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white";
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
@@ -45,13 +63,13 @@ export default function NewFamily() {
actions={
<>
<button onClick={() => navigate('/families')} className="px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">Cancel</button>
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
<button type="submit" form="family-form" className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
<Save className="w-4 h-4" /> Create Family
</button>
</>
}
/>
<div className="flex-1 max-w-[1400px] w-full mx-auto">
<form id="family-form" onSubmit={formik.handleSubmit} className="flex-1 max-w-[1400px] w-full mx-auto">
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200 mb-6 overflow-x-auto hide-scrollbar">
{TABS.map(tab => {
@@ -78,27 +96,27 @@ export default function NewFamily() {
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<label className={labelClass}>Family Name <span className="text-red-500">*</span></label>
<input placeholder="e.g., Laptop Family" className={inputClass} />
<input name="name" value={formik.values.name} onChange={formik.handleChange} placeholder="e.g., Laptop Family" className={inputClass} />
</div>
<div>
<label className={labelClass}>Family Code <span className="text-red-500">*</span></label>
<input placeholder="e.g., laptop_family" className={`${inputClass} bg-gray-50`} />
<input name="code" value={formik.values.code} onChange={formik.handleChange} placeholder="e.g., laptop_family" className={`${inputClass} bg-gray-50`} />
</div>
<div className="col-span-2">
<label className={labelClass}>Description</label>
<textarea placeholder="Describe this product family..." rows={3} className={`${inputClass} resize-none`} />
<textarea name="description" value={formik.values.description} onChange={formik.handleChange} placeholder="Describe this product family..." rows={3} className={`${inputClass} resize-none`} />
</div>
<div>
<label className={labelClass}>Status</label>
<select className={inputClass} defaultValue="Draft">
<option value="Draft">Draft</option>
<option value="Active">Active</option>
<select name="status" value={formik.values.status} onChange={formik.handleChange} className={inputClass}>
<option value="draft">Draft</option>
<option value="active">Active</option>
</select>
</div>
<div>
<label className={labelClass}>Category Assignment</label>
<select className={inputClass}>
<option>Select a category...</option>
<select name="category" value={formik.values.category} onChange={formik.handleChange} className={inputClass}>
<option value="">Select a category...</option>
</select>
</div>
</div>
@@ -416,7 +434,7 @@ export default function NewFamily() {
</div>
)}
</div>
</div>
</form>
</PageWrapper>
);
}
+52 -29
View File
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useFormik } from 'formik';
import { Box, LayoutGrid, Tags, Globe, Eye, Settings2, Save, Send, CheckCircle2, Copy, Upload, Image as ImageIcon, Video, FileText, Link as LinkIcon, Info, FolderTree, AlertCircle, RefreshCw, Briefcase } from 'lucide-react';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
import { productSchema } from '../validation/product.schema';
const MOCK_FAMILIES = [
@@ -49,12 +51,32 @@ export default function NewProduct() {
const [activeTab, setActiveTab] = useState('general');
// Form State
const [formData, setFormData] = useState({
name: '', code: 'PROF-LAPTOP', sku: '', barcode: '',
gtin: '', upc: '', ean: '',
brand: '', unit: '', country: '', hsn: '',
type: 'simple', status: 'Draft',
shortDesc: ''
const formik = useFormik({
initialValues: {
name: '',
sku: '',
productId: 'PROF-LAPTOP',
category: '',
subcategory: '',
status: 'draft',
price: '',
stock: 0,
code: 'PROF-LAPTOP',
barcode: '',
gtin: '',
upc: '',
ean: '',
brand: '',
unit: '',
country: '',
hsn: '',
type: 'simple',
shortDesc: ''
},
validationSchema: productSchema,
onSubmit: async (values) => {
console.log('Product submitted', values);
}
});
const handleNext = () => {
@@ -80,10 +102,10 @@ export default function NewProduct() {
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
<Save className="w-4 h-4" /> Save Draft
</button>
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
<button type="submit" form="product-form" className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
<Send className="w-4 h-4" /> Submit for Review
</button>
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">Publish</button>
<button type="submit" form="product-form" className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">Publish</button>
</>
) : undefined
}
@@ -168,7 +190,7 @@ export default function NewProduct() {
)}
{step === 2 && (
<div className="flex flex-col h-[calc(100vh-140px)]">
<form id="product-form" onSubmit={formik.handleSubmit} className="flex flex-col h-[calc(100vh-140px)]">
<p className="text-sm text-gray-500 mb-4">Fill in product details across all tabs</p>
{/* Tabs */}
@@ -206,22 +228,23 @@ export default function NewProduct() {
<div className="col-span-2">
<label className={labelClass}>Product Name *</label>
<input
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
name="name"
value={formik.values.name}
onChange={formik.handleChange}
placeholder="Enter product name"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Product Code *</label>
<input value={formData.code} readOnly className={`${inputClass} bg-gray-50 text-gray-500`} />
<input name="code" value={formik.values.code} readOnly className={`${inputClass} bg-gray-50 text-gray-500`} />
</div>
<div>
<label className={labelClass}>Status</label>
<select value={formData.status} onChange={e => setFormData({...formData, status: e.target.value})} className={inputClass}>
<option>Draft</option>
<option>Pending Review</option>
<option>Active</option>
<select name="status" value={formik.values.status} onChange={formik.handleChange} className={inputClass}>
<option value="draft">Draft</option>
<option value="pending">Pending Review</option>
<option value="active">Active</option>
</select>
</div>
</div>
@@ -236,23 +259,23 @@ export default function NewProduct() {
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>SKU</label>
<input placeholder="Stock Keeping Unit" className={inputClass} />
<input name="sku" value={formik.values.sku} onChange={formik.handleChange} placeholder="Stock Keeping Unit" className={inputClass} />
</div>
<div>
<label className={labelClass}>Barcode</label>
<input placeholder="Barcode" className={inputClass} />
<input name="barcode" value={formik.values.barcode} onChange={formik.handleChange} placeholder="Barcode" className={inputClass} />
</div>
<div>
<label className={labelClass}>GTIN</label>
<input placeholder="Global Trade Item Number" className={inputClass} />
<input name="gtin" value={formik.values.gtin} onChange={formik.handleChange} placeholder="Global Trade Item Number" className={inputClass} />
</div>
<div>
<label className={labelClass}>UPC</label>
<input placeholder="Universal Product Code" className={inputClass} />
<input name="upc" value={formik.values.upc} onChange={formik.handleChange} placeholder="Universal Product Code" className={inputClass} />
</div>
<div className="col-span-2">
<label className={labelClass}>EAN</label>
<input placeholder="European Article Number" className={inputClass} />
<input name="ean" value={formik.values.ean} onChange={formik.handleChange} placeholder="European Article Number" className={inputClass} />
</div>
</div>
</div>
@@ -288,14 +311,14 @@ export default function NewProduct() {
<h3 className="font-semibold text-gray-900 mb-4">Product Type</h3>
<div className="flex gap-4">
<label className="flex items-center gap-3 p-4 border border-purple-200 bg-purple-50 rounded-xl cursor-pointer flex-1">
<input type="radio" name="type" value="simple" checked={formData.type === 'simple'} onChange={() => setFormData({...formData, type: 'simple'})} className="w-4 h-4 text-purple-600 focus:ring-purple-500" />
<input type="radio" name="type" value="simple" checked={formik.values.type === 'simple'} onChange={formik.handleChange} className="w-4 h-4 text-purple-600 focus:ring-purple-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Simple Product</div>
<div className="text-xs text-gray-500">Single product without variations</div>
</div>
</label>
<label className="flex items-center gap-3 p-4 border border-gray-200 bg-white hover:border-purple-200 rounded-xl cursor-pointer flex-1">
<input type="radio" name="type" value="variant" checked={formData.type === 'variant'} onChange={() => setFormData({...formData, type: 'variant'})} className="w-4 h-4 text-purple-600 focus:ring-purple-500" />
<input type="radio" name="type" value="variant" checked={formik.values.type === 'variant'} onChange={formik.handleChange} className="w-4 h-4 text-purple-600 focus:ring-purple-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Variant Product</div>
<div className="text-xs text-gray-500">Product with multiple variations (size, color, etc.)</div>
@@ -615,11 +638,11 @@ export default function NewProduct() {
<div className="grid grid-cols-2 gap-y-6 gap-x-12">
<div>
<div className="text-xs text-gray-500 mb-1">Product Name</div>
<div className="font-medium text-sm text-gray-900">{formData.name || 'Not set'}</div>
<div className="font-medium text-sm text-gray-900">{formik.values.name || 'Not set'}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Product Code</div>
<div className="font-medium text-sm text-gray-900">{formData.code}</div>
<div className="font-medium text-sm text-gray-900">{formik.values.code}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Product Family</div>
@@ -627,15 +650,15 @@ export default function NewProduct() {
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Brand</div>
<div className="font-medium text-sm text-gray-900">{formData.brand || 'Not set'}</div>
<div className="font-medium text-sm text-gray-900">{formik.values.brand || 'Not set'}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Status</div>
<span className="px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md">draft</span>
<span className="px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md">{formik.values.status}</span>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Product Type</div>
<div className="font-medium text-sm text-gray-900 capitalize">{formData.type}</div>
<div className="font-medium text-sm text-gray-900 capitalize">{formik.values.type}</div>
</div>
</div>
</div>
@@ -779,7 +802,7 @@ export default function NewProduct() {
</div>
</div>
</div>
</div>
</form>
)}
</div>
</PageWrapper>
+27 -1
View File
@@ -12,6 +12,7 @@ import { useProduct } from "../hook/useProduct";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { DataTable, type DataTableColumn } from "../../../components/customs/DataTable";
import type { Product } from "../types/product.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
// ── Completeness bar ───────────────────────────────────────────────────────────
@@ -44,9 +45,24 @@ export default function ProductList() {
const navigate = useNavigate();
const { products, fetchProducts, deleteProduct } = useProduct();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => { fetchProducts(); }, [fetchProducts]);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteProduct(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Keep modal open on error, toast is handled in hook
} finally {
setIsDeleting(false);
}
};
// Stats for KPI cards
const stats = useMemo(() => {
const published = products.filter(p => p.status === "published").length;
@@ -171,7 +187,7 @@ export default function ProductList() {
<Edit className="w-4 h-4" />
</button>
<button
onClick={e => { e.stopPropagation(); if (confirm("Delete this product?")) deleteProduct(row.id); }}
onClick={e => { e.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }}
className="p-1.5 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
@@ -180,6 +196,16 @@ export default function ProductList() {
</div>
)}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Product"
description="Are you sure you want to delete this product? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
+154 -61
View File
@@ -1,42 +1,65 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useScope } from "../hook/useScope";
import { scopeSchema } from "../validation/scopes.schema";
import { scopesService } from "../services/scopes.service";
import type { ScopeCreateRequest } from "../types/scopes.types";
import { Save } from "lucide-react";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const inputClass = (error?: boolean) =>
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
const textareaClass = `w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 min-h-[100px] resize-y`;
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
const PRESET_COLORS = [
{ name: "Purple", value: "#7C3AED", bg: "bg-purple-600" },
{ name: "Green", value: "#10B981", bg: "bg-emerald-500" },
{ name: "Orange", value: "#F59E0B", bg: "bg-orange-500" },
{ name: "Blue", value: "#3B82F6", bg: "bg-blue-500" },
{ name: "Violet", value: "#8B5CF6", bg: "bg-violet-500" },
{ name: "Red", value: "#F97316", bg: "bg-red-500" },
{ name: "Cyan", value: "#06B6D4", bg: "bg-cyan-500" },
{ name: "Pink", value: "#EC4899", bg: "bg-pink-500" },
];
export default function NewScope() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useScope();
const [selectedColor, setSelectedColor] = useState("#7C3AED");
const [isActive, setIsActive] = useState(true);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
code: "",
description: "",
},
validationSchema: scopeSchema,
onSubmit: async (values, { setSubmitting }) => {
const payload: ScopeCreateRequest = {
...values,
status: isActive ? "active" : "inactive",
color: selectedColor,
};
try {
if (isEdit && id) {
await updateItem(id, values as any);
await updateItem(id, payload as any);
} else {
await createItem(values as ScopeCreateRequest);
await createItem(payload);
}
navigate("..");
} catch {
// toast handled
// Handled by hook
} finally {
setSubmitting(false);
}
@@ -45,11 +68,9 @@ export default function NewScope() {
useEffect(() => {
if (isEdit && id) {
scopesService.getById(id).then(item => {
if (item) formik.setValues({ name: item.name, status: item.status });
});
// Fetch existing scope if editing
// scopesService.getById(id).then(...);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
return (
@@ -59,60 +80,132 @@ export default function NewScope() {
backTo="/scopes"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="scope-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Scope' : 'Create Scope'}</Button>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/scopes')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="outline" size="md" type="button">Save as Draft</Button>
<Button variant="primary" size="md" type="submit" form="scope-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Scope'}</Button>
</>
}
/>
<form id="scope-form" onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Scope Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Global"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<div className="max-w-4xl mx-auto">
<form id="scope-form" onSubmit={formik.handleSubmit} className="space-y-10">
{/* Basic Information */}
<div className="bg-white border border-gray-200 rounded-xl p-8">
<h2 className="text-lg font-semibold mb-2">Basic Information</h2>
<p className="text-sm text-gray-500 mb-8">Enter the basic details for this scope</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Scope Name <span className="text-red-500">*</span></label>
<input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., Ecommerce"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Internal Code <span className="text-red-500">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., ecommerce"
className={inputClass(formik.touched.code && Boolean(formik.errors.code))}
/>
</div>
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
placeholder="Describe the purpose of this scope..."
className={textareaClass}
/>
</div>
<div className="md:col-span-2 pt-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
<span className="text-sm font-medium text-gray-700">
Active <span className="text-gray-500 font-normal">(Unchecked scopes will be saved as inactive)</span>
</span>
</label>
</div>
</div>
</div>
</div>
</form>
{/* Visual Configuration */}
<div className="bg-white border border-gray-200 rounded-xl p-8">
<h2 className="text-lg font-semibold mb-2">Visual Configuration</h2>
<p className="text-sm text-gray-500 mb-6">Choose a color to identify this scope</p>
<div>
<p className="text-sm font-medium text-gray-700 mb-3">Preset Colors</p>
<div className="grid grid-cols-4 gap-3">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)}
className={`flex items-center gap-3 p-3 border rounded-lg hover:border-gray-400 transition-all ${selectedColor === color.value ? 'border-purple-600 bg-purple-50' : 'border-gray-200'}`}
>
<div className={`w-8 h-8 rounded-md ${color.bg}`} />
<div className="text-left">
<div className="font-medium text-sm">{color.name}</div>
<div className="text-xs text-gray-500 font-mono">{color.value}</div>
</div>
</button>
))}
</div>
</div>
{/* Custom Color */}
<div className="mt-8">
<p className="text-sm font-medium text-gray-700 mb-3">Custom Color</p>
<div className="flex items-center gap-4">
<input
type="color"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="w-12 h-12 p-1 border border-gray-200 rounded-lg cursor-pointer"
/>
<input
type="text"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="font-mono text-sm border border-gray-200 rounded-lg px-4 py-2.5 w-40"
/>
</div>
</div>
{/* Preview */}
<div className="mt-8 bg-gray-50 border border-gray-200 rounded-xl p-6">
<p className="text-sm text-gray-500 mb-3">Preview</p>
<div className="flex items-center gap-4 bg-white p-4 rounded-lg border">
<div className="w-10 h-10 rounded-lg" style={{ backgroundColor: selectedColor }} />
<div>
<p className="font-medium">Scope Preview</p>
<p className="text-sm text-gray-500">This is how the scope color will appear in the system</p>
</div>
</div>
</div>
</div>
</form>
</div>
</PageWrapper>
);
}
}
+25 -5
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -7,19 +7,29 @@ import { DataTable } from "../../../components/customs/DataTable";
import { useScope } from "../hook/useScope";
import type { Scope } from "../types/scopes.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
export default function ScopeList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useScope();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleDelete = async (item: Scope) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteItem(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Error is handled in the hook
} finally {
setIsDeleting(false);
}
};
@@ -32,7 +42,7 @@ export default function ScopeList() {
const actions = (item: Scope) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => setDeleteModal({ isOpen: true, id: item.id, name: item.name })} />
</div>
);
@@ -49,6 +59,16 @@ export default function ScopeList() {
<div className="mb-8">
<DataTable data={items} columns={columns} selectable actions={actions} searchPlaceholder="Search scopes..." />
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Scope"
description="Are you sure you want to delete this scope? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
);
}
@@ -1,6 +1,9 @@
export interface Scope {
id: string;
name: string;
code?: string;
description?: string;
color?: string;
status: 'active' | 'inactive';
createdAt: string;
}
+159 -164
View File
@@ -1,57 +1,64 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFormik } from 'formik';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { Button } from '../../../components/customs/Button';
import { unitService } from '../services/unit.service';
import { unitSchema } from '../validation/unit.schema';
import type { UnitStatus } from '../types/unit.types';
import { Save } from 'lucide-react';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useUnit } from "../hook/useUnit";
import { unitSchema } from "../validation/unit.schema";
import type { UnitCreateRequest } from "../types/unit.types";
const inputClass = (error?: boolean) =>
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
const textareaClass = `w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 min-h-[100px] resize-y`;
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
const UNIT_TYPES = ["Weight", "Length", "Volume", "Area", "Count", "Temperature", "Time", "Other"];
export default function NewUnit() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createUnit, updateUnit } = useUnit();
const [isActive, setIsActive] = useState(true);
const formik = useFormik({
initialValues: {
name: '',
code: '',
symbol: '',
unitType: '',
conversionFactor: '',
baseUnit: '',
status: 'active' as UnitStatus,
description: '',
name: "",
code: "",
symbol: "",
unitType: "",
conversionFactor: "",
baseUnit: "",
description: "",
},
validationSchema: unitSchema,
onSubmit: async (values, { setSubmitting }) => {
const payload = {
const payload: UnitCreateRequest = {
name: values.name,
code: values.code,
symbol: values.symbol,
unitType: values.unitType,
conversionFactor: values.conversionFactor ? parseFloat(values.conversionFactor) : undefined,
baseUnit: values.baseUnit || undefined,
status: values.status,
description: values.description,
description: values.description || undefined,
status: isActive ? "active" : "inactive",
};
try {
if (isEdit && id) {
await unitService.update(id, payload);
await updateUnit(id, payload);
} else {
await unitService.create(payload);
await createUnit(payload);
}
navigate('/units');
navigate("/units");
} catch {
// handled inside service
// handled by hook
} finally {
setSubmitting(false);
}
@@ -59,23 +66,11 @@ export default function NewUnit() {
});
useEffect(() => {
if (!id) return;
unitService.getById(id).then((u) => {
if (u) {
formik.setValues({
name: u.name,
code: u.code,
symbol: u.symbol,
unitType: u.unitType,
conversionFactor: u.conversionFactor ? String(u.conversionFactor) : '',
baseUnit: u.baseUnit || '',
status: u.status,
description: u.description || '',
});
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
if (isEdit && id) {
// Fetch existing unit for edit mode
// unitService.getById(id).then(...);
}
}, [isEdit, id]);
return (
<PageWrapper>
@@ -85,144 +80,144 @@ export default function NewUnit() {
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/units')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="unit-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Unit' : 'Create Unit'}</Button>
<Button variant="outline" size="md" type="button">Save as Draft</Button>
<Button variant="primary" size="md" type="submit" form="unit-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Unit'}</Button>
</>
}
/>
<form id="unit-form" onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Unit Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Kilogram"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
<div className="max-w-4xl mx-auto">
<form id="unit-form" onSubmit={formik.handleSubmit} className="space-y-10">
{/* Basic Information */}
<div className="bg-white border border-gray-200 rounded-xl p-8">
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
<p className="text-sm text-gray-500">Enter the basic details for this unit</p>
</div>
<div>
<label className={labelClass}>Code *</label>
<input
name="code"
className={inputClass(formik.touched.code && Boolean(formik.errors.code))}
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. kg"
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Unit Name <span className="text-red-500">*</span></label>
<input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., Kilogram"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Symbol *</label>
<input
name="symbol"
className={inputClass(formik.touched.symbol && Boolean(formik.errors.symbol))}
value={formik.values.symbol}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. kg"
/>
{formik.touched.symbol && formik.errors.symbol && <p className={errorClass}>{formik.errors.symbol}</p>}
</div>
<div>
<label className={labelClass}>Internal Code <span className="text-red-500">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., kg"
className={inputClass(formik.touched.code && Boolean(formik.errors.code))}
/>
</div>
<div>
<label className={labelClass}>Type *</label>
<input
name="unitType"
className={inputClass(formik.touched.unitType && Boolean(formik.errors.unitType))}
value={formik.values.unitType}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Weight"
/>
{formik.touched.unitType && formik.errors.unitType && <p className={errorClass}>{formik.errors.unitType}</p>}
</div>
<div>
<label className={labelClass}>Symbol <span className="text-red-500">*</span></label>
<input
name="symbol"
value={formik.values.symbol}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., kg"
className={inputClass(formik.touched.symbol && Boolean(formik.errors.symbol))}
/>
</div>
<div>
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<div>
<label className={labelClass}>Unit Type <span className="text-red-500">*</span></label>
<select
name="unitType"
value={formik.values.unitType}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.unitType && Boolean(formik.errors.unitType))}
>
<option value="">Select Type</option>
{UNIT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
placeholder="Describe the purpose of this unit..."
className={textareaClass}
/>
<p className="text-xs text-gray-500 mt-1">Optional description to help users understand when to use this unit</p>
</div>
<div className="md:col-span-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === 'active'}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === 'inactive'}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
<span className="text-sm font-medium text-gray-700">
Active <span className="text-gray-500 font-normal">(Unchecked units will be saved as inactive)</span>
</span>
</label>
</div>
</div>
</div>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Conversion (Optional)</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Base Unit</label>
<input
name="baseUnit"
className={inputClass()}
value={formik.values.baseUnit}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. kg"
/>
{/* Conversion Configuration */}
<div className="bg-white border border-gray-200 rounded-xl p-8">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Conversion Configuration</h2>
<p className="text-sm text-gray-500 mb-6">Define conversion factors for this unit (optional)</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Conversion Factor</label>
<input
name="conversionFactor"
type="number"
step="0.000001"
value={formik.values.conversionFactor}
onChange={formik.handleChange}
placeholder="e.g., 0.001"
className={inputClass()}
/>
<p className="text-xs text-gray-500 mt-1">How many base units equal one of this unit</p>
</div>
<div>
<label className={labelClass}>Base Unit</label>
<input
name="baseUnit"
value={formik.values.baseUnit}
onChange={formik.handleChange}
placeholder="e.g., kg"
className={inputClass()}
/>
<p className="text-xs text-gray-500 mt-1">The reference unit for conversion</p>
</div>
</div>
<div>
<label className={labelClass}>Conversion Factor</label>
<input
name="conversionFactor"
type="number"
step="0.000001"
className={inputClass(formik.touched.conversionFactor && Boolean(formik.errors.conversionFactor))}
value={formik.values.conversionFactor}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. 0.001"
/>
{formik.touched.conversionFactor && formik.errors.conversionFactor && (
<p className={errorClass}>{formik.errors.conversionFactor}</p>
)}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<textarea
name="description"
className={inputClass()}
rows={3}
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="Add notes..."
/>
<div className="mt-6 p-4 bg-blue-50 border border-blue-100 rounded-lg text-sm">
<strong>Note:</strong> Conversion factor represents how many base units equal one of this unit.
For example, 1 gram = 0.001 kilograms.
</div>
</div>
</div>
</form>
</form>
</div>
</PageWrapper>
);
}
}
+29 -2
View File
@@ -13,6 +13,7 @@ import { Pagination } from "../../../components/customs/Pagination";
import { useUnit } from "../hook/useUnit";
import type { Unit, UnitStatus } from "../types/unit.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
export default function UnitList() {
const { canImport, canExport } = usePermissions("masters.units");
@@ -23,11 +24,26 @@ export default function UnitList() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchUnits();
}, [fetchUnits]);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteUnit(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Keep modal open on error
} finally {
setIsDeleting(false);
}
};
useEffect(() => {
setCurrentPage(1);
}, [query]);
@@ -119,8 +135,9 @@ export default function UnitList() {
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={() => {
if (confirm(`Delete ${row.name}?`)) deleteUnit(row.id);
onClick={(e) => {
e?.stopPropagation();
setDeleteModal({ isOpen: true, id: row.id, name: row.name });
}}
/>
</div>
@@ -136,6 +153,16 @@ export default function UnitList() {
onPageSizeChange={setPageSize}
/>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Unit"
description="Are you sure you want to delete this unit? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
+30 -3
View File
@@ -10,16 +10,32 @@ import { Can } from "../../../components/customs/Can";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { VariantStatsCards } from "../components/VariantStatsCards";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
export default function VariantList() {
const { variants, fetchVariants, deleteVariant } = useVariant();
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchVariants();
}, [fetchVariants]);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteVariant(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Keep modal open on error
} finally {
setIsDeleting(false);
}
};
const activeCount = variants.filter(v => v.status === 'active').length;
const draftCount = variants.filter(v => v.status === 'draft').length;
const disabledCount = variants.filter(v => v.status === 'disabled').length;
@@ -84,14 +100,25 @@ export default function VariantList() {
searchPlaceholder="Search variants by SKU, name, or product..."
actions={(row) => (
<div className="flex items-center gap-2 justify-end">
<Button variant="ghost" size="sm" icon={<Edit className="w-4 h-4" />} onClick={() => navigate(`${row.id}/edit`)} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => {
if (confirm('Delete this variant?')) deleteVariant(row.id);
<Button variant="ghost" size="sm" icon={<Edit className="w-4 h-4" />} onClick={(e) => { e?.stopPropagation(); navigate(`${row.id}/edit`); }} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={(e) => {
e?.stopPropagation();
setDeleteModal({ isOpen: true, id: row.id, name: row.name });
}} />
</div>
)}
/>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Variant"
description="Are you sure you want to delete this variant? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
@@ -1,7 +1,7 @@
import { Download, Filter, Activity, Users, Clock, CheckCircle } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { KPI, KPIGrid } from "../../../components/customs/KPI";
import { DataTable } from "../../../components/customs/DataTable";
import { WorkflowStatsCard } from "./WorkflowStatsCard";
export const AuditUsageTab = () => {
const auditLogs = [
@@ -27,12 +27,12 @@ export const AuditUsageTab = () => {
return (
<div className="space-y-6 pb-6">
<KPIGrid>
<KPI value="1,245" label="Items Processed" icon={CheckCircle} variant="green" trendValue="All time" />
<KPI value="34" label="Active Users" icon={Users} variant="blue" trendValue="In last 30 days" />
<KPI value="1.2d" label="Avg. Time to Complete" icon={Clock} variant="purple" trendValue="-0.3d vs last month" trendDirection="up" />
<KPI value="98%" label="SLA Compliance" icon={Activity} variant="green" trendValue="Excellent" />
</KPIGrid>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<WorkflowStatsCard title="Items Processed" value="1,245" subtitle="All time" icon={<CheckCircle className="w-5 h-5" />} color="green" />
<WorkflowStatsCard title="Active Users" value="34" subtitle="In last 30 days" icon={<Users className="w-5 h-5" />} color="blue" />
<WorkflowStatsCard title="Avg. Time to Complete" value="1.2d" subtitle="-0.3d vs last month" icon={<Clock className="w-5 h-5" />} color="purple" />
<WorkflowStatsCard title="SLA Compliance" value="98%" subtitle="Excellent" icon={<Activity className="w-5 h-5" />} color="green" />
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
<div className="px-6 py-5 border-b border-gray-100 flex justify-between items-center bg-white">
@@ -1,9 +1,10 @@
import { useFormikContext } from "formik";
const inputClass = 'w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export const GeneralInfoTab = () => {
const formik = useFormikContext<any>();
return (
<div className="space-y-6 pb-6">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
@@ -11,24 +12,24 @@ export const GeneralInfoTab = () => {
<div className="space-y-5">
<div>
<label className={labelClass}>Workflow Name *</label>
<input className={inputClass} placeholder="e.g. Electronics Approval Workflow" />
<input name="name" value={formik.values.name} onChange={formik.handleChange} className={inputClass} placeholder="e.g. Electronics Approval Workflow" />
</div>
<div>
<label className={labelClass}>Workflow Code *</label>
<input className={inputClass} placeholder="e.g. electronics_approval" />
<input name="code" value={formik.values.code} onChange={formik.handleChange} className={inputClass} placeholder="e.g. electronics_approval" />
<p className="text-xs text-gray-400 mt-1">Unique identifier. Lowercase, underscores only.</p>
</div>
<div>
<label className={labelClass}>Description</label>
<textarea className={inputClass} rows={3} placeholder="Describe the purpose and scope of this workflow..." />
<textarea name="description" value={formik.values.description} onChange={formik.handleChange} className={inputClass} rows={3} placeholder="Describe the purpose and scope of this workflow..." />
</div>
<div>
<label className={labelClass}>Workflow Category</label>
<select className={inputClass}>
<option>Standard Approval</option>
<option>Publishing</option>
<option>Compliance</option>
<option>Fast Track</option>
<select name="category" value={formik.values.category} onChange={formik.handleChange} className={inputClass}>
<option value="Standard Approval">Standard Approval</option>
<option value="Publishing">Publishing</option>
<option value="Compliance">Compliance</option>
<option value="Fast Track">Fast Track</option>
</select>
</div>
</div>
@@ -1,6 +1,7 @@
import { Mail, MessageSquare, Bell, Plus, Settings } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
const MOCK_EVENTS = [
{ id: "1", event: "Workflow Created", email: false, slack: false, inApp: true },
@@ -41,7 +42,7 @@ export const NotificationsTab = () => {
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full">Active</span>
<StatusBadge status="active" />
<button className="text-gray-400 hover:text-gray-700"><Settings className="w-4 h-4" /></button>
</div>
</div>
@@ -57,7 +58,7 @@ export const NotificationsTab = () => {
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-gray-500 bg-gray-100 px-2 py-1 rounded-full">Not Configured</span>
<StatusBadge status="neutral" label="Not Configured" />
<button className="text-gray-400 hover:text-gray-700"><Settings className="w-4 h-4" /></button>
</div>
</div>
@@ -73,7 +74,7 @@ export const NotificationsTab = () => {
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full">Active</span>
<StatusBadge status="active" />
<button className="text-gray-400 hover:text-gray-700"><Settings className="w-4 h-4" /></button>
</div>
</div>
@@ -1,6 +1,7 @@
import { useState } from "react";
import { MOCK_ROLES, MOCK_PERMISSIONS, STAGE_COLORS } from "../data/mockData";
import { MOCK_ROLES, MOCK_PERMISSIONS } from "../data/mockData";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
export const RolePermissionsTab = () => {
const [permissions, setPermissions] = useState(MOCK_PERMISSIONS);
@@ -70,11 +71,7 @@ export const RolePermissionsTab = () => {
return (
<div key={stageBlock.stage} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3 bg-white">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[stageBlock.stage]?.bg ?? 'bg-gray-50'} ${STAGE_COLORS[stageBlock.stage]?.text ?? 'text-gray-700'} ${STAGE_COLORS[stageBlock.stage]?.border ?? 'border-gray-200'}`}
>
{stageBlock.stage}
</span>
<StatusBadge status={stageBlock.stage.toLowerCase() as any} label={stageBlock.stage} showDot={false} />
<h3 className="text-sm font-semibold text-gray-700">Stage Permissions</h3>
</div>
@@ -2,7 +2,8 @@
import { Plus, Check, Trash2 } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { MOCK_TRANSITIONS, STAGE_COLORS } from "../data/mockData";
import { Badge, StatusBadge } from "../../../components/customs/StatusBadge";
import { MOCK_TRANSITIONS } from "../data/mockData";
export const StageTransitionsTab = () => {
const columns = [
@@ -10,9 +11,7 @@ export const StageTransitionsTab = () => {
key: "current",
label: "CURRENT STAGE",
render: (val: string) => (
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[val].bg} ${STAGE_COLORS[val].text} ${STAGE_COLORS[val].border}`}>
{val}
</span>
<StatusBadge status={val.toLowerCase() as any} label={val} showDot={false} />
),
},
{
@@ -21,9 +20,7 @@ export const StageTransitionsTab = () => {
render: (val: string) => (
<div className="flex items-center gap-2">
<span className="text-gray-400"></span>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[val].bg} ${STAGE_COLORS[val].text} ${STAGE_COLORS[val].border}`}>
{val}
</span>
<StatusBadge status={val.toLowerCase() as any} label={val} showDot={false} />
</div>
),
},
@@ -36,9 +33,9 @@ export const StageTransitionsTab = () => {
key: "allowed",
label: "ALLOWED",
render: () => (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-emerald-700 bg-emerald-50 border border-emerald-100">
<Badge variant="success" className="gap-1">
<Check className="w-3 h-3" /> Allowed
</span>
</Badge>
),
},
];
@@ -1,6 +1,7 @@
import { Plus, GripVertical, Edit2, Trash2, ArrowLeft } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { MOCK_STAGES, STAGE_COLORS } from "../data/mockData";
export const WorkflowStagesTab = () => {
@@ -37,7 +38,7 @@ export const WorkflowStagesTab = () => {
<div className="flex items-center gap-1 text-xs text-gray-500 mb-3">
<span className="opacity-70">🕒</span> SLA: {stage.sla}
</div>
<span className="text-xs font-semibold text-emerald-600">Active</span>
<StatusBadge status={stage.status.toLowerCase() as any} />
</div>
</div>
{index < MOCK_STAGES.length - 1 && (
+22 -5
View File
@@ -4,6 +4,8 @@ import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Save, Check } from "lucide-react";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { FormikProvider, useFormik } from "formik";
import { workflowSchema } from "../validation/workflow.schema";
import { GeneralInfoTab } from "../components/GeneralInfoTab";
import { WorkflowStagesTab } from "../components/WorkflowStagesTab";
@@ -19,6 +21,19 @@ export default function NewWorkflow() {
const isEdit = Boolean(id);
const [activeTab, setActiveTab] = useState("General Information");
const formik = useFormik({
initialValues: {
name: '',
code: '',
description: '',
category: 'Standard Approval'
},
validationSchema: workflowSchema,
onSubmit: async (values) => {
console.log('Workflow submitted', values);
}
});
const tabs = ["General Information", "Workflow Stages", "Stage Transitions", "Role Permissions", "Notifications", "Audit & Usage"];
return (
@@ -32,8 +47,8 @@ export default function NewWorkflow() {
<option>Draft</option>
</select>
<Button variant="outline" onClick={() => navigate('..')}>Cancel</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white" icon={<Save className="w-4 h-4" />}>Save</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white" icon={<Check className="w-4 h-4" />}>Publish</Button>
<Button type="submit" form="workflow-form" className="bg-purple-600 hover:bg-purple-700 text-white" icon={<Save className="w-4 h-4" />}>Save</Button>
<Button type="submit" form="workflow-form" className="bg-emerald-600 hover:bg-emerald-700 text-white" icon={<Check className="w-4 h-4" />}>Publish</Button>
</>
}
/>
@@ -55,8 +70,9 @@ export default function NewWorkflow() {
))}
</div>
<div className="flex gap-6 h-[calc(100vh-220px)]">
{/* Main Content Area */}
<FormikProvider value={formik}>
<form id="workflow-form" onSubmit={formik.handleSubmit} className="flex gap-6 h-[calc(100vh-220px)]">
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto pr-2">
{activeTab === "General Information" && <GeneralInfoTab />}
{activeTab === "Workflow Stages" && <WorkflowStagesTab />}
@@ -68,7 +84,8 @@ export default function NewWorkflow() {
{/* Right Sidebar - Workflow Overview */}
<WorkflowOverview />
</div>
</form>
</FormikProvider>
</PageWrapper>
);
}