Добавлены новые зависимости для работы с графиками и статистикой: интегрирован пакет recharts для визуализации данных. Обновлены компоненты бизнес-демо и сайдбара, добавлены новые функции для отображения информации о поставках и статистике. Улучшена структура кода и взаимодействие с пользователем. Обновлены GraphQL резолверы для получения статистики Wildberries.
This commit is contained in:
338
package-lock.json
generated
338
package-lock.json
generated
@ -49,6 +49,7 @@
|
||||
"react-dom": "19.1.0",
|
||||
"react-imask": "^7.6.1",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
@ -1589,6 +1590,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime-corejs3": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.0.tgz",
|
||||
@ -4659,6 +4669,69 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@ -6141,9 +6214,129 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@ -6222,6 +6415,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@ -6321,6 +6520,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@ -7022,6 +7231,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
@ -7071,6 +7286,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
@ -7766,6 +7990,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@ -8638,6 +8871,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
@ -9645,6 +9884,21 @@
|
||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"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",
|
||||
@ -9667,6 +9921,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@ -10488,6 +10796,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"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/tinyglobby": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
@ -10918,6 +11232,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
@ -50,6 +50,7 @@
|
||||
"react-dom": "19.1.0",
|
||||
"react-imask": "^7.6.1",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
|
12
src/app/seller-statistics/page.tsx
Normal file
12
src/app/seller-statistics/page.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { AuthGuard } from '@/components/auth-guard'
|
||||
import { SellerStatisticsDashboard } from '@/components/seller-statistics/seller-statistics-dashboard'
|
||||
|
||||
export default function SellerStatisticsPage() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SellerStatisticsDashboard />
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
@ -7,6 +7,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Calendar,
|
||||
Check,
|
||||
@ -29,12 +36,20 @@ import {
|
||||
Briefcase,
|
||||
Eye,
|
||||
Plus,
|
||||
Minus
|
||||
Minus,
|
||||
Store,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Hash,
|
||||
Package2,
|
||||
Truck
|
||||
} from 'lucide-react'
|
||||
|
||||
export function BusinessDemo() {
|
||||
const [selectedProduct] = useState(null)
|
||||
const [cartQuantity, setCartQuantity] = useState(1)
|
||||
const [expandedSeller, setExpandedSeller] = useState(false)
|
||||
|
||||
// Данные для демонстрации
|
||||
const scheduleData = Array.from({ length: 30 }, (_, i) => ({
|
||||
@ -92,6 +107,39 @@ export function BusinessDemo() {
|
||||
}
|
||||
]
|
||||
|
||||
// Данные для поставки фулфилмента
|
||||
const fulfillmentSupply = {
|
||||
id: "1",
|
||||
supplyNumber: "ФФ-2024-001",
|
||||
supplyDate: "2024-01-15",
|
||||
seller: {
|
||||
id: "seller1",
|
||||
name: "TechStore LLC",
|
||||
storeName: "ТехноМагазин",
|
||||
managerName: "Иванов Иван Иванович",
|
||||
phone: "+7 (495) 123-45-67",
|
||||
email: "contact@techstore.ru",
|
||||
inn: "7701234567",
|
||||
},
|
||||
itemsQuantity: 150,
|
||||
cargoPlaces: 5,
|
||||
volume: 12.5,
|
||||
responsibleEmployeeId: "emp1",
|
||||
logisticsPartnerId: "log1",
|
||||
status: "planned",
|
||||
totalValue: 2500000,
|
||||
}
|
||||
|
||||
const employees = [
|
||||
{ id: "emp1", firstName: "Иван", lastName: "Петров", position: "Менеджер склада" },
|
||||
{ id: "emp2", firstName: "Мария", lastName: "Сидорова", position: "Логист" }
|
||||
]
|
||||
|
||||
const logisticsPartners = [
|
||||
{ id: "log1", name: "ТК Энергия", fullName: "ООО ТК Энергия" },
|
||||
{ id: "log2", name: "СДЭК", fullName: "ООО СДЭК" }
|
||||
]
|
||||
|
||||
const wholesalers = [
|
||||
{
|
||||
id: '1',
|
||||
@ -165,6 +213,52 @@ export function BusinessDemo() {
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
planned: {
|
||||
color: "text-blue-300 border-blue-400/30",
|
||||
label: "Запланировано",
|
||||
},
|
||||
"in-transit": {
|
||||
color: "text-yellow-300 border-yellow-400/30",
|
||||
label: "В пути",
|
||||
},
|
||||
delivered: {
|
||||
color: "text-green-300 border-green-400/30",
|
||||
label: "Доставлено",
|
||||
},
|
||||
"in-processing": {
|
||||
color: "text-purple-300 border-purple-400/30",
|
||||
label: "Обрабатывается",
|
||||
},
|
||||
}
|
||||
|
||||
const config =
|
||||
statusConfig[status as keyof typeof statusConfig] || statusConfig.planned
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={`glass-secondary ${config.color}`}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Табель рабочего времени */}
|
||||
@ -385,6 +479,238 @@ export function BusinessDemo() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Карточка поставки фулфилмента */}
|
||||
<Card className="glass-card border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Карточка поставки фулфилмента</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Card className="glass-card p-4 hover:bg-white/10 transition-colors">
|
||||
<div className="space-y-3">
|
||||
{/* Компактный блок с названием магазина */}
|
||||
<div className="flex items-center justify-between bg-white/5 rounded-lg p-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 bg-green-500/20 rounded">
|
||||
<Store className="h-3 w-3 text-green-400" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium text-sm">
|
||||
{fulfillmentSupply.seller.storeName}
|
||||
</span>
|
||||
<span className="text-white/60 text-xs"> • </span>
|
||||
<span className="text-white/80 text-xs">
|
||||
{fulfillmentSupply.seller.managerName}
|
||||
</span>
|
||||
<span className="text-white/60 text-xs"> • </span>
|
||||
<span className="text-white/80 text-xs">
|
||||
{fulfillmentSupply.seller.phone}
|
||||
</span>
|
||||
<span className="text-white/60 text-xs"> • </span>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href={`https://t.me/${fulfillmentSupply.seller.phone.replace(
|
||||
/[^\d]/g,
|
||||
""
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 bg-blue-500/20 rounded hover:bg-blue-500/30 transition-colors"
|
||||
title="Написать в Telegram"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3 text-blue-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={`https://wa.me/${fulfillmentSupply.seller.phone.replace(
|
||||
/[^\d]/g,
|
||||
""
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 bg-green-500/20 rounded hover:bg-green-500/30 transition-colors"
|
||||
title="Написать в WhatsApp"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3 text-green-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandedSeller(!expandedSeller)}
|
||||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
{expandedSeller ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Скрытая детальная информация о магазине */}
|
||||
{expandedSeller && (
|
||||
<div className="bg-white/5 rounded-lg p-3 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">
|
||||
Юридическое название
|
||||
</p>
|
||||
<p className="text-white text-sm">
|
||||
{fulfillmentSupply.seller.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">ИНН</p>
|
||||
<p className="text-white text-sm font-mono">
|
||||
{fulfillmentSupply.seller.inn}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">Email</p>
|
||||
<p className="text-white text-sm">
|
||||
{fulfillmentSupply.seller.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Единый блок со всеми параметрами в одной строке */}
|
||||
<div className="bg-white/5 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-9 gap-4 items-center">
|
||||
{/* Номер поставки */}
|
||||
<div className="text-center">
|
||||
<p className="text-white/60 text-xs mb-1">Номер</p>
|
||||
<p className="text-white font-semibold text-sm">
|
||||
{fulfillmentSupply.supplyNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Дата поставки */}
|
||||
<div className="text-center">
|
||||
<p className="text-white/60 text-xs mb-1">Дата</p>
|
||||
<p className="text-white font-semibold text-sm">
|
||||
{formatDate(fulfillmentSupply.supplyDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Количество товаров */}
|
||||
<div className="text-center">
|
||||
<p className="text-white/60 text-xs mb-1">Товаров</p>
|
||||
<p className="text-white font-semibold text-sm">
|
||||
{fulfillmentSupply.itemsQuantity}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Количество мест */}
|
||||
<div className="text-center">
|
||||
<p className="text-white/60 text-xs mb-1">Мест</p>
|
||||
<p className="text-white font-semibold text-sm">
|
||||
{fulfillmentSupply.cargoPlaces}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Объём */}
|
||||
<div className="text-center">
|
||||
<p className="text-white/60 text-xs mb-1">Объём</p>
|
||||
<p className="text-white font-semibold text-sm">
|
||||
{fulfillmentSupply.volume} м³
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Стоимость */}
|
||||
<div className="text-center">
|
||||
<p className="text-white/60 text-xs mb-1">Стоимость</p>
|
||||
<p className="text-green-400 font-semibold text-sm">
|
||||
{formatCurrency(fulfillmentSupply.totalValue)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ответственный сотрудник */}
|
||||
<div className="col-span-1">
|
||||
<p className="text-white/60 text-xs mb-1">
|
||||
Ответственный
|
||||
</p>
|
||||
<Select defaultValue={fulfillmentSupply.responsibleEmployeeId}>
|
||||
<SelectTrigger className="h-8 glass-input bg-white/10 border-white/20 text-white hover:bg-white/15 focus:bg-white/15 focus:ring-1 focus:ring-purple-400/50 text-xs">
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900/95 backdrop-blur border-white/20 text-white">
|
||||
{employees.map((employee) => (
|
||||
<SelectItem
|
||||
key={employee.id}
|
||||
value={employee.id}
|
||||
className="text-white hover:bg-white/10 focus:bg-white/10 text-xs"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</span>
|
||||
<span className="text-xs text-white/60">
|
||||
{employee.position}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Логистический партнер */}
|
||||
<div className="col-span-1">
|
||||
<p className="text-white/60 text-xs mb-1">Логистика</p>
|
||||
<Select defaultValue={fulfillmentSupply.logisticsPartnerId}>
|
||||
<SelectTrigger className="h-8 glass-input bg-white/10 border-white/20 text-white hover:bg-white/15 focus:bg-white/15 focus:ring-1 focus:ring-orange-400/50 text-xs">
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900/95 backdrop-blur border-white/20 text-white">
|
||||
{logisticsPartners.map((partner) => (
|
||||
<SelectItem
|
||||
key={partner.id}
|
||||
value={partner.id}
|
||||
className="text-white hover:bg-white/10 focus:bg-white/10 text-xs"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{partner.name || partner.fullName || "Без названия"}
|
||||
</span>
|
||||
<span className="text-xs text-white/60">
|
||||
Логистический партнер
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Статус */}
|
||||
<div className="text-center">
|
||||
<p className="text-white/60 text-xs mb-1">Статус</p>
|
||||
<div className="flex justify-center">
|
||||
{getStatusBadge(fulfillmentSupply.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Карточки оптовиков */}
|
||||
<Card className="glass-card border-white/10">
|
||||
<CardHeader>
|
||||
|
@ -138,6 +138,10 @@ export function Sidebar() {
|
||||
router.push("/fulfillment-statistics");
|
||||
};
|
||||
|
||||
const handleSellerStatisticsClick = () => {
|
||||
router.push("/seller-statistics");
|
||||
};
|
||||
|
||||
const handlePartnersClick = () => {
|
||||
router.push("/partners");
|
||||
};
|
||||
@ -153,6 +157,9 @@ export function Sidebar() {
|
||||
const isFulfillmentStatisticsActive = pathname.startsWith(
|
||||
"/fulfillment-statistics"
|
||||
);
|
||||
const isSellerStatisticsActive = pathname.startsWith(
|
||||
"/seller-statistics"
|
||||
);
|
||||
const isEmployeesActive = pathname.startsWith("/employees");
|
||||
const isSuppliesActive =
|
||||
pathname.startsWith("/supplies") ||
|
||||
@ -412,6 +419,25 @@ export function Sidebar() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Статистика - для селлеров */}
|
||||
{user?.organization?.type === "SELLER" && (
|
||||
<Button
|
||||
variant={isSellerStatisticsActive ? "secondary" : "ghost"}
|
||||
className={`w-full ${
|
||||
isCollapsed ? "justify-center px-2 h-9" : "justify-start h-10"
|
||||
} text-left transition-all duration-200 text-xs ${
|
||||
isSellerStatisticsActive
|
||||
? "bg-white/20 text-white hover:bg-white/30"
|
||||
: "text-white/80 hover:bg-white/10 hover:text-white"
|
||||
} cursor-pointer`}
|
||||
onClick={handleSellerStatisticsClick}
|
||||
title={isCollapsed ? "Статистика" : ""}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Статистика</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Входящие поставки - для фулфилмент */}
|
||||
{user?.organization?.type === "FULFILLMENT" && (
|
||||
<Button
|
||||
|
525
src/components/seller-statistics/sales-tab.tsx
Normal file
525
src/components/seller-statistics/sales-tab.tsx
Normal file
@ -0,0 +1,525 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { gql } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TrendingUp } from 'lucide-react'
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
// GraphQL query для получения статистики WB
|
||||
const GET_WILDBERRIES_STATISTICS = gql`
|
||||
query GetWildberriesStatistics(
|
||||
$period: String
|
||||
$startDate: String
|
||||
$endDate: String
|
||||
) {
|
||||
getWildberriesStatistics(
|
||||
period: $period
|
||||
startDate: $startDate
|
||||
endDate: $endDate
|
||||
) {
|
||||
success
|
||||
message
|
||||
data {
|
||||
date
|
||||
sales
|
||||
orders
|
||||
advertising
|
||||
refusals
|
||||
returns
|
||||
revenue
|
||||
buyoutPercentage
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface SalesTabProps {
|
||||
selectedPeriod: string
|
||||
useCustomDates?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// Mock данные для графиков
|
||||
const mockChartData = [
|
||||
{ date: '01.11', sales: 45, orders: 52, advertising: 32, refusals: 8, returns: 3 },
|
||||
{ date: '02.11', sales: 35, orders: 41, advertising: 28, refusals: 6, returns: 2 },
|
||||
{ date: '03.11', sales: 52, orders: 61, advertising: 39, refusals: 9, returns: 4 },
|
||||
{ date: '04.11', sales: 38, orders: 45, advertising: 31, refusals: 7, returns: 2 },
|
||||
{ date: '05.11', sales: 58, orders: 69, advertising: 45, refusals: 11, returns: 5 },
|
||||
{ date: '06.11', sales: 47, orders: 55, advertising: 37, refusals: 8, returns: 3 },
|
||||
{ date: '07.11', sales: 56, orders: 66, advertising: 42, refusals: 10, returns: 4 },
|
||||
]
|
||||
|
||||
// Конфигурация chart
|
||||
const chartConfig = {
|
||||
sales: {
|
||||
label: "Продажи",
|
||||
color: "#10b981", // зеленый
|
||||
},
|
||||
orders: {
|
||||
label: "Заказы",
|
||||
color: "#3b82f6", // синий
|
||||
},
|
||||
advertising: {
|
||||
label: "Реклама",
|
||||
color: "#f59e0b", // оранжевый
|
||||
},
|
||||
refusals: {
|
||||
label: "Отказы",
|
||||
color: "#ef4444", // красный
|
||||
},
|
||||
returns: {
|
||||
label: "Возвраты",
|
||||
color: "#8b5cf6", // фиолетовый
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
// Mock данные для таблицы
|
||||
const mockTableData = [
|
||||
{
|
||||
date: '01.11.2024',
|
||||
salesUnits: 45,
|
||||
buyoutPercentage: 82.2,
|
||||
advertising: 320,
|
||||
orders: 52,
|
||||
refusals: 8,
|
||||
returns: 3,
|
||||
revenue: 1250
|
||||
},
|
||||
{
|
||||
date: '02.11.2024',
|
||||
salesUnits: 35,
|
||||
buyoutPercentage: 85.7,
|
||||
advertising: 280,
|
||||
orders: 41,
|
||||
refusals: 6,
|
||||
returns: 2,
|
||||
revenue: 980
|
||||
},
|
||||
{
|
||||
date: '03.11.2024',
|
||||
salesUnits: 52,
|
||||
buyoutPercentage: 78.8,
|
||||
advertising: 390,
|
||||
orders: 61,
|
||||
refusals: 9,
|
||||
returns: 4,
|
||||
revenue: 1450
|
||||
},
|
||||
{
|
||||
date: '04.11.2024',
|
||||
salesUnits: 38,
|
||||
buyoutPercentage: 84.4,
|
||||
advertising: 310,
|
||||
orders: 45,
|
||||
refusals: 7,
|
||||
returns: 2,
|
||||
revenue: 1120
|
||||
},
|
||||
{
|
||||
date: '05.11.2024',
|
||||
salesUnits: 58,
|
||||
buyoutPercentage: 80.6,
|
||||
advertising: 450,
|
||||
orders: 69,
|
||||
refusals: 11,
|
||||
returns: 5,
|
||||
revenue: 1680
|
||||
},
|
||||
{
|
||||
date: '06.11.2024',
|
||||
salesUnits: 47,
|
||||
buyoutPercentage: 83.0,
|
||||
advertising: 370,
|
||||
orders: 55,
|
||||
refusals: 8,
|
||||
returns: 3,
|
||||
revenue: 1350
|
||||
},
|
||||
{
|
||||
date: '07.11.2024',
|
||||
salesUnits: 56,
|
||||
buyoutPercentage: 81.2,
|
||||
advertising: 420,
|
||||
orders: 66,
|
||||
refusals: 10,
|
||||
returns: 4,
|
||||
revenue: 1580
|
||||
},
|
||||
]
|
||||
|
||||
export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }: SalesTabProps) {
|
||||
// Состояния для чекбоксов фильтрации
|
||||
const [visibleMetrics, setVisibleMetrics] = useState({
|
||||
sales: true,
|
||||
orders: true,
|
||||
advertising: true,
|
||||
refusals: true,
|
||||
returns: true,
|
||||
})
|
||||
|
||||
// Получаем данные из WB API
|
||||
const { data: wbData, loading, error } = useQuery(GET_WILDBERRIES_STATISTICS, {
|
||||
variables: useCustomDates
|
||||
? { startDate, endDate }
|
||||
: { period: selectedPeriod },
|
||||
errorPolicy: 'all',
|
||||
skip: useCustomDates && (!startDate || !endDate) // Не запрашиваем пока не выбраны обе даты
|
||||
})
|
||||
|
||||
// Данные для графика и таблицы
|
||||
const [chartData, setChartData] = useState<typeof mockChartData>([])
|
||||
const [tableData, setTableData] = useState<typeof mockTableData>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (wbData?.getWildberriesStatistics?.success && wbData.getWildberriesStatistics.data) {
|
||||
const realData = wbData.getWildberriesStatistics.data
|
||||
|
||||
// Обновляем данные для графика
|
||||
const newChartData = realData.map((item: {
|
||||
date: string;
|
||||
sales: number;
|
||||
orders: number;
|
||||
advertising: number;
|
||||
refusals: number;
|
||||
returns: number;
|
||||
}) => ({
|
||||
date: new Date(item.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||
sales: item.sales,
|
||||
orders: item.orders,
|
||||
advertising: Math.round(item.advertising),
|
||||
refusals: item.refusals,
|
||||
returns: item.returns
|
||||
}))
|
||||
|
||||
// Обновляем данные для таблицы
|
||||
const newTableData = realData.map((item: {
|
||||
date: string;
|
||||
sales: number;
|
||||
orders: number;
|
||||
advertising: number;
|
||||
refusals: number;
|
||||
returns: number;
|
||||
revenue: number;
|
||||
buyoutPercentage: number;
|
||||
}) => ({
|
||||
date: new Date(item.date).toLocaleDateString('ru-RU'),
|
||||
salesUnits: item.sales,
|
||||
buyoutPercentage: item.buyoutPercentage,
|
||||
advertising: Math.round(item.advertising),
|
||||
orders: item.orders,
|
||||
refusals: item.refusals,
|
||||
returns: item.returns,
|
||||
revenue: Math.round(item.revenue)
|
||||
}))
|
||||
|
||||
setChartData(newChartData)
|
||||
setTableData(newTableData)
|
||||
}
|
||||
}, [wbData])
|
||||
|
||||
// Функция для переключения видимости метрики
|
||||
const toggleMetric = (metric: keyof typeof visibleMetrics) => {
|
||||
setVisibleMetrics(prev => ({
|
||||
...prev,
|
||||
[metric]: !prev[metric]
|
||||
}))
|
||||
}
|
||||
|
||||
// Проверяем состояние загрузки и данных
|
||||
const isLoading = loading || (useCustomDates && (!startDate || !endDate))
|
||||
const hasData = tableData.length > 0
|
||||
const hasAnyActivity = hasData && tableData.some(row =>
|
||||
row.salesUnits > 0 || row.orders > 0 || row.advertising > 0 || row.refusals > 0 || row.returns > 0
|
||||
)
|
||||
|
||||
// Вычисляем итоги на основе текущих данных
|
||||
const totals = {
|
||||
salesUnits: tableData.reduce((sum: number, row: { salesUnits: number }) => sum + row.salesUnits, 0),
|
||||
buyoutPercentage: tableData.length > 0 ? tableData.reduce((sum: number, row: { buyoutPercentage: number }) => sum + row.buyoutPercentage, 0) / tableData.length : 0,
|
||||
advertising: tableData.reduce((sum: number, row: { advertising: number }) => sum + row.advertising, 0),
|
||||
orders: tableData.reduce((sum: number, row: { orders: number }) => sum + row.orders, 0),
|
||||
refusals: tableData.reduce((sum: number, row: { refusals: number }) => sum + row.refusals, 0),
|
||||
returns: tableData.reduce((sum: number, row: { returns: number }) => sum + row.returns, 0),
|
||||
revenue: tableData.reduce((sum: number, row: { revenue: number }) => sum + row.revenue, 0),
|
||||
}
|
||||
|
||||
// Если загружается
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-3">
|
||||
<Card className="glass-card p-4 flex-shrink-0" style={{ height: '380px' }}>
|
||||
<div className="h-full flex flex-col">
|
||||
<Skeleton className="h-6 w-48 mb-4" />
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{[1,2,3,4,5].map(i => (
|
||||
<Skeleton key={i} className="h-8 w-20" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="flex-1 w-full" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="glass-card p-4 flex-1">
|
||||
<Skeleton className="h-6 w-40 mb-4" />
|
||||
<div className="space-y-2">
|
||||
{[1,2,3,4,5].map(i => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Если нет данных или активности
|
||||
if (!hasData || !hasAnyActivity) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Card className="glass-card p-8 text-center max-w-md">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<TrendingUp className="h-16 w-16 text-white/30" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
{!hasData ? 'Нет данных' : 'Нет активности'}
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm">
|
||||
{!hasData
|
||||
? 'За выбранный период данные отсутствуют'
|
||||
: 'За выбранный период не было продаж, заказов или рекламной активности'
|
||||
}
|
||||
</p>
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-400 text-xs">Ошибка: {error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-3">
|
||||
{/* График с фильтрами */}
|
||||
<Card className="glass-card p-4 flex-shrink-0 overflow-hidden" style={{ height: '380px' }}>
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
{/* Компактный заголовок */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-white font-semibold">Динамика показателей</h3>
|
||||
{error && (
|
||||
<div className="text-red-400 text-xs">Предупреждение: {error.message}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Компактные чекбоксы для фильтрации */}
|
||||
<div className="mb-2 pb-2 border-b border-white/10">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{Object.entries(chartConfig).map(([key, config]) => {
|
||||
const isVisible = visibleMetrics[key as keyof typeof visibleMetrics]
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center space-x-1.5 p-1.5 rounded-lg transition-all duration-200 cursor-pointer ${
|
||||
isVisible
|
||||
? 'bg-white/10 border border-white/20 shadow-sm'
|
||||
: 'bg-white/5 border border-white/10 hover:bg-white/8'
|
||||
}`}
|
||||
onClick={() => toggleMetric(key as keyof typeof visibleMetrics)}
|
||||
>
|
||||
<Checkbox
|
||||
id={key}
|
||||
checked={isVisible}
|
||||
onCheckedChange={() => toggleMetric(key as keyof typeof visibleMetrics)}
|
||||
className="border-white/30 data-[state=checked]:bg-white/20 data-[state=checked]:border-white/50"
|
||||
/>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className={`text-xs cursor-pointer select-none flex items-center gap-1.5 transition-colors duration-200 ${
|
||||
isVisible ? 'text-white font-medium' : 'text-white/60'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-sm transition-all duration-200 ${
|
||||
isVisible ? 'opacity-100 scale-100' : 'opacity-50 scale-90'
|
||||
}`}
|
||||
style={{ backgroundColor: config.color }}
|
||||
/>
|
||||
{config.label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChartContainer config={chartConfig} className="min-h-[200px] h-full w-full">
|
||||
<BarChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.slice(0, 5)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dashed" />}
|
||||
/>
|
||||
|
||||
{/* Условно рендерим бары в зависимости от чекбоксов */}
|
||||
{visibleMetrics.sales && (
|
||||
<Bar
|
||||
dataKey="sales"
|
||||
fill={chartConfig.sales.color}
|
||||
radius={4}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.orders && (
|
||||
<Bar
|
||||
dataKey="orders"
|
||||
fill={chartConfig.orders.color}
|
||||
radius={4}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.advertising && (
|
||||
<Bar
|
||||
dataKey="advertising"
|
||||
fill={chartConfig.advertising.color}
|
||||
radius={4}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.refusals && (
|
||||
<Bar
|
||||
dataKey="refusals"
|
||||
fill={chartConfig.refusals.color}
|
||||
radius={4}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.returns && (
|
||||
<Bar
|
||||
dataKey="returns"
|
||||
fill={chartConfig.returns.color}
|
||||
radius={4}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Таблица данных */}
|
||||
<Card className="glass-card flex-1 overflow-hidden">
|
||||
<div className="p-4 h-full flex flex-col">
|
||||
<h3 className="text-white font-semibold mb-3 text-sm">Детальная статистика</h3>
|
||||
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/20">
|
||||
<th className="text-left p-2 text-white font-semibold text-xs">Дата</th>
|
||||
<th className="text-left p-2 text-white font-semibold text-xs">Продажи, шт</th>
|
||||
<th className="text-left p-2 text-white font-semibold text-xs">% выкупов</th>
|
||||
<th className="text-left p-2 text-white font-semibold text-xs">Реклама, ₽</th>
|
||||
<th className="text-left p-2 text-white font-semibold text-xs">Заказы</th>
|
||||
<th className="text-left p-2 text-white font-semibold text-xs">Отказы</th>
|
||||
<th className="text-left p-2 text-white font-semibold text-xs">Возвраты</th>
|
||||
<th className="text-left p-2 text-white font-semibold text-xs">Выручка, ₽</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.map((row, index) => (
|
||||
<tr key={index} className="border-b border-white/10 hover:bg-white/5 transition-colors">
|
||||
<td className="p-2 text-white/80 text-xs">{row.date}</td>
|
||||
<td className="p-2 text-white text-xs font-medium">{row.salesUnits}</td>
|
||||
<td className="p-2 text-xs">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${
|
||||
row.buyoutPercentage >= 80
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{row.buyoutPercentage}%
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-white/80 text-xs">{row.advertising.toLocaleString('ru-RU')}</td>
|
||||
<td className="p-2 text-white/80 text-xs">{row.orders}</td>
|
||||
<td className="p-2 text-xs">
|
||||
<Badge variant="secondary" className="bg-red-500/20 text-red-400 text-xs px-2 py-0.5">
|
||||
{row.refusals}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-xs">
|
||||
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 text-xs px-2 py-0.5">
|
||||
{row.returns}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-white text-xs font-medium">
|
||||
{row.revenue.toLocaleString('ru-RU')} ₽
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Результирующая строка */}
|
||||
<tr className="border-t-2 border-white/30 bg-white/10 font-semibold">
|
||||
<td className="p-2 text-white text-xs font-bold">ИТОГО</td>
|
||||
<td className="p-2 text-white text-xs font-bold">{totals.salesUnits}</td>
|
||||
<td className="p-2 text-xs">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${
|
||||
totals.buyoutPercentage >= 80
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
} font-bold`}
|
||||
>
|
||||
{totals.buyoutPercentage.toFixed(1)}%
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-white text-xs font-bold">{totals.advertising.toLocaleString('ru-RU')}</td>
|
||||
<td className="p-2 text-white text-xs font-bold">{totals.orders}</td>
|
||||
<td className="p-2 text-xs">
|
||||
<Badge variant="secondary" className="bg-red-500/20 text-red-400 font-bold text-xs px-2 py-0.5">
|
||||
{totals.refusals}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-xs">
|
||||
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 font-bold text-xs px-2 py-0.5">
|
||||
{totals.returns}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-white text-xs font-bold">
|
||||
{totals.revenue.toLocaleString('ru-RU')} ₽
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
168
src/components/seller-statistics/seller-statistics-dashboard.tsx
Normal file
168
src/components/seller-statistics/seller-statistics-dashboard.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { SalesTab } from '@/components/seller-statistics/sales-tab'
|
||||
import { DateRangePicker } from '@/components/ui/date-picker'
|
||||
import { BarChart3, PieChart, TrendingUp, Calendar } from 'lucide-react'
|
||||
|
||||
export function SellerStatisticsDashboard() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week')
|
||||
const [useCustomDates, setUseCustomDates] = useState(false)
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Компактный заголовок с переключателями */}
|
||||
<div className="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Статистика продаж</h1>
|
||||
<p className="text-white/50 text-sm">Аналитика продаж, заказов и рекламы</p>
|
||||
</div>
|
||||
|
||||
{/* Переключатели периода и пользовательские даты */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Стильные переключатели периода */}
|
||||
<div className="flex gap-1 bg-white/5 backdrop-blur border border-white/10 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPeriod('week')
|
||||
setUseCustomDates(false)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 ${
|
||||
selectedPeriod === 'week' && !useCustomDates
|
||||
? 'bg-white/20 text-white shadow-sm'
|
||||
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
|
||||
}`}
|
||||
>
|
||||
Неделя
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPeriod('month')
|
||||
setUseCustomDates(false)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 ${
|
||||
selectedPeriod === 'month' && !useCustomDates
|
||||
? 'bg-white/20 text-white shadow-sm'
|
||||
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
|
||||
}`}
|
||||
>
|
||||
Месяц
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPeriod('quarter')
|
||||
setUseCustomDates(false)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 ${
|
||||
selectedPeriod === 'quarter' && !useCustomDates
|
||||
? 'bg-white/20 text-white shadow-sm'
|
||||
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
|
||||
}`}
|
||||
>
|
||||
Квартал
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUseCustomDates(true)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 flex items-center gap-1 ${
|
||||
useCustomDates
|
||||
? 'bg-white/20 text-white shadow-sm'
|
||||
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
Период
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Выбор произвольных дат */}
|
||||
{useCustomDates && (
|
||||
<DateRangePicker
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
className="min-w-[280px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент с табами */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs defaultValue="sales" className="h-full flex flex-col">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border border-white/10 rounded-xl flex-shrink-0 h-11">
|
||||
<TabsTrigger
|
||||
value="sales"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 flex items-center gap-2 text-sm rounded-lg"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Продажи
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="advertising"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 flex items-center gap-2 text-sm rounded-lg"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Реклама
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="other"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 flex items-center gap-2 text-sm rounded-lg"
|
||||
>
|
||||
<PieChart className="h-4 w-4" />
|
||||
Иное
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Контент вкладок */}
|
||||
<div className="flex-1 overflow-hidden mt-3">
|
||||
<TabsContent value="sales" className="h-full m-0 overflow-hidden">
|
||||
<SalesTab
|
||||
selectedPeriod={selectedPeriod}
|
||||
useCustomDates={useCustomDates}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advertising" className="h-full m-0 overflow-hidden">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Статистика рекламы</h3>
|
||||
<p className="text-white/60">Раздел в разработке</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="other" className="h-full m-0 overflow-hidden">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<PieChart className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Прочая статистика</h3>
|
||||
<p className="text-white/60">Раздел в разработке</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
353
src/components/ui/chart.tsx
Normal file
353
src/components/ui/chart.tsx
Normal file
@ -0,0 +1,353 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
78
src/components/ui/date-picker.tsx
Normal file
78
src/components/ui/date-picker.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Calendar } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface DatePickerProps {
|
||||
value?: string
|
||||
onChange?: (date: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Выберите дату",
|
||||
className,
|
||||
disabled,
|
||||
}: DatePickerProps) {
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={handleDateChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-white/10 bg-white/5 backdrop-blur px-3 py-1 text-sm text-white placeholder:text-white/50 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/20 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"pr-8", // Place for calendar icon
|
||||
className
|
||||
)}
|
||||
/>
|
||||
<Calendar className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40 pointer-events-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DateRangePickerProps {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
onStartDateChange?: (date: string) => void
|
||||
onEndDateChange?: (date: string) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
className,
|
||||
disabled,
|
||||
}: DateRangePickerProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={onStartDateChange}
|
||||
placeholder="От"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-white/40 text-sm">—</span>
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={onEndDateChange}
|
||||
placeholder="До"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
className={cn("bg-white/10 animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
@ -6,6 +6,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { SmsService } from "@/services/sms-service";
|
||||
import { DaDataService } from "@/services/dadata-service";
|
||||
import { MarketplaceService } from "@/services/marketplace-service";
|
||||
import { WildberriesService } from "@/services/wildberries-service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
// Сервисы
|
||||
@ -4942,10 +4943,163 @@ const adminMutations = {
|
||||
},
|
||||
};
|
||||
|
||||
// Wildberries статистика
|
||||
const wildberriesQueries = {
|
||||
debugWildberriesAdverts: async (
|
||||
_: unknown,
|
||||
__: unknown,
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
apiKeys: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.organization || user.organization.type !== 'SELLER') {
|
||||
throw new GraphQLError("Доступно только для продавцов");
|
||||
}
|
||||
|
||||
const wbApiKeyRecord = user.organization.apiKeys?.find(
|
||||
key => key.marketplace === 'WILDBERRIES' && key.isActive
|
||||
);
|
||||
|
||||
if (!wbApiKeyRecord) {
|
||||
throw new GraphQLError("WB API ключ не настроен");
|
||||
}
|
||||
|
||||
const wbService = new WildberriesService(wbApiKeyRecord.apiKey);
|
||||
|
||||
// Получаем кампании во всех статусах
|
||||
const [active, completed, paused] = await Promise.all([
|
||||
wbService.getAdverts(9).catch(() => []), // активные
|
||||
wbService.getAdverts(7).catch(() => []), // завершенные
|
||||
wbService.getAdverts(11).catch(() => []) // на паузе
|
||||
]);
|
||||
|
||||
const allCampaigns = [...active, ...completed, ...paused];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${active.length} active, ${completed.length} completed, ${paused.length} paused campaigns`,
|
||||
campaignsCount: allCampaigns.length,
|
||||
campaigns: allCampaigns.map(c => ({
|
||||
id: c.advertId,
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
type: c.type
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error debugging WB adverts:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
campaignsCount: 0,
|
||||
campaigns: []
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
getWildberriesStatistics: async (
|
||||
_: unknown,
|
||||
{ period, startDate, endDate }: {
|
||||
period?: 'week' | 'month' | 'quarter'
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
},
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Получаем организацию пользователя и её WB API ключ
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
apiKeys: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.organization) {
|
||||
throw new GraphQLError("Организация не найдена");
|
||||
}
|
||||
|
||||
if (user.organization.type !== 'SELLER') {
|
||||
throw new GraphQLError("Доступно только для продавцов");
|
||||
}
|
||||
|
||||
const wbApiKeyRecord = user.organization.apiKeys?.find(
|
||||
key => key.marketplace === 'WILDBERRIES' && key.isActive
|
||||
);
|
||||
|
||||
if (!wbApiKeyRecord) {
|
||||
throw new GraphQLError("WB API ключ не настроен");
|
||||
}
|
||||
|
||||
// Создаем экземпляр сервиса
|
||||
const wbService = new WildberriesService(wbApiKeyRecord.apiKey);
|
||||
|
||||
// Получаем даты
|
||||
let dateFrom: string;
|
||||
let dateTo: string;
|
||||
|
||||
if (startDate && endDate) {
|
||||
// Используем пользовательские даты
|
||||
dateFrom = startDate;
|
||||
dateTo = endDate;
|
||||
} else if (period) {
|
||||
// Используем предустановленный период
|
||||
dateFrom = WildberriesService.getDatePeriodAgo(period);
|
||||
dateTo = WildberriesService.formatDate(new Date());
|
||||
} else {
|
||||
throw new GraphQLError("Необходимо указать либо period, либо startDate и endDate");
|
||||
}
|
||||
|
||||
// Получаем статистику
|
||||
const statistics = await wbService.getStatistics(dateFrom, dateTo);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: statistics,
|
||||
message: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching WB statistics:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Ошибка получения статистики',
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Добавляем админ запросы и мутации к основным резолверам
|
||||
resolvers.Query = {
|
||||
...resolvers.Query,
|
||||
...adminQueries,
|
||||
...wildberriesQueries,
|
||||
};
|
||||
|
||||
resolvers.Mutation = {
|
||||
|
@ -75,6 +75,16 @@ export const typeDefs = gql`
|
||||
# Админ запросы
|
||||
adminMe: Admin
|
||||
allUsers(search: String, limit: Int, offset: Int): UsersResponse!
|
||||
|
||||
# Wildberries статистика
|
||||
getWildberriesStatistics(
|
||||
period: String
|
||||
startDate: String
|
||||
endDate: String
|
||||
): WildberriesStatisticsResponse!
|
||||
|
||||
# Отладка рекламы (временно)
|
||||
debugWildberriesAdverts: DebugAdvertsResponse!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@ -892,4 +902,36 @@ export const typeDefs = gql`
|
||||
message: String!
|
||||
supply: WildberriesSupply
|
||||
}
|
||||
|
||||
# Wildberries статистика
|
||||
type WildberriesStatistics {
|
||||
date: String!
|
||||
sales: Int!
|
||||
orders: Int!
|
||||
advertising: Float!
|
||||
refusals: Int!
|
||||
returns: Int!
|
||||
revenue: Float!
|
||||
buyoutPercentage: Float!
|
||||
}
|
||||
|
||||
type WildberriesStatisticsResponse {
|
||||
success: Boolean!
|
||||
message: String
|
||||
data: [WildberriesStatistics!]!
|
||||
}
|
||||
|
||||
type DebugAdvertsResponse {
|
||||
success: Boolean!
|
||||
message: String
|
||||
campaignsCount: Int!
|
||||
campaigns: [DebugCampaign!]
|
||||
}
|
||||
|
||||
type DebugCampaign {
|
||||
id: Int!
|
||||
name: String!
|
||||
status: Int!
|
||||
type: Int!
|
||||
}
|
||||
`;
|
||||
|
@ -61,143 +61,388 @@ interface WildberriesCardFilter {
|
||||
}
|
||||
}
|
||||
|
||||
export class WildberriesService {
|
||||
private static contentUrl = 'https://content-api.wildberries.ru'
|
||||
private static publicUrl = 'https://public-api.wildberries.ru'
|
||||
private static supplierUrl = 'https://suppliers-api.wildberries.ru'
|
||||
export interface WBSalesData {
|
||||
date: string
|
||||
saleID: string
|
||||
gNumber: string
|
||||
supplierArticle: string
|
||||
techSize: string
|
||||
barcode: string
|
||||
totalPrice: number
|
||||
discountPercent: number
|
||||
isSupply: boolean
|
||||
isRealization: boolean
|
||||
promoCodeDiscount: number
|
||||
warehouseName: string
|
||||
countryName: string
|
||||
oblastOkrugName: string
|
||||
regionName: string
|
||||
incomeID: number
|
||||
isCancel: boolean
|
||||
cancelDate: string
|
||||
orderType: string
|
||||
sticker: string
|
||||
srp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение карточек товаров через Content API v2
|
||||
*/
|
||||
static async getCards(apiKey: string, filter?: WildberriesCardFilter): Promise<WildberriesCard[]> {
|
||||
try {
|
||||
console.log('Calling WB Content API v2 with filter:', filter)
|
||||
export interface WBOrdersData {
|
||||
date: string
|
||||
lastChangeDate: string
|
||||
supplierArticle: string
|
||||
techSize: string
|
||||
barcode: string
|
||||
totalPrice: number
|
||||
discountPercent: number
|
||||
warehouseName: string
|
||||
oblast: string
|
||||
incomeID: number
|
||||
odid: number
|
||||
nmId: number
|
||||
subject: string
|
||||
category: string
|
||||
brand: string
|
||||
isCancel: boolean
|
||||
cancel_dt: string
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.contentUrl}/content/v2/get/cards/list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(filter || {
|
||||
settings: {
|
||||
cursor: {
|
||||
limit: 100
|
||||
},
|
||||
filter: {
|
||||
withPhoto: -1
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
export interface WBAdvertData {
|
||||
advertId: number
|
||||
name: string
|
||||
status: number
|
||||
type: number
|
||||
createTime: string
|
||||
changeTime: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
dailyBudget: number
|
||||
budget: number
|
||||
}
|
||||
|
||||
console.log(`${this.contentUrl}/content/v2/get/cards/list`, response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorData
|
||||
try {
|
||||
errorData = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorData = { message: errorText }
|
||||
}
|
||||
|
||||
console.log('WB API Error Response:', errorData)
|
||||
throw new Error(`WB API Error: ${response.status} - ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: WildberriesCardsResponse = await response.json()
|
||||
return data.cards || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching WB cards:', error)
|
||||
throw new Error('Ошибка получения карточек товаров')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск карточек товаров
|
||||
*/
|
||||
static async searchCards(apiKey: string, searchTerm: string, limit = 50): Promise<WildberriesCard[]> {
|
||||
const filter: WildberriesCardFilter = {
|
||||
settings: {
|
||||
cursor: {
|
||||
limit
|
||||
},
|
||||
filter: {
|
||||
textSearch: searchTerm,
|
||||
withPhoto: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.getCards(apiKey, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение всех карточек товаров с пагинацией
|
||||
*/
|
||||
static async getAllCards(apiKey: string, limit = 100): Promise<WildberriesCard[]> {
|
||||
const filter: WildberriesCardFilter = {
|
||||
settings: {
|
||||
cursor: {
|
||||
limit
|
||||
},
|
||||
filter: {
|
||||
withPhoto: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.getCards(apiKey, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение складов WB
|
||||
*/
|
||||
static async getWarehouses(apiKey: string): Promise<WildberriesWarehouse[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.supplierUrl}/api/v3/warehouses`, {
|
||||
headers: {
|
||||
'Authorization': apiKey,
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: WildberriesWarehousesResponse = await response.json()
|
||||
return data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching warehouses:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка валидности API ключа
|
||||
*/
|
||||
static async validateApiKey(apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.contentUrl}/content/v2/get/cards/list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
cursor: {
|
||||
limit: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.error('Error validating API key:', error)
|
||||
return false
|
||||
}
|
||||
export interface WBAdvertStatsRequest {
|
||||
id?: number
|
||||
dates?: string[]
|
||||
interval?: {
|
||||
begin: string
|
||||
end: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WBAdvertStatsResponse {
|
||||
dates: string[]
|
||||
views: number
|
||||
clicks: number
|
||||
ctr: number
|
||||
cpc: number
|
||||
sum: number
|
||||
atbs: number
|
||||
orders: number
|
||||
cr: number
|
||||
shks: number
|
||||
sum_price: number
|
||||
days: Array<{
|
||||
date: string
|
||||
views: number
|
||||
clicks: number
|
||||
ctr: number
|
||||
cpc: number
|
||||
sum: number
|
||||
atbs: number
|
||||
orders: number
|
||||
cr: number
|
||||
shks: number
|
||||
sum_price: number
|
||||
}>
|
||||
advertId: number
|
||||
}
|
||||
|
||||
export interface WBStatisticsData {
|
||||
date: string
|
||||
sales: number
|
||||
orders: number
|
||||
advertising: number
|
||||
refusals: number
|
||||
returns: number
|
||||
revenue: number
|
||||
buyoutPercentage: number
|
||||
}
|
||||
|
||||
class WildberriesService {
|
||||
private apiKey: string
|
||||
private baseURL = 'https://statistics-api.wildberries.ru'
|
||||
private advertURL = 'https://advert-api.wildberries.ru'
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey
|
||||
}
|
||||
|
||||
private async makeRequest<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': this.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
// Получение данных о продажах
|
||||
async getSales(dateFrom: string, flag = 0): Promise<WBSalesData[]> {
|
||||
const url = `${this.baseURL}/api/v1/supplier/sales?dateFrom=${dateFrom}&flag=${flag}`
|
||||
return this.makeRequest<WBSalesData[]>(url)
|
||||
}
|
||||
|
||||
// Получение данных о заказах
|
||||
async getOrders(dateFrom: string, flag = 0): Promise<WBOrdersData[]> {
|
||||
const url = `${this.baseURL}/api/v1/supplier/orders?dateFrom=${dateFrom}&flag=${flag}`
|
||||
return this.makeRequest<WBOrdersData[]>(url)
|
||||
}
|
||||
|
||||
// Получение рекламных кампаний
|
||||
async getAdverts(status?: number, type?: number, limit = 100, offset = 0): Promise<WBAdvertData[]> {
|
||||
let url = `${this.advertURL}/adv/v0/adverts?limit=${limit}&offset=${offset}`
|
||||
if (status) url += `&status=${status}`
|
||||
if (type) url += `&type=${type}`
|
||||
|
||||
return this.makeRequest<WBAdvertData[]>(url)
|
||||
}
|
||||
|
||||
// Получение статистики всех рекламных кампаний (v2)
|
||||
async getAdvertStats(dateFrom: string, dateTo: string): Promise<WBAdvertStatsResponse[]> {
|
||||
const url = `${this.advertURL}/adv/v2/fullstats`
|
||||
|
||||
// Попробуем через interval - это может работать лучше
|
||||
const request: WBAdvertStatsRequest[] = [{
|
||||
interval: {
|
||||
begin: dateFrom,
|
||||
end: dateTo
|
||||
}
|
||||
}]
|
||||
|
||||
console.log(`WB API: Requesting campaign stats with interval: ${dateFrom} to ${dateTo}`)
|
||||
console.log(`WB API: Request body:`, JSON.stringify(request, null, 2))
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest<WBAdvertStatsResponse[]>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request)
|
||||
})
|
||||
|
||||
console.log(`WB API: Advert response:`, JSON.stringify(response, null, 2))
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error(`WB API: Advert stats error:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Получение детального отчета по периоду
|
||||
async getDetailReport(dateFrom: string, dateTo: string, limit = 10000, rrdid = 0): Promise<Record<string, unknown>[]> {
|
||||
const url = `${this.baseURL}/api/v1/supplier/reportDetailByPeriod?dateFrom=${dateFrom}&dateTo=${dateTo}&limit=${limit}&rrdid=${rrdid}`
|
||||
return this.makeRequest<Record<string, unknown>[]>(url)
|
||||
}
|
||||
|
||||
// Агрегированная статистика для дашборда
|
||||
async getStatistics(dateFrom: string, dateTo: string): Promise<WBStatisticsData[]> {
|
||||
try {
|
||||
console.log(`WB API: Getting statistics from ${dateFrom} to ${dateTo}`)
|
||||
|
||||
// Получаем продажи и заказы
|
||||
const [salesData, ordersData] = await Promise.all([
|
||||
this.getSales(dateFrom, 0), // flag=0 для получения данных за период от dateFrom до сегодня
|
||||
this.getOrders(dateFrom, 0)
|
||||
])
|
||||
|
||||
console.log(`WB API: Got ${salesData.length} sales, ${ordersData.length} orders`)
|
||||
|
||||
// Получаем статистику рекламы напрямую через /adv/v2/fullstats
|
||||
let advertStatsData: WBAdvertStatsResponse[] = []
|
||||
try {
|
||||
console.log(`WB API: Getting campaign stats for interval: ${dateFrom} to ${dateTo}`)
|
||||
|
||||
advertStatsData = await this.getAdvertStats(dateFrom, dateTo)
|
||||
console.log(`WB API: Got advertising stats for ${advertStatsData.length} campaigns`)
|
||||
|
||||
// Логируем детали рекламных затрат
|
||||
advertStatsData.forEach(stat => {
|
||||
const totalSpend = stat.days?.reduce((sum, day) => sum + (day.sum || 0), 0) || stat.sum || 0
|
||||
console.log(`WB API: Campaign ${stat.advertId} spent ${totalSpend} rubles over ${stat.days?.length || 0} days`)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('WB API: Failed to get advertising stats:', error)
|
||||
}
|
||||
|
||||
// Группируем данные по датам
|
||||
const statsMap = new Map<string, WBStatisticsData>()
|
||||
|
||||
// Обрабатываем продажи
|
||||
salesData.forEach(sale => {
|
||||
const date = sale.date.split('T')[0] // Берем только дату без времени
|
||||
|
||||
// Строгая фильтрация по диапазону дат
|
||||
if (date < dateFrom || date > dateTo) return
|
||||
|
||||
if (!statsMap.has(date)) {
|
||||
statsMap.set(date, {
|
||||
date,
|
||||
sales: 0,
|
||||
orders: 0,
|
||||
advertising: 0,
|
||||
refusals: 0,
|
||||
returns: 0,
|
||||
revenue: 0,
|
||||
buyoutPercentage: 0
|
||||
})
|
||||
}
|
||||
|
||||
const stats = statsMap.get(date)!
|
||||
if (!sale.isCancel) {
|
||||
stats.sales += 1
|
||||
stats.revenue += sale.totalPrice * (1 - sale.discountPercent / 100)
|
||||
} else {
|
||||
stats.returns += 1
|
||||
}
|
||||
})
|
||||
|
||||
// Обрабатываем заказы
|
||||
ordersData.forEach(order => {
|
||||
const date = order.date.split('T')[0]
|
||||
|
||||
// Строгая фильтрация по диапазону дат
|
||||
if (date < dateFrom || date > dateTo) return
|
||||
|
||||
if (!statsMap.has(date)) {
|
||||
statsMap.set(date, {
|
||||
date,
|
||||
sales: 0,
|
||||
orders: 0,
|
||||
advertising: 0,
|
||||
refusals: 0,
|
||||
returns: 0,
|
||||
revenue: 0,
|
||||
buyoutPercentage: 0
|
||||
})
|
||||
}
|
||||
|
||||
const stats = statsMap.get(date)!
|
||||
if (!order.isCancel) {
|
||||
stats.orders += 1
|
||||
} else {
|
||||
stats.refusals += 1
|
||||
}
|
||||
})
|
||||
|
||||
// Обрабатываем данные рекламы
|
||||
advertStatsData.forEach(advertStat => {
|
||||
console.log(`WB API: Processing advert ${advertStat.advertId}, total sum: ${advertStat.sum}`)
|
||||
|
||||
// Обрабатываем статистику по дням для каждой кампании
|
||||
if (advertStat.days && advertStat.days.length > 0) {
|
||||
advertStat.days.forEach(day => {
|
||||
const date = day.date
|
||||
|
||||
console.log(`WB API: Day ${date} - spent ${day.sum} rubles (campaign ${advertStat.advertId})`)
|
||||
|
||||
// Строгая фильтрация по диапазону дат
|
||||
if (date < dateFrom || date > dateTo) {
|
||||
console.log(`WB API: Skipping ${date} - outside range ${dateFrom} to ${dateTo}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!statsMap.has(date)) {
|
||||
statsMap.set(date, {
|
||||
date,
|
||||
sales: 0,
|
||||
orders: 0,
|
||||
advertising: 0,
|
||||
refusals: 0,
|
||||
returns: 0,
|
||||
revenue: 0,
|
||||
buyoutPercentage: 0
|
||||
})
|
||||
}
|
||||
|
||||
const stats = statsMap.get(date)!
|
||||
const adSpend = day.sum || 0
|
||||
stats.advertising += adSpend
|
||||
console.log(`WB API: Added ${adSpend} rubles to ${date}, total now: ${stats.advertising}`)
|
||||
})
|
||||
} else {
|
||||
console.log(`WB API: No daily data for campaign ${advertStat.advertId}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Вычисляем процент выкупов
|
||||
statsMap.forEach(stats => {
|
||||
if (stats.orders > 0) {
|
||||
// Ограничиваем процент выкупов до 100% максимум
|
||||
const percentage = Math.min(100, Math.round((stats.sales / stats.orders) * 100))
|
||||
stats.buyoutPercentage = percentage
|
||||
}
|
||||
})
|
||||
|
||||
// Получаем данные по рекламе (это более сложно, так как нужна отдельная статистика)
|
||||
// Пока используем заглушку, в реальности нужно вызвать отдельный API
|
||||
|
||||
return Array.from(statsMap.values()).sort((a, b) => a.date.localeCompare(b.date))
|
||||
} catch (error) {
|
||||
console.error('Error fetching WB statistics:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Получение статистики рекламы (заглушка)
|
||||
async getAdvertStatistics(dateFrom: string, dateTo: string): Promise<{ date: string; spend: number }[]> {
|
||||
// В реальности здесь нужно вызвать соответствующий API для статистики рекламы
|
||||
// Пока возвращаем заглушку
|
||||
return []
|
||||
}
|
||||
|
||||
// Генерирует массив дат между dateFrom и dateTo
|
||||
private generateDateRange(dateFrom: string, dateTo: string): string[] {
|
||||
const dates: string[] = []
|
||||
const start = new Date(dateFrom)
|
||||
const end = new Date(dateTo)
|
||||
|
||||
const current = new Date(start)
|
||||
while (current <= end) {
|
||||
dates.push(WildberriesService.formatDate(current))
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
|
||||
// Форматирование даты для WB API (RFC3339)
|
||||
static formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0] // YYYY-MM-DD
|
||||
}
|
||||
|
||||
// Получение даты период назад
|
||||
static getDatePeriodAgo(period: 'week' | 'month' | 'quarter'): string {
|
||||
const now = new Date()
|
||||
const date = new Date(now)
|
||||
|
||||
switch (period) {
|
||||
case 'week':
|
||||
date.setDate(date.getDate() - 7)
|
||||
break
|
||||
case 'month':
|
||||
date.setMonth(date.getMonth() - 1)
|
||||
break
|
||||
case 'quarter':
|
||||
date.setMonth(date.getMonth() - 3)
|
||||
break
|
||||
}
|
||||
|
||||
return this.formatDate(date)
|
||||
}
|
||||
}
|
||||
|
||||
export { WildberriesService }
|
Reference in New Issue
Block a user