29/6/2026 v1.1 correction in ui
This commit is contained in:
Generated
+64
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user