From 25cfdd5538be892be667196e415e36a3095af378 Mon Sep 17 00:00:00 2001 From: "andrew.ng" Date: Fri, 3 Jul 2026 17:15:29 +0700 Subject: [PATCH] Update UI --- frontend/package-lock.json | 346 ++++++++++++++++ frontend/package.json | 4 + frontend/src/App.jsx | 143 ++++--- frontend/src/components/AiResult.jsx | 28 +- frontend/src/components/FeedPanel.jsx | 108 +++-- frontend/src/components/FilterBar.jsx | 80 ++++ frontend/src/components/LoginForm.jsx | 54 ++- frontend/src/components/PriceChart.jsx | 64 ++- frontend/src/components/ProductFormPanel.jsx | 267 ++++++++++--- frontend/src/components/ProductTablePanel.jsx | 140 +++++-- frontend/src/components/SuggestForm.jsx | 35 +- frontend/src/components/Tabs.jsx | 17 - frontend/src/main.jsx | 23 +- frontend/src/store/productsSlice.js | 148 ++++++- frontend/src/store/uiSlice.js | 17 +- frontend/src/styles.css | 369 +----------------- frontend/vite.config.js | 9 +- 17 files changed, 1201 insertions(+), 651 deletions(-) create mode 100644 frontend/src/components/FilterBar.jsx delete mode 100644 frontend/src/components/Tabs.jsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ceb3fb1..9002f54 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "suggestprice-web", "version": "0.1.0", "dependencies": { + "@mantine/charts": "^8.3.18", + "@mantine/core": "^8.3.18", + "@mantine/hooks": "^8.3.18", + "@mantine/notifications": "^8.3.18", "@reduxjs/toolkit": "^2.12.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -701,6 +705,59 @@ "node": ">=12" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -751,6 +808,72 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mantine/charts": { + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.3.18.tgz", + "integrity": "sha512-oudif3EUH7Nb9DPm0abAPxpFYDWWjR3k2S5ll0/CcB+pJzlhwaBG19QwpOJaRA6VAvAXDDKOXCO4mi9XCEN78g==", + "license": "MIT", + "peerDependencies": { + "@mantine/core": "8.3.18", + "@mantine/hooks": "8.3.18", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x", + "recharts": ">=2.13.3" + } + }, + "node_modules/@mantine/core": { + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz", + "integrity": "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "clsx": "^2.1.1", + "react-number-format": "^5.4.4", + "react-remove-scroll": "^2.7.1", + "react-textarea-autosize": "8.5.9", + "type-fest": "^4.41.0" + }, + "peerDependencies": { + "@mantine/hooks": "8.3.18", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/hooks": { + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz", + "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/notifications": { + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.18.tgz", + "integrity": "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw==", + "license": "MIT", + "dependencies": { + "@mantine/store": "8.3.18", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "8.3.18", + "@mantine/hooks": "8.3.18", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/store": { + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.18.tgz", + "integrity": "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -1523,6 +1646,12 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1629,6 +1758,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/immer": { "version": "11.1.8", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", @@ -1837,6 +1975,16 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-number-format": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -1870,6 +2018,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -1885,6 +2080,45 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -2029,12 +2263,36 @@ "node": ">=0.10.0" } }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2066,6 +2324,94 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a36b881..f5b4628 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "@mantine/charts": "^8.3.18", + "@mantine/core": "^8.3.18", + "@mantine/hooks": "^8.3.18", + "@mantine/notifications": "^8.3.18", "@reduxjs/toolkit": "^2.12.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9eeced3..84a672c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,31 +1,40 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import Tabs from './components/Tabs.jsx'; +import { Badge, Box, Button, Group, SimpleGrid, Stack, Title } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import FilterBar from './components/FilterBar.jsx'; import ProductTablePanel from './components/ProductTablePanel.jsx'; import ProductFormPanel from './components/ProductFormPanel.jsx'; import FeedPanel from './components/FeedPanel.jsx'; import LoginForm from './components/LoginForm.jsx'; import { logout } from './store/authSlice'; -import { fetchProducts, saveProduct } from './store/productsSlice'; +import { + fetchProducts, + saveProduct, + deleteProduct, + suggestPrice, + importProducts, +} from './store/productsSlice'; import { fetchNotifications, markNotificationRead, markAllNotificationsRead, } from './store/notificationsSlice'; -import { setActiveTab, updateForm, startAdd, startEdit, resetForm } from './store/uiSlice'; +import { updateForm, startAdd, startEdit, resetForm } from './store/uiSlice'; export default function App() { const dispatch = useDispatch(); const { user, token } = useSelector((state) => state.auth); - const { erp, manual, saving, saveError } = useSelector((state) => state.products); + const { erp, manual, saving, saveError, deleting, suggesting, suggestError, importing } = + useSelector((state) => state.products); const notifications = useSelector((state) => state.notifications); - const { activeTab, editingId, form } = useSelector((state) => state.ui); + const { editingId, form } = useSelector((state) => state.ui); // Nạp dữ liệu khi đã đăng nhập. useEffect(() => { if (!token) return; - dispatch(fetchProducts('ERP')); - dispatch(fetchProducts('MANUAL')); + dispatch(fetchProducts({ type: 'ERP', page: 1 })); + dispatch(fetchProducts({ type: 'MANUAL', page: 1 })); dispatch(fetchNotifications()); }, [dispatch, token]); @@ -33,14 +42,6 @@ export default function App() { return ; } - const unread = notifications.items.filter((n) => !n.isRead).length; - const tabs = [ - { key: 'erp', label: 'Product ERP' }, - { key: 'manual', label: 'Manual' }, - { key: 'form', label: editingId ? 'Edit Product' : 'Add Product' }, - { key: 'feed', label: 'New Feed', badge: unread }, - ]; - function handleChange(event) { const { name, value } = event.target; dispatch(updateForm({ name, value })); @@ -54,15 +55,56 @@ export default function App() { qty: Number(form.qty) || 0, price: Number(form.price) || 0, warehouse: form.warehouse || null, + packageContain: form.packageContain || null, + noListing: !!form.noListing, }; - // Sản phẩm tạo tay thuộc tab Manual. + // Sản phẩm tạo tay thuộc panel Manual. if (!editingId) values.type = 'MANUAL'; dispatch(saveProduct({ id: editingId, values })).then((action) => { - if (!action.error) { - dispatch(resetForm()); - dispatch(setActiveTab('manual')); + if (!action.error) dispatch(resetForm()); + }); + } + + // Xóa sản phẩm đang sửa (có xác nhận) rồi reset form. + function handleDelete() { + if (!editingId) return; + if (!window.confirm(`Xóa sản phẩm "${form.sku}"?`)) return; + dispatch(deleteProduct(editingId)).then((action) => { + if (!action.error) dispatch(resetForm()); + }); + } + + // Import Excel nhiều sản phẩm 1 lượt; báo kết quả bằng notification. + function handleImport(file) { + if (!file) return; + dispatch(importProducts(file)).then((action) => { + if (action.error) { + notifications.show({ + color: 'red', + title: 'Import lỗi', + message: action.payload || 'Import thất bại', + }); + return; } + const s = action.payload; + notifications.show({ + color: s.failed > 0 ? 'yellow' : 'green', + title: 'Import Excel xong', + message: `Tổng ${s.total} dòng: ${s.created} mới, ${s.updated} cập nhật, ${s.failed} lỗi.`, + }); + }); + } + + // Gọi AI gợi ý lại giá; cập nhật aiPrice hiển thị và điền giá đề xuất vào ô Price. + function handleSuggest() { + if (!editingId) return; + dispatch(suggestPrice(editingId)).then((action) => { + if (action.error) return; + const suggested = action.payload?.suggestion?.suggestedPrice; + if (suggested == null) return; + dispatch(updateForm({ name: 'aiPrice', value: suggested })); + dispatch(updateForm({ name: 'price', value: suggested })); }); } @@ -70,58 +112,67 @@ export default function App() { [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username; return ( -
-
-
- Hi, {displayName} -
+ + + + + Hi, {displayName} + -
Listing - Suggest Price
+ Listing - Suggest Price -
- - -
-
+ + + + + - dispatch(setActiveTab(key))} /> + -
- {activeTab === 'erp' && ( + dispatch(startEdit(product))} + onPageChange={(page) => dispatch(fetchProducts({ type: 'ERP', page }))} /> - )} - {activeTab === 'manual' && ( dispatch(startEdit(product))} + onPageChange={(page) => dispatch(fetchProducts({ type: 'MANUAL', page }))} /> - )} - {activeTab === 'form' && ( dispatch(resetForm())} + onDelete={handleDelete} + onSuggest={handleSuggest} + onImport={handleImport} /> - )} - {activeTab === 'feed' && ( dispatch(markNotificationRead(id))} onMarkAllRead={() => dispatch(markAllNotificationsRead())} /> - )} -
-
+ + + ); } diff --git a/frontend/src/components/AiResult.jsx b/frontend/src/components/AiResult.jsx index d0b2f13..ed3b18c 100644 --- a/frontend/src/components/AiResult.jsx +++ b/frontend/src/components/AiResult.jsx @@ -1,15 +1,25 @@ +import { Badge, Card, Group, Text } from '@mantine/core'; + export default function AiResult({ ai }) { if (!ai) return null; return ( -
-
- ${ai.suggestedPrice} - {ai._mock && MOCK} -
-
+ + + + ${ai.suggestedPrice} + + {ai._mock && ( + + MOCK + + )} + + Khoảng đề xuất: ${ai.priceRange?.min} – ${ai.priceRange?.max} -
-
{ai.reasoning}
-
+ + + {ai.reasoning} + + ); } diff --git a/frontend/src/components/FeedPanel.jsx b/frontend/src/components/FeedPanel.jsx index 62ab4ab..daa9bb6 100644 --- a/frontend/src/components/FeedPanel.jsx +++ b/frontend/src/components/FeedPanel.jsx @@ -1,44 +1,96 @@ +import { Badge, Button, Card, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from '@mantine/core'; + export default function FeedPanel({ items, loading, error, onMarkRead, onMarkAllRead }) { const unread = items.filter((n) => !n.isRead).length; return ( -
-
-

New Feed

-
- {unread} chưa đọc + + + New Feed + + + {unread} chưa đọc + {unread > 0 && ( - + )} -
-
+ + - {loading &&

Đang tải…

} - {error &&

{error}

} + {loading && ( + + Đang tải… + + )} + {error && ( + + {error} + + )} {!loading && !error && ( -
+ {items.length === 0 ? ( -

Chưa có thông báo

+ + Chưa có thông báo + ) : ( - items.map((entry) => ( -
!entry.isRead && onMarkRead(entry.id)} - > - -
- {entry.title} - {entry.message && {entry.message}} -
-
- )) + + {items.map((entry) => ( + !entry.isRead && onMarkRead(entry.id)} + style={{ + display: 'flex', + gap: 10, + padding: '10px 12px', + borderRadius: 'var(--mantine-radius-md)', + border: '1px solid var(--mantine-color-gray-2)', + background: entry.isRead + ? 'var(--mantine-color-gray-0)' + : 'var(--mantine-color-blue-0)', + opacity: entry.isRead ? 0.7 : 1, + cursor: entry.isRead ? 'default' : 'pointer', + }} + > + +
+ + {entry.title} + + {entry.message && ( + + {entry.message} + + )} +
+
+ ))} +
)} -
+ )} -
+ ); } diff --git a/frontend/src/components/FilterBar.jsx b/frontend/src/components/FilterBar.jsx new file mode 100644 index 0000000..21711cb --- /dev/null +++ b/frontend/src/components/FilterBar.jsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Button, Group, Paper, Select, TextInput } from '@mantine/core'; +import { setFilters, clearFilters, refreshProducts } from '../store/productsSlice'; + +const CONDITIONS = ['NIB', 'NOB', 'USEB', 'NEW', 'USED', 'REF']; +const WAREHOUSES = ['AU', 'US']; +const DEBOUNCE_MS = 300; + +export default function FilterBar() { + const dispatch = useDispatch(); + const filters = useSelector((state) => state.products.filters); + + // Chỉ ô SKU cần debounce; condition/warehouse là select nên áp dụng ngay. + const [sku, setSku] = useState(filters.sku); + const isFirst = useRef(true); + + useEffect(() => { + if (isFirst.current) { + isFirst.current = false; + return; + } + const timer = setTimeout(() => { + dispatch(setFilters({ sku: sku.trim() })); + dispatch(refreshProducts()); + }, DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [sku, dispatch]); + + function handleSelect(name, value) { + dispatch(setFilters({ [name]: value || '' })); + dispatch(refreshProducts()); + } + + function handleClear() { + setSku(''); + dispatch(clearFilters()); + dispatch(refreshProducts()); + } + + const hasFilter = sku || filters.condition || filters.warehouse; + + return ( + + + setSku(e.target.value)} + placeholder="Search SKU…" + leftSection={🔍} + style={{ flex: 1, minWidth: 220, maxWidth: 400 }} + /> + + handleSelect('warehouse', value)} + data={WAREHOUSES} + clearable + w={160} + /> + + {hasFilter && ( + + )} + + + ); +} diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx index b466922..dedb109 100644 --- a/frontend/src/components/LoginForm.jsx +++ b/frontend/src/components/LoginForm.jsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { Alert, Button, Center, Paper, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core'; import { login } from '../store/authSlice'; export default function LoginForm() { @@ -14,25 +15,42 @@ export default function LoginForm() { } return ( -
-
-

Listing - Suggest Price

-

Đăng nhập bằng tài khoản ERP

+
+ + + +
+ Listing - Suggest Price + + Đăng nhập bằng tài khoản ERP + +
- {error &&

{error}

} + {error && ( + + {error} + + )} - - - - -
+ setUsername(e.target.value)} + placeholder="you@company.com" + required + /> + setPassword(e.target.value)} + required + /> + + + + + ); } diff --git a/frontend/src/components/PriceChart.jsx b/frontend/src/components/PriceChart.jsx index 0df23b2..c5e8bfa 100644 --- a/frontend/src/components/PriceChart.jsx +++ b/frontend/src/components/PriceChart.jsx @@ -1,16 +1,8 @@ -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; +import { Card, Text, Title } from '@mantine/core'; +import { LineChart } from '@mantine/charts'; /** - * Gộp nhiều series ({date, price}) theo ngày thành 1 mảng cho Recharts. + * Gộp nhiều series ({date, price}) theo ngày thành 1 mảng cho biểu đồ. * lines: [{ key, name, color, data: [{date, price}] }] */ function mergeByDate(lines) { @@ -27,40 +19,30 @@ function mergeByDate(lines) { export default function PriceChart({ title, lines }) { const hasData = lines.some((l) => (l.data || []).length > 0); const merged = mergeByDate(lines); + const series = lines.map((l) => ({ name: l.key, label: l.name, color: l.color })); return ( -
-

{title}

+ + + {title} + {!hasData ? ( -

Chưa có dữ liệu.

+ + Chưa có dữ liệu. + ) : ( - - - - - `$${v}`} - domain={['auto', 'auto']} - /> - `$${v}`} /> - - {lines.map((l) => ( - - ))} - - + `$${v}`} + yAxisProps={{ width: 56, domain: ['auto', 'auto'] }} + /> )} -
+ ); } diff --git a/frontend/src/components/ProductFormPanel.jsx b/frontend/src/components/ProductFormPanel.jsx index a70b45e..d7fc2d6 100644 --- a/frontend/src/components/ProductFormPanel.jsx +++ b/frontend/src/components/ProductFormPanel.jsx @@ -1,56 +1,231 @@ +import { + Badge, + Button, + Card, + Checkbox, + Divider, + FileButton, + Flex, + Group, + NativeSelect, + NumberInput, + Stack, + Text, + Textarea, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; + const CONDITIONS = [ - { value: 'NEW', label: 'New' }, - { value: 'REF', label: 'Refurbished' }, - { value: 'USED', label: 'Used' }, + { value: "NEW", label: "New" }, + { value: "USED", label: "Used" }, ]; -export default function ProductFormPanel({ formMode, form, saving, error, onChange, onSubmit, onClear }) { +const WAREHOUSES = [ + { value: "AU", label: "AU" }, + { value: "US", label: "US" }, +]; + +export default function ProductFormPanel({ + formMode, + form, + saving, + deleting, + suggesting, + importing, + error, + suggestError, + onChange, + onSubmit, + onClear, + onDelete, + onSuggest, + onImport, +}) { + const isEdit = formMode === "edit"; + // NumberInput / Checkbox trả về value trực tiếp; bọc lại thành sự kiện giả để dùng chung handleChange. + const handleNumber = (name) => (value) => + onChange({ target: { name, value } }); + const handleChecked = (name) => (event) => + onChange({ target: { name, value: event.currentTarget.checked } }); + return ( -
-
-

{formMode === 'add' ? 'Add Product' : 'Edit Product'}

- {formMode === 'add' ? 'Manual' : `#${form.sku}`} -
+ + + {isEdit ? "Edit Product" : "Add Product"} + + {isEdit ? `#${form.sku}` : "Manual"} + + - {error &&

{error}

} + {error && ( + + {error} + + )} -
- - - - - + + + + + + -
- - -
+ + +
+ + + + + + + + {form.aiPrice != null + ? `AI gợi ý: $${form.aiPrice}` + : "Chưa có giá AI"} + + {suggestError && ( + + {suggestError} + + )} +
+
+ + + +