Update UI
This commit is contained in:
parent
53e2f33064
commit
25cfdd5538
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <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,
|
||||
};
|
||||
// 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 có thông báo</p>
|
||||
<Text c="dimmed" size="sm">
|
||||
Chưa có 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 có dữ liệu.</p>
|
||||
<Text c="dimmed" size="sm">
|
||||
Chưa có 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 có 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 có 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,370 +1,13 @@
|
|||
/*
|
||||
* Giao diện dùng Mantine (component + theme). File này chỉ giữ reset tối thiểu
|
||||
* và màu nền dashboard. KHÔNG thêm rule cho thẻ thuần (button/input…) vì 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue