Update UI

This commit is contained in:
andrew.ng 2026-07-03 17:15:29 +07:00
parent 53e2f33064
commit 25cfdd5538
17 changed files with 1201 additions and 651 deletions

View File

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

View File

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

View File

@ -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);
// Np d liu khi đã đăng nhp.
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 <LoginForm />;
}
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,
};
// Sn phm to tay thuc tab Manual.
// Sn phm to tay thuc 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 sn phm đang sa (có xác nhn) ri 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 nhiu sn phm 1 lưt; báo kết qu bng 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.`,
});
});
}
// Gi AI gi ý li giá; cp nht aiPrice hin th và đin giá đ xut 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 (
<div className="app-shell">
<header className="topbar">
<div className="topbar-left">
<span className="user-pill">Hi, {displayName}</span>
</div>
<Box p="md">
<Stack gap="md">
<Group
justify="space-between"
bg="white"
p="md"
style={{
borderRadius: 'var(--mantine-radius-lg)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<Badge size="lg" variant="light" radius="xl">
Hi, {displayName}
</Badge>
<div className="page-title">Listing - Suggest Price</div>
<Title order={3}>Listing - Suggest Price</Title>
<div className="topbar-actions">
<button type="button" onClick={() => dispatch(startAdd())}>
+ Add Product
</button>
<button type="button" className="secondary" onClick={() => dispatch(logout())}>
Logout
</button>
</div>
</header>
<Group gap="sm">
<Button onClick={() => dispatch(startAdd())}>+ Add Product</Button>
<Button variant="default" onClick={() => dispatch(logout())}>
Logout
</Button>
</Group>
</Group>
<Tabs tabs={tabs} active={activeTab} onChange={(key) => dispatch(setActiveTab(key))} />
<FilterBar />
<main className="tab-content">
{activeTab === 'erp' && (
<SimpleGrid cols={{ base: 1, md: 2, xl: 4 }} spacing="md">
<ProductTablePanel
title="Product ERP"
badge="type = ERP"
bucket={erp}
onSelect={(product) => dispatch(startEdit(product))}
onPageChange={(page) => dispatch(fetchProducts({ type: 'ERP', page }))}
/>
)}
{activeTab === 'manual' && (
<ProductTablePanel
title="Product Manual"
badge="type = MANUAL"
bucket={manual}
onSelect={(product) => dispatch(startEdit(product))}
onPageChange={(page) => dispatch(fetchProducts({ type: 'MANUAL', page }))}
/>
)}
{activeTab === 'form' && (
<ProductFormPanel
formMode={editingId ? 'edit' : 'add'}
form={form}
saving={saving}
deleting={deleting}
suggesting={suggesting}
importing={importing}
error={saveError}
suggestError={suggestError}
onChange={handleChange}
onSubmit={handleSubmit}
onClear={() => dispatch(resetForm())}
onDelete={handleDelete}
onSuggest={handleSuggest}
onImport={handleImport}
/>
)}
{activeTab === 'feed' && (
<FeedPanel
items={notifications.items}
loading={notifications.loading}
@ -129,8 +180,8 @@ export default function App() {
onMarkRead={(id) => dispatch(markNotificationRead(id))}
onMarkAllRead={() => dispatch(markAllNotificationsRead())}
/>
)}
</main>
</div>
</SimpleGrid>
</Stack>
</Box>
);
}

View File

@ -1,15 +1,25 @@
import { Badge, Card, Group, Text } from '@mantine/core';
export default function AiResult({ ai }) {
if (!ai) return null;
return (
<div className="ai-card">
<div>
<span className="ai-price">${ai.suggestedPrice}</span>
{ai._mock && <span className="mock-badge">MOCK</span>}
</div>
<div className="ai-range">
<Card withBorder radius="lg" padding="md">
<Group gap="sm" align="center">
<Text fw={700} fz={28} c="blue">
${ai.suggestedPrice}
</Text>
{ai._mock && (
<Badge color="gray" variant="light">
MOCK
</Badge>
)}
</Group>
<Text size="sm" c="dimmed" mt={4}>
Khoảng đề xuất: ${ai.priceRange?.min} ${ai.priceRange?.max}
</div>
<div className="ai-reasoning">{ai.reasoning}</div>
</div>
</Text>
<Text size="sm" mt="sm">
{ai.reasoning}
</Text>
</Card>
);
}

View File

@ -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 (
<section className="panel">
<div className="panel-header">
<h2>New Feed</h2>
<div className="panel-header-actions">
<span className="panel-badge">{unread} chưa đọc</span>
<Card
withBorder
shadow="sm"
radius="lg"
padding="md"
h="76vh"
style={{ display: 'flex', flexDirection: 'column' }}
>
<Group justify="space-between" mb="sm">
<Title order={4}>New Feed</Title>
<Group gap="xs">
<Badge variant="light" radius="xl">
{unread} chưa đọc
</Badge>
{unread > 0 && (
<button type="button" className="secondary" onClick={onMarkAllRead}>
<Button variant="default" size="xs" onClick={onMarkAllRead}>
Đọc tất cả
</button>
</Button>
)}
</div>
</div>
</Group>
</Group>
{loading && <p className="panel-hint">Đang tải</p>}
{error && <p className="panel-error">{error}</p>}
{loading && (
<Text c="dimmed" size="sm">
Đang tải
</Text>
)}
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
{!loading && !error && (
<div className="feed-list">
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
{items.length === 0 ? (
<p className="panel-hint">Chưa thông báo</p>
<Text c="dimmed" size="sm">
Chưa thông báo
</Text>
) : (
items.map((entry) => (
<div
key={entry.id}
className={`feed-item ${entry.type}${entry.isRead ? ' read' : ''}`}
onClick={() => !entry.isRead && onMarkRead(entry.id)}
>
<span className="feed-dot" />
<div className="feed-body">
<strong>{entry.title}</strong>
{entry.message && <span>{entry.message}</span>}
</div>
</div>
))
<Stack gap="xs">
{items.map((entry) => (
<UnstyledButton
key={entry.id}
onClick={() => !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',
}}
>
<span
aria-hidden
style={{
flex: '0 0 auto',
width: 8,
height: 8,
marginTop: 6,
borderRadius: '50%',
background: entry.isRead
? 'var(--mantine-color-gray-4)'
: 'var(--mantine-color-blue-6)',
}}
/>
<div>
<Text fw={600} size="sm">
{entry.title}
</Text>
{entry.message && (
<Text size="xs" c="dimmed">
{entry.message}
</Text>
)}
</div>
</UnstyledButton>
))}
</Stack>
)}
</div>
</ScrollArea>
)}
</section>
</Card>
);
}

View File

@ -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 cn debounce; condition/warehouse là select nên áp dng 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 (
<Paper shadow="sm" radius="lg" p="sm">
<Group gap="sm" align="center">
<TextInput
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder="Search SKU…"
leftSection={<span aria-hidden>🔍</span>}
style={{ flex: 1, minWidth: 220, maxWidth: 400 }}
/>
<Select
placeholder="Condition"
value={filters.condition || null}
onChange={(value) => handleSelect('condition', value)}
data={CONDITIONS}
clearable
w={160}
/>
<Select
placeholder="Warehouse"
value={filters.warehouse || null}
onChange={(value) => handleSelect('warehouse', value)}
data={WAREHOUSES}
clearable
w={160}
/>
{hasFilter && (
<Button variant="default" onClick={handleClear}>
Clear Filters
</Button>
)}
</Group>
</Paper>
);
}

View File

@ -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 (
<div className="login-shell">
<form className="login-card" onSubmit={handleSubmit}>
<h1>Listing - Suggest Price</h1>
<p className="login-sub">Đăng nhập bằng tài khoản ERP</p>
<Center mih="100vh" p="md">
<Paper shadow="md" radius="lg" p="xl" w={380} maw="100%">
<form onSubmit={handleSubmit}>
<Stack gap="md">
<div>
<Title order={2}>Listing - Suggest Price</Title>
<Text c="dimmed" size="sm">
Đăng nhập bằng tài khoản ERP
</Text>
</div>
{error && <p className="panel-error">{error}</p>}
{error && (
<Alert color="red" variant="light">
{error}
</Alert>
)}
<label>
Email
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="you@company.com" required />
</label>
<label>
Mật khẩu
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<button type="submit" disabled={loading || !username.trim()}>
{loading ? 'Đang đăng nhập…' : 'Đăng nhập'}
</button>
</form>
</div>
<TextInput
label="Email"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="you@company.com"
required
/>
<PasswordInput
label="Mật khẩu"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Button type="submit" fullWidth loading={loading} disabled={!username.trim()}>
Đăng nhập
</Button>
</Stack>
</form>
</Paper>
</Center>
);
}

View File

@ -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 (
<div className="chart-card">
<h3>{title}</h3>
<Card withBorder radius="lg" padding="md">
<Title order={4} mb="sm">
{title}
</Title>
{!hasData ? (
<p className="empty">Chưa dữ liệu.</p>
<Text c="dimmed" size="sm">
Chưa dữ liệu.
</Text>
) : (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={merged} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis
tick={{ fontSize: 11 }}
width={56}
tickFormatter={(v) => `$${v}`}
domain={['auto', 'auto']}
/>
<Tooltip formatter={(v) => `$${v}`} />
<Legend />
{lines.map((l) => (
<Line
key={l.key}
type="monotone"
dataKey={l.key}
name={l.name}
stroke={l.color}
dot={false}
connectNulls
strokeWidth={2}
/>
))}
</LineChart>
</ResponsiveContainer>
<LineChart
h={280}
data={merged}
dataKey="date"
series={series}
curveType="monotone"
connectNulls
withDots={false}
valueFormatter={(v) => `$${v}`}
yAxisProps={{ width: 56, domain: ['auto', 'auto'] }}
/>
)}
</div>
</Card>
);
}

View File

@ -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 trc tiếp; bc li thành s kin 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 (
<section className="panel form-panel">
<div className="panel-header">
<h2>{formMode === 'add' ? 'Add Product' : 'Edit Product'}</h2>
<span className="panel-badge">{formMode === 'add' ? 'Manual' : `#${form.sku}`}</span>
</div>
<Card
withBorder
shadow="sm"
radius="lg"
padding="md"
h="76vh"
style={{ display: "flex", flexDirection: "column" }}
>
<Group justify="space-between" mb="sm">
<Title order={4}>{isEdit ? "Edit Product" : "Add Product"}</Title>
<Badge variant="light" radius="xl">
{isEdit ? `#${form.sku}` : "Manual"}
</Badge>
</Group>
{error && <p className="panel-error">{error}</p>}
{error && (
<Text c="red" size="sm" mb="sm">
{error}
</Text>
)}
<form className="product-form" onSubmit={onSubmit}>
<label>
SKU
<input name="sku" value={form.sku} onChange={onChange} placeholder="SKU" required />
</label>
<label>
Condition
<select name="condition" value={form.condition} onChange={onChange}>
{CONDITIONS.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
<label>
Qty
<input name="qty" type="number" min="0" value={form.qty} onChange={onChange} placeholder="Qty" />
</label>
<label>
Price
<input name="price" type="number" min="0" step="0.01" value={form.price} onChange={onChange} placeholder="Price" />
</label>
<label>
WH (Warehouse)
<input name="warehouse" value={form.warehouse} onChange={onChange} placeholder="Warehouse" />
</label>
<form
onSubmit={onSubmit}
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<Stack gap="sm" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
<Flex justify="space-between" gap="sm">
<TextInput
w={"100%"}
label="SKU"
name="sku"
value={form.sku}
onChange={onChange}
placeholder="SKU"
required
/>
<NativeSelect
w={"200px"}
label="Condition"
name="condition"
value={form.condition}
onChange={onChange}
data={CONDITIONS}
/>
</Flex>
<div className="form-actions">
<button type="submit" disabled={saving}>
{saving ? 'Đang lưu…' : formMode === 'add' ? 'Add' : 'Save'}
</button>
<button type="button" className="secondary" onClick={onClear} disabled={saving}>
Clear
</button>
</div>
<Flex justify="space-between" gap="sm" align="flex-end">
<NumberInput
w={120}
label="Qty"
min={0}
value={form.qty}
onChange={handleNumber("qty")}
placeholder="Qty"
/>
<div style={{ flex: 1 }}>
<Group gap="xs" align="flex-end" wrap="nowrap">
<NumberInput
style={{ flex: 1 }}
label="Price"
min={0}
step={0.01}
decimalScale={2}
value={form.price}
onChange={handleNumber("price")}
placeholder="Price"
/>
<Tooltip
label={
isEdit
? "Gọi AI gợi ý lại giá"
: "Lưu sản phẩm trước khi gợi ý giá"
}
>
<Button
variant="light"
onClick={onSuggest}
loading={suggesting}
disabled={!isEdit}
>
Suggest
</Button>
</Tooltip>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{form.aiPrice != null
? `AI gợi ý: $${form.aiPrice}`
: "Chưa có giá AI"}
</Text>
{suggestError && (
<Text size="xs" c="red">
{suggestError}
</Text>
)}
</div>
</Flex>
<NativeSelect
label="WH (Warehouse)"
name="warehouse"
value={form.warehouse}
onChange={onChange}
data={WAREHOUSES}
placeholder="Select Warehouse"
/>
<Textarea
label="Package Contain"
name="packageContain"
value={form.packageContain ?? ""}
onChange={onChange}
placeholder="Package Contain…"
minRows={3}
autosize
/>
<Checkbox
label="No Listing (skip auto suggestion and listing)"
checked={!!form.noListing}
onChange={handleChecked("noListing")}
/>
</Stack>
<Stack gap="sm" mt="sm">
<Group gap="sm">
<Button type="submit" loading={saving}>
{isEdit ? "Save" : "Add"}
</Button>
<Button
type="button"
variant="default"
onClick={onClear}
disabled={saving}
>
Clear
</Button>
</Group>
{!isEdit && (
<>
<Divider label="hoặc" labelPosition="center" />
<FileButton onChange={onImport} accept=".xlsx,.xls,.csv">
{(props) => (
<Button {...props} variant="light" loading={importing}>
Import Excel (nhiều sản phẩm)
</Button>
)}
</FileButton>
<Text size="xs" c="dimmed">
Cột yêu cầu: sku, condition, qty, price
</Text>
</>
)}
{isEdit && (
<>
<Divider />
<Button
type="button"
color="red"
variant="light"
onClick={onDelete}
loading={deleting}
>
Xóa sản phẩm
</Button>
</>
)}
</Stack>
</form>
</section>
</Card>
);
}

View File

@ -1,48 +1,108 @@
export default function ProductTablePanel({ title, badge, bucket, onSelect }) {
const { items, loading, error } = bucket;
import { Badge, Button, Card, Group, Loader, Table, Text, Title } from '@mantine/core';
export default function ProductTablePanel({ title, badge, bucket, onSelect, onPageChange }) {
const { items, meta, loading, error } = bucket;
const currentPage = meta?.currentPage ?? 1;
const lastPage = meta?.lastPage ?? 1;
const total = meta?.total ?? items.length;
return (
<section className="panel">
<div className="panel-header">
<h2>{title}</h2>
<span className="panel-badge">{badge}</span>
</div>
<Card
withBorder
shadow="sm"
radius="lg"
padding="md"
h="76vh"
style={{ display: 'flex', flexDirection: 'column' }}
>
<Group justify="space-between" mb="sm">
<Title order={4}>{title}</Title>
<Badge variant="light" radius="xl">
{badge}
</Badge>
</Group>
{loading && <p className="panel-hint">Đang tải</p>}
{error && <p className="panel-error">{error}</p>}
{loading && <Loader size="sm" />}
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
{!loading && !error && (
<table className="data-table">
<thead>
<tr>
<th>SKU</th>
<th>Condition</th>
<th>Qty</th>
<th>Price</th>
<th>WH</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={5} className="empty-row">
Chưa sản phẩm
</td>
</tr>
) : (
items.map((product) => (
<tr key={product.id} onClick={() => onSelect(product)}>
<td>{product.sku}</td>
<td>{product.condition}</td>
<td>{product.qty}</td>
<td>${product.price}</td>
<td>{product.warehouse || '—'}</td>
</tr>
))
)}
</tbody>
</table>
<>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<Table stickyHeader highlightOnHover striped verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>SKU</Table.Th>
<Table.Th>Condition</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Price</Table.Th>
<Table.Th>WH</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text ta="center" c="dimmed" py="md">
Chưa sản phẩm
</Text>
</Table.Td>
</Table.Tr>
) : (
items.map((product) => (
<Table.Tr
key={product.id}
onClick={() => onSelect(product)}
style={{ cursor: 'pointer' }}
>
<Table.Td>{product.sku}</Table.Td>
<Table.Td>{product.condition}</Table.Td>
<Table.Td>{product.qty}</Table.Td>
<Table.Td>${product.price}</Table.Td>
<Table.Td>{product.warehouse || '—'}</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</div>
{total > 0 && (
<Group
justify="space-between"
mt="sm"
pt="sm"
style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}
>
<Text size="sm" c="dimmed">
Trang {currentPage}/{lastPage} · {total} sản phẩm
</Text>
<Group gap="xs">
<Button
variant="default"
size="xs"
disabled={currentPage <= 1}
onClick={() => onPageChange(currentPage - 1)}
>
Trước
</Button>
<Button
variant="default"
size="xs"
disabled={currentPage >= lastPage}
onClick={() => onPageChange(currentPage + 1)}
>
Sau
</Button>
</Group>
</Group>
)}
</>
)}
</section>
</Card>
);
}

View File

@ -1,3 +1,5 @@
import { Button, Group, NativeSelect, TextInput } from '@mantine/core';
const CONDITIONS = [
{ value: 'NEW', label: 'New' },
{ value: 'REF', label: 'Refurbished' },
@ -7,34 +9,29 @@ const CONDITIONS = [
export default function SuggestForm({ sku, setSku, condition, setCondition, onSubmit, loading }) {
return (
<form
className="form"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<div className="field">
<label htmlFor="sku">SKU</label>
<input
id="sku"
<Group align="flex-end" gap="sm">
<TextInput
label="SKU"
value={sku}
onChange={(e) => setSku(e.target.value)}
placeholder="VD: C9200L-24T-4G-E"
style={{ flex: 1 }}
/>
</div>
<div className="field">
<label htmlFor="condition">Condition</label>
<select id="condition" value={condition} onChange={(e) => setCondition(e.target.value)}>
{CONDITIONS.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<button type="submit" disabled={loading || !sku.trim()}>
{loading ? 'Đang xử lý…' : 'Suggest'}
</button>
<NativeSelect
label="Condition"
value={condition}
onChange={(e) => setCondition(e.target.value)}
data={CONDITIONS}
/>
<Button type="submit" loading={loading} disabled={!sku.trim()}>
Suggest
</Button>
</Group>
</form>
);
}

View File

@ -1,17 +0,0 @@
export default function Tabs({ tabs, active, onChange }) {
return (
<nav className="tabs">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`tab${active === tab.key ? ' active' : ''}`}
onClick={() => onChange(tab.key)}
>
{tab.label}
{tab.badge > 0 && <span className="tab-badge">{tab.badge}</span>}
</button>
))}
</nav>
);
}

View File

@ -1,14 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import '@mantine/core/styles.css';
import '@mantine/charts/styles.css';
import '@mantine/notifications/styles.css';
import './styles.css';
import store from './store/index.js';
import App from './App.jsx';
import './styles.css';
const theme = {
primaryColor: 'blue',
defaultRadius: 'md',
};
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
<MantineProvider theme={theme} defaultColorScheme="light">
<Notifications position="top-right" />
<Provider store={store}>
<App />
</Provider>
</MantineProvider>
</React.StrictMode>
);

View File

@ -1,17 +1,36 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { apiFetch, unwrapList } from '../api/client';
const PER_PAGE = 50;
// type='ERP' -> bucket 'erp'; type='MANUAL' -> bucket 'manual'.
function bucketOf(type) {
return type === 'ERP' ? 'erp' : 'manual';
}
// Lấy danh sách product theo type (ERP | MANUAL). Endpoint /api/products là public.
export const fetchProducts = createAsyncThunk('products/fetch', async (type) => {
const payload = await apiFetch('/products', { auth: false, params: { type, perPage: 200 } });
return { type, items: unwrapList(payload) };
// Lấy danh sách product theo type (ERP | MANUAL) + trang. Endpoint /api/products là public.
// Kèm bộ lọc dùng chung (sku LIKE, condition, warehouse) lấy từ state.
// page bỏ trống -> giữ nguyên trang hiện tại của bucket đó.
export const fetchProducts = createAsyncThunk('products/fetch', async ({ type, page, perPage }, { getState }) => {
const state = getState().products;
const { filters } = state;
const currentPage = page ?? state[bucketOf(type)].meta?.currentPage ?? 1;
const params = { type, page: currentPage, perPage: perPage ?? PER_PAGE };
if (filters.sku) params.sku = filters.sku;
if (filters.condition) params.condition = filters.condition;
if (filters.warehouse) params.warehouse = filters.warehouse;
const payload = await apiFetch('/products', { auth: false, params });
return { type, items: unwrapList(payload), meta: payload?.meta ?? null };
});
// Search/filter -> nạp lại cả 2 danh sách và quay về trang 1.
export const refreshProducts = () => (dispatch) => {
dispatch(fetchProducts({ type: 'ERP', page: 1 }));
dispatch(fetchProducts({ type: 'MANUAL', page: 1 }));
};
// Tạo mới (không id) hoặc cập nhật (có id) — yêu cầu đăng nhập.
export const saveProduct = createAsyncThunk(
'products/save',
@ -21,9 +40,9 @@ export const saveProduct = createAsyncThunk(
? await apiFetch(`/products/${id}`, { method: 'PATCH', body: values })
: await apiFetch('/products', { method: 'POST', body: values });
// Nạp lại 2 danh sách để phản ánh thay đổi.
dispatch(fetchProducts('ERP'));
dispatch(fetchProducts('MANUAL'));
// Nạp lại 2 danh sách (giữ nguyên trang hiện tại) để phản ánh thay đổi.
dispatch(fetchProducts({ type: 'ERP' }));
dispatch(fetchProducts({ type: 'MANUAL' }));
return product;
} catch (error) {
return rejectWithValue(error.message);
@ -31,17 +50,90 @@ export const saveProduct = createAsyncThunk(
}
);
const emptyBucket = () => ({ items: [], loading: false, error: null });
// Xóa 1 product — yêu cầu đăng nhập. Nạp lại danh sách sau khi xóa.
export const deleteProduct = createAsyncThunk(
'products/delete',
async (id, { dispatch, rejectWithValue }) => {
try {
await apiFetch(`/products/${id}`, { method: 'DELETE' });
dispatch(fetchProducts({ type: 'ERP' }));
dispatch(fetchProducts({ type: 'MANUAL' }));
return id;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Gọi AI gợi ý lại giá cho 1 product (POST /pricing/suggest/:id). BE tự cập nhật aiPrice.
// Trả về SuggestResult { suggestion, applied, oldPrice, newPrice, ... }.
export const suggestPrice = createAsyncThunk(
'products/suggest',
async (id, { dispatch, rejectWithValue }) => {
try {
const result = await apiFetch(`/pricing/suggest/${id}`, { method: 'POST' });
// aiPrice / price có thể đã đổi phía server -> nạp lại danh sách.
dispatch(fetchProducts({ type: 'ERP' }));
dispatch(fetchProducts({ type: 'MANUAL' }));
return result;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Import Excel (multipart, field "file") -> upsert nhiều sản phẩm 1 lượt.
// apiFetch luôn set Content-Type JSON nên upload file phải dùng fetch trực tiếp với FormData.
export const importProducts = createAsyncThunk(
'products/import',
async (file, { dispatch, rejectWithValue }) => {
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('token');
const response = await fetch('/api/imports/products', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const message = data?.error || data?.message || `Import thất bại (${response.status})`;
throw new Error(typeof message === 'string' ? message : JSON.stringify(message));
}
// Nạp lại 2 danh sách để thấy sản phẩm vừa import.
dispatch(fetchProducts({ type: 'ERP' }));
dispatch(fetchProducts({ type: 'MANUAL' }));
return data; // ImportSummary { total, created, updated, failed, rows }
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const emptyBucket = () => ({ items: [], meta: null, loading: false, error: null });
const productsSlice = createSlice({
name: 'products',
initialState: {
erp: emptyBucket(),
manual: emptyBucket(),
filters: { sku: '', condition: '', warehouse: '' },
saving: false,
saveError: null,
deleting: false,
suggesting: false,
suggestError: null,
importing: false,
importError: null,
},
reducers: {
setFilters(state, action) {
Object.assign(state.filters, action.payload);
},
clearFilters(state) {
state.filters = { sku: '', condition: '', warehouse: '' };
},
clearSaveError(state) {
state.saveError = null;
},
@ -49,7 +141,7 @@ const productsSlice = createSlice({
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state, action) => {
const bucket = state[bucketOf(action.meta.arg)];
const bucket = state[bucketOf(action.meta.arg.type)];
bucket.loading = true;
bucket.error = null;
})
@ -57,9 +149,10 @@ const productsSlice = createSlice({
const bucket = state[bucketOf(action.payload.type)];
bucket.loading = false;
bucket.items = action.payload.items;
bucket.meta = action.payload.meta;
})
.addCase(fetchProducts.rejected, (state, action) => {
const bucket = state[bucketOf(action.meta.arg)];
const bucket = state[bucketOf(action.meta.arg.type)];
bucket.loading = false;
bucket.error = action.error.message || 'Không tải được danh sách';
})
@ -73,9 +166,42 @@ const productsSlice = createSlice({
.addCase(saveProduct.rejected, (state, action) => {
state.saving = false;
state.saveError = action.payload || 'Lưu sản phẩm thất bại';
})
.addCase(deleteProduct.pending, (state) => {
state.deleting = true;
state.saveError = null;
})
.addCase(deleteProduct.fulfilled, (state) => {
state.deleting = false;
})
.addCase(deleteProduct.rejected, (state, action) => {
state.deleting = false;
state.saveError = action.payload || 'Xóa sản phẩm thất bại';
})
.addCase(suggestPrice.pending, (state) => {
state.suggesting = true;
state.suggestError = null;
})
.addCase(suggestPrice.fulfilled, (state) => {
state.suggesting = false;
})
.addCase(suggestPrice.rejected, (state, action) => {
state.suggesting = false;
state.suggestError = action.payload || 'Gợi ý giá thất bại';
})
.addCase(importProducts.pending, (state) => {
state.importing = true;
state.importError = null;
})
.addCase(importProducts.fulfilled, (state) => {
state.importing = false;
})
.addCase(importProducts.rejected, (state, action) => {
state.importing = false;
state.importError = action.payload || 'Import thất bại';
});
},
});
export const { clearSaveError } = productsSlice.actions;
export const { setFilters, clearFilters, clearSaveError } = productsSlice.actions;
export default productsSlice.reducer;

View File

@ -1,25 +1,25 @@
import { createSlice } from '@reduxjs/toolkit';
// Form chỉ dùng các trường hiển thị theo yêu cầu: sku, condition, qty, price, warehouse.
// Form các trường hiển thị: sku, condition, qty, price, warehouse, packageContain, noListing.
// aiPrice là giá do AI gợi ý (chỉ hiển thị / cập nhật qua nút Suggest, không nhập tay).
export const emptyForm = {
sku: '',
condition: 'NEW',
qty: 0,
price: 0,
warehouse: '',
packageContain: '',
noListing: false,
aiPrice: null,
};
const uiSlice = createSlice({
name: 'ui',
initialState: {
activeTab: 'erp', // erp | manual | form | feed
editingId: null, // null = thêm mới, số = đang sửa
form: { ...emptyForm },
},
reducers: {
setActiveTab(state, action) {
state.activeTab = action.payload;
},
updateForm(state, action) {
const { name, value } = action.payload;
state.form[name] = value;
@ -27,7 +27,6 @@ const uiSlice = createSlice({
startAdd(state) {
state.editingId = null;
state.form = { ...emptyForm };
state.activeTab = 'form';
},
startEdit(state, action) {
const p = action.payload;
@ -38,8 +37,10 @@ const uiSlice = createSlice({
qty: p.qty ?? 0,
price: p.price ?? 0,
warehouse: p.warehouse ?? '',
packageContain: p.packageContain ?? '',
noListing: p.noListing ?? false,
aiPrice: p.aiPrice ?? null,
};
state.activeTab = 'form';
},
resetForm(state) {
state.editingId = null;
@ -48,5 +49,5 @@ const uiSlice = createSlice({
},
});
export const { setActiveTab, updateForm, startAdd, startEdit, resetForm } = uiSlice.actions;
export const { updateForm, startAdd, startEdit, resetForm } = uiSlice.actions;
export default uiSlice.reducer;

View File

@ -1,370 +1,13 @@
/*
* Giao diện dùng Mantine (component + theme). File này chỉ giữ reset tối thiểu
* màu nền dashboard. KHÔNG thêm rule cho thẻ thuần (button/input) CSS
* không-layer sẽ đè lên style layer của Mantine.
*/
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
background: #f3f5f9;
color: #1f2937;
}
button {
border: none;
border-radius: 8px;
padding: 9px 14px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
background: #2563eb;
color: white;
}
button.secondary {
background: #e5e7eb;
color: #111827;
}
.app-shell {
min-height: 100vh;
padding: 20px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
border-radius: 16px;
padding: 16px 20px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
margin-bottom: 20px;
}
.topbar-left,
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.brand {
font-size: 18px;
font-weight: 700;
color: #1d4ed8;
}
.user-pill,
.panel-badge {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 600;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #111827;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
align-items: stretch;
}
.topbar-actions {
gap: 10px;
}
/* --- Tabs --- */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.tab {
background: white;
color: #374151;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
display: inline-flex;
align-items: center;
gap: 8px;
}
.tab.active {
background: #2563eb;
color: white;
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #ef4444;
color: white;
font-size: 11px;
font-weight: 700;
}
.tab-content {
display: block;
}
.tab-content .panel {
height: auto;
min-height: 60vh;
}
.tab-content .form-panel {
max-width: 520px;
}
.panel-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.panel-hint {
color: #6b7280;
font-size: 14px;
}
.panel-error {
color: #b91c1c;
background: #fef2f2;
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
margin: 0 0 12px;
}
.empty-row {
text-align: center;
color: #9ca3af;
padding: 20px 0;
}
.panel {
background: white;
border-radius: 16px;
padding: 16px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
height: 86vh;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.panel-header h2 {
margin: 0;
font-size: 16px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table th,
.data-table td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid #e5e7eb;
}
.data-table tbody tr {
cursor: pointer;
}
.data-table tbody tr:hover {
background: #f8fafc;
}
.form-panel {
min-height: 100%;
}
.product-form {
display: grid;
gap: 12px;
}
.product-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #374151;
}
.product-form input,
.product-form select {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 9px 10px;
font-size: 14px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 6px;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.feed-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
background: #f8fafc;
color: #374151;
}
.feed-item.warning {
background: #fff7ed;
color: #9a2c00;
}
.feed-item.success {
background: #ecfdf3;
color: #166534;
}
.feed-item.info,
.feed-item.news {
background: #eff6ff;
color: #1d4ed8;
}
.feed-item.error {
background: #fef2f2;
color: #b91c1c;
}
.feed-item.read {
opacity: 0.55;
}
.feed-item {
cursor: pointer;
align-items: flex-start;
}
.feed-body {
display: flex;
flex-direction: column;
gap: 2px;
}
.feed-body strong {
font-size: 14px;
}
.feed-body span {
font-size: 13px;
opacity: 0.85;
}
.feed-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
margin-top: 6px;
flex-shrink: 0;
}
/* --- Login --- */
.login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
background: white;
border-radius: 16px;
padding: 28px;
width: 100%;
max-width: 380px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.1);
display: grid;
gap: 14px;
}
.login-card h1 {
margin: 0;
font-size: 20px;
}
.login-sub {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.login-card label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #374151;
}
.login-card input {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
font-size: 14px;
}
@media (max-width: 1200px) {
.dashboard-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.topbar {
flex-direction: column;
gap: 10px;
}
}

View File

@ -1,12 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Cổng backend (AdonisJS) — mặc định 3333 (backend/.env: PORT).
// Override khi cần: VITE_API_TARGET=http://localhost:8386 npm run dev
const apiTarget = process.env.VITE_API_TARGET || 'http://localhost:3333';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8386',
'/api': {
target: apiTarget,
changeOrigin: true,
},
},
},
});