diff --git a/package-lock.json b/package-lock.json index c090abf..3e77f32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7671732..5d37b22 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/customs/StatusBadge.tsx b/src/components/customs/StatusBadge.tsx index 821d339..2b1aaa4 100644 --- a/src/components/customs/StatusBadge.tsx +++ b/src/components/customs/StatusBadge.tsx @@ -14,11 +14,13 @@ export type BadgeVariant = | "info" | "success" | "warning" - | "neutral"; + | "neutral" + | "review" + | "approval"; const BADGE_STYLES: Record = { 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 = { 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 = { 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 = { success: "bg-emerald-500", warning: "bg-orange-500", neutral: "bg-gray-400", + review: "bg-blue-400", + approval: "bg-yellow-400", }; interface StatusBadgeProps { diff --git a/src/components/layouts/Sidebar.tsx b/src/components/layouts/Sidebar.tsx index 7af804e..e09b6d0 100644 --- a/src/components/layouts/Sidebar.tsx +++ b/src/components/layouts/Sidebar.tsx @@ -156,9 +156,9 @@ export function Sidebar() { {/* Footer */}
-
+
-
+
AC
diff --git a/src/components/modals/ConfirmationModal.tsx b/src/components/modals/ConfirmationModal.tsx index e69de29..e7d475d 100644 --- a/src/components/modals/ConfirmationModal.tsx +++ b/src/components/modals/ConfirmationModal.tsx @@ -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 = ({ + isOpen, + title, + description, + itemName, + loading = false, + confirmText = 'Delete', + cancelText = 'Cancel', + onConfirm, + onCancel, + variant = 'danger', +}) => { + const isDanger = variant === 'danger'; + + return ( + !open && onCancel()}> + + + +
+
+
+
+ + {title} + + + {description} + {itemName && ( + + "{itemName}" + + )} + +
+
+
+ + + + + + +
+ +
+
+
+ ); +}; diff --git a/src/features/attributes/pages/AttributeList.tsx b/src/features/attributes/pages/AttributeList.tsx index 05350fc..0dcc04a 100644 --- a/src/features/attributes/pages/AttributeList.tsx +++ b/src/features/attributes/pages/AttributeList.tsx @@ -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() { { e.stopPropagation(); navigate(`/attributes/${row.id}/edit`); }} /> - { e.stopPropagation(); handleDelete(row.id, row.name); }} /> + { e.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }} />
)} />
+ + setDeleteModal({ isOpen: false, id: "", name: "" })} + /> ); diff --git a/src/features/attributes/pages/NewAttribute.tsx b/src/features/attributes/pages/NewAttribute.tsx index 876fc2f..ca0320f 100644 --- a/src/features/attributes/pages/NewAttribute.tsx +++ b/src/features/attributes/pages/NewAttribute.tsx @@ -124,6 +124,7 @@ export default function NewAttribute() { actions={ <> + } @@ -314,11 +315,6 @@ export default function NewAttribute() {
- {/* Buttons */} -
- - -
diff --git a/src/features/brands/pages/BrandList.tsx b/src/features/brands/pages/BrandList.tsx index 8ddf701..53c75e7 100644 --- a/src/features/brands/pages/BrandList.tsx +++ b/src/features/brands/pages/BrandList.tsx @@ -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={} onClick={(e) => { e?.stopPropagation(); - handleDelete(row.id, row.name); + setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }} />
)} />
+ + setDeleteModal({ isOpen: false, id: "", name: "" })} + /> ); diff --git a/src/features/brands/pages/NewBrandForm.tsx b/src/features/brands/pages/NewBrandForm.tsx index 8c67ca6..d30f21e 100644 --- a/src/features/brands/pages/NewBrandForm.tsx +++ b/src/features/brands/pages/NewBrandForm.tsx @@ -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 ( - - - - - - - } - /> -
-
-

Brand Information

-
- - {/* Code */} -
- - - {formik.touched.code && formik.errors.code &&

{formik.errors.code}

} -
+ + + + + + + } + /> + +
+ + {/* Basic Information */} +
+
+

Basic Information

+

Enter the basic details for this brand

+
- {/* Name */} +
- + {formik.touched.name && formik.errors.name &&

{formik.errors.name}

}
- {/* Description */} +
+ + +
+