Compare commits
88 Commits
2e576b6d28
...
ec8f2cbc75
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ec8f2cbc75 | ||
![]() |
cf5f666651 | ||
![]() |
b1e08815b3 | ||
![]() |
7eb957f66a | ||
![]() |
57ae872cc6 | ||
65050794f0 | |||
![]() |
e164cd8fbe | ||
8fcaaf2332 | |||
0c4854326b | |||
![]() |
68ed074c37 | ||
3791ce992c | |||
![]() |
e024439cb0 | ||
![]() |
b1925331ed | ||
![]() |
51467925f3 | ||
b24b0810f4 | |||
3c5cebedfe | |||
![]() |
7353c7e243 | ||
![]() |
b2c5684577 | ||
a569de9c03 | |||
929f7d170b | |||
40c91f9f2b | |||
![]() |
85f55da57b | ||
![]() |
f697f38e4f | ||
![]() |
c5b4a41b19 | ||
![]() |
5e2caefaaf | ||
d4d17ae987 | |||
![]() |
fbda209db4 | ||
![]() |
d05d0180b8 | ||
8b2c85df33 | |||
![]() |
b29416495c | ||
![]() |
d645dbde2f | ||
![]() |
73ccee5dd3 | ||
![]() |
fb130799a9 | ||
![]() |
5af53723cc | ||
![]() |
81e9a98315 | ||
![]() |
15280ecdab | ||
![]() |
e33368dcb7 | ||
93037b581c | |||
02e91a3951 | |||
a241b74599 | |||
![]() |
5825370f23 | ||
e00907e575 | |||
![]() |
8b431eaeca | ||
![]() |
6ce39c89d0 | ||
![]() |
dc5b91d104 | ||
![]() |
260c1c0af3 | ||
![]() |
c9d9cd99ee | ||
![]() |
dfa886eec7 | ||
![]() |
7fb0cfb41c | ||
![]() |
ebb5e4931f | ||
34f9127778 | |||
![]() |
0c69b59677 | ||
86a9fc4382 | |||
![]() |
6f7f777eab | ||
36fe2b114f | |||
![]() |
99aa53652b | ||
![]() |
1db029e573 | ||
767288ea86 | |||
3820522f32 | |||
![]() |
74dbaab169 | ||
![]() |
b527ecebee | ||
![]() |
58ed99a8ae | ||
7644ea5c6b | |||
fe4eb80ed5 | |||
335bbc3bab | |||
1dc07159c1 | |||
78f4f1e527 | |||
![]() |
d810b86474 | ||
![]() |
dd8b4c8e94 | ||
![]() |
ef74c46550 | ||
![]() |
9f50189cd1 | ||
965ac51c59 | |||
![]() |
135ce5d8fa | ||
![]() |
50cb89eff5 | ||
![]() |
6e7e4e4f73 | ||
![]() |
7a61a3d62a | ||
![]() |
8b2a44e1ea | ||
![]() |
460049a1a2 | ||
![]() |
037328f0c9 | ||
bcf249e9c4 | |||
f3ab31ce76 | |||
e30cac00ea | |||
2009273959 | |||
![]() |
127dd3cfda | ||
2ba0f735fd | |||
ad7ada88c2 | |||
![]() |
ec8430189a | ||
![]() |
cb32ccaba9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
build/bin
|
||||
dist
|
||||
node_modules
|
||||
src/lib/pocketbaseAdmin.ts
|
||||
|
73
README.md
73
README.md
@@ -1,73 +0,0 @@
|
||||
# Welcome to your Lovable project
|
||||
|
||||
## Project info
|
||||
|
||||
**URL**: https://lovable.dev/projects/d225defa-9140-4bac-bcc6-c0b9f5f703e2
|
||||
|
||||
## How can I edit this code?
|
||||
|
||||
There are several ways of editing your application.
|
||||
|
||||
**Use Lovable**
|
||||
|
||||
Simply visit the [Lovable Project](https://lovable.dev/projects/d225defa-9140-4bac-bcc6-c0b9f5f703e2) and start prompting.
|
||||
|
||||
Changes made via Lovable will be committed automatically to this repo.
|
||||
|
||||
**Use your preferred IDE**
|
||||
|
||||
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
|
||||
|
||||
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
|
||||
|
||||
Follow these steps:
|
||||
|
||||
```sh
|
||||
# Step 1: Clone the repository using the project's Git URL.
|
||||
git clone <YOUR_GIT_URL>
|
||||
|
||||
# Step 2: Navigate to the project directory.
|
||||
cd <YOUR_PROJECT_NAME>
|
||||
|
||||
# Step 3: Install the necessary dependencies.
|
||||
npm i
|
||||
|
||||
# Step 4: Start the development server with auto-reloading and an instant preview.
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Edit a file directly in GitHub**
|
||||
|
||||
- Navigate to the desired file(s).
|
||||
- Click the "Edit" button (pencil icon) at the top right of the file view.
|
||||
- Make your changes and commit the changes.
|
||||
|
||||
**Use GitHub Codespaces**
|
||||
|
||||
- Navigate to the main page of your repository.
|
||||
- Click on the "Code" button (green button) near the top right.
|
||||
- Select the "Codespaces" tab.
|
||||
- Click on "New codespace" to launch a new Codespace environment.
|
||||
- Edit files directly within the Codespace and commit and push your changes once you're done.
|
||||
|
||||
## What technologies are used for this project?
|
||||
|
||||
This project is built with:
|
||||
|
||||
- Vite
|
||||
- TypeScript
|
||||
- React
|
||||
- shadcn-ui
|
||||
- Tailwind CSS
|
||||
|
||||
## How can I deploy this project?
|
||||
|
||||
Simply open [Lovable](https://lovable.dev/projects/d225defa-9140-4bac-bcc6-c0b9f5f703e2) and click on Share -> Publish.
|
||||
|
||||
## Can I connect a custom domain to my Lovable project?
|
||||
|
||||
Yes, you can!
|
||||
|
||||
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
||||
|
||||
Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)
|
10
index.html
10
index.html
@@ -5,16 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EVE Industry Manager</title>
|
||||
<meta name="description" content="Manage EVE Online industrial jobs and track profitability" />
|
||||
<meta name="author" content="Lovable" />
|
||||
|
||||
<meta property="og:title" content="EVE Industry Manager" />
|
||||
<meta property="og:description" content="Manage EVE Online industrial jobs and track profitability" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@lovable_dev" />
|
||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
118
package-lock.json
generated
118
package-lock.json
generated
@@ -83,7 +83,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -815,7 +814,6 @@
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
@@ -833,7 +831,6 @@
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
||||
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
@@ -848,7 +845,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -858,7 +854,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -868,14 +863,12 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -886,7 +879,6 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
@@ -900,7 +892,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -910,7 +901,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
@@ -924,7 +914,6 @@
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -2925,14 +2914,14 @@
|
||||
"version": "15.7.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
|
||||
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
||||
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -2943,7 +2932,7 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
@@ -3235,7 +3224,6 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -3248,7 +3236,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -3264,14 +3251,12 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
@@ -3285,7 +3270,6 @@
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
@@ -3349,14 +3333,12 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3380,7 +3362,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -3436,7 +3417,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3484,7 +3464,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
@@ -3509,7 +3488,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -3920,7 +3898,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3933,14 +3910,12 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3957,7 +3932,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
@@ -3971,7 +3945,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
@@ -4158,14 +4131,12 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
@@ -4182,7 +4153,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
@@ -4224,7 +4194,6 @@
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
@@ -4503,7 +4472,6 @@
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
@@ -4520,7 +4488,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -4547,7 +4514,6 @@
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
|
||||
"integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@@ -4570,7 +4536,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -4621,7 +4586,6 @@
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
||||
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.0",
|
||||
@@ -4652,7 +4616,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -4667,7 +4630,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -4686,7 +4648,6 @@
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
@@ -4707,7 +4668,6 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
@@ -4720,7 +4680,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -4730,7 +4689,6 @@
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
@@ -4776,7 +4734,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -4854,7 +4811,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
@@ -4867,7 +4823,6 @@
|
||||
"version": "2.15.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
|
||||
"integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
@@ -4883,7 +4838,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4893,7 +4847,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4903,7 +4856,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -4916,7 +4868,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -4926,14 +4877,12 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
@@ -4949,7 +4898,6 @@
|
||||
"version": "1.21.6",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz",
|
||||
"integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -5023,7 +4971,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -5036,7 +4983,6 @@
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -5546,7 +5492,6 @@
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
@@ -5571,7 +5516,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -5581,7 +5525,6 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -5608,7 +5551,6 @@
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -5625,7 +5567,6 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
@@ -5637,7 +5578,6 @@
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5680,7 +5620,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5709,7 +5648,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5769,7 +5707,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
@@ -5799,7 +5736,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5809,14 +5745,12 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
@@ -5833,14 +5767,12 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -5853,7 +5785,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5863,7 +5794,6 @@
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
|
||||
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5879,7 +5809,6 @@
|
||||
"version": "8.4.47",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
|
||||
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -5908,7 +5837,6 @@
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
@@ -5926,7 +5854,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
|
||||
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelcase-css": "^2.0.1"
|
||||
@@ -5946,7 +5873,6 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
|
||||
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -5982,7 +5908,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6008,7 +5933,6 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@@ -6022,7 +5946,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
@@ -6066,7 +5989,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6291,7 +6213,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
@@ -6301,7 +6222,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
@@ -6352,7 +6272,6 @@
|
||||
"version": "1.22.8",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||
"integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.13.0",
|
||||
@@ -6380,7 +6299,6 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@@ -6427,7 +6345,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6473,7 +6390,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -6486,7 +6402,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6496,7 +6411,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -6519,7 +6433,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6529,7 +6442,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
@@ -6548,7 +6460,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -6563,7 +6474,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6573,14 +6483,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -6593,7 +6501,6 @@
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
@@ -6610,7 +6517,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -6623,7 +6529,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6646,7 +6551,6 @@
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
@@ -6682,7 +6586,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -6705,7 +6608,6 @@
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -6759,7 +6661,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
@@ -6769,7 +6670,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
@@ -6788,7 +6688,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -6814,7 +6713,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
@@ -6969,7 +6867,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
@@ -7071,7 +6968,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@@ -7097,7 +6993,6 @@
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
@@ -7116,7 +7011,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -7134,7 +7028,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7144,14 +7037,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -7166,7 +7057,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7179,7 +7069,6 @@
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -7192,7 +7081,6 @@
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz",
|
||||
"integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
@@ -1 +0,0 @@
|
||||
08d93e7b725ac442f35af92973131ea7
|
157
src/components/BOMActions.tsx
Normal file
157
src/components/BOMActions.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download, Upload, Check, AlertTriangle } from 'lucide-react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useClipboard } from '@/hooks/useClipboard';
|
||||
import { useMaterialsCalculations } from '@/hooks/useMaterialsCalculations';
|
||||
|
||||
interface BOMActionsProps {
|
||||
job: IndJob;
|
||||
onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void;
|
||||
}
|
||||
|
||||
const BOMActions: React.FC<BOMActionsProps> = ({ job, onImportBOM }) => {
|
||||
const { toast } = useToast();
|
||||
const { copying, copyToClipboard } = useClipboard();
|
||||
const { calculateMissingMaterials } = useMaterialsCalculations(job, job.billOfMaterials || []);
|
||||
|
||||
const importBillOfMaterials = async () => {
|
||||
if (!onImportBOM) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Import functionality is not available",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
const lines = clipboardText.split('\n').filter(line => line.trim());
|
||||
const items: { name: string; quantity: number }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/[\s\t]+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts.slice(0, -1).join(' ');
|
||||
const quantityPart = parts[parts.length - 1].replace(/,/g, '');
|
||||
const quantity = parseInt(quantityPart);
|
||||
if (name && !isNaN(quantity)) {
|
||||
items.push({ name, quantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
onImportBOM(job.id, items);
|
||||
toast({
|
||||
title: "BOM Imported",
|
||||
description: `Successfully imported ${items.length} items`,
|
||||
duration: 3000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "No Valid Items",
|
||||
description: "No valid items found in clipboard. Format: 'Item Name Quantity' per line",
|
||||
variant: "destructive",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to read from clipboard",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exportBillOfMaterials = async () => {
|
||||
if (!job.billOfMaterials?.length) {
|
||||
toast({
|
||||
title: "Nothing to Export",
|
||||
description: "No bill of materials found for this job",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = job.billOfMaterials
|
||||
.map(item => `${item.name}\t${item.quantity.toLocaleString()}`)
|
||||
.join('\n');
|
||||
|
||||
await copyToClipboard(text, 'bom', 'Bill of materials copied to clipboard');
|
||||
};
|
||||
|
||||
const exportMissingMaterials = async () => {
|
||||
const missingMaterials = calculateMissingMaterials();
|
||||
|
||||
if (missingMaterials.length === 0) {
|
||||
toast({
|
||||
title: "Nothing Missing",
|
||||
description: "All materials are satisfied for this job",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = missingMaterials
|
||||
.map(item => `${item.name}\t${item.quantity.toLocaleString()}`)
|
||||
.join('\n');
|
||||
|
||||
await copyToClipboard(text, 'missing', 'Missing materials copied to clipboard');
|
||||
};
|
||||
|
||||
const missingMaterials = calculateMissingMaterials();
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6 w-6"
|
||||
onClick={importBillOfMaterials}
|
||||
title="Import BOM from clipboard"
|
||||
data-no-navigate
|
||||
>
|
||||
<Download className="w-4 h-4 text-blue-400" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6 w-6"
|
||||
onClick={exportBillOfMaterials}
|
||||
disabled={!job.billOfMaterials?.length}
|
||||
title="Export BOM to clipboard"
|
||||
data-no-navigate
|
||||
>
|
||||
{copying === 'bom' ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 text-blue-400" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6 w-6"
|
||||
onClick={exportMissingMaterials}
|
||||
disabled={missingMaterials.length === 0}
|
||||
title="Export missing materials to clipboard"
|
||||
data-no-navigate
|
||||
>
|
||||
{copying === 'missing' ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BOMActions;
|
89
src/components/BatchExpenditureForm.tsx
Normal file
89
src/components/BatchExpenditureForm.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { IndTransactionRecordNoId } from '@/lib/pbtypes';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import BatchExpenditureHeader from './batch-expenditure/BatchExpenditureHeader';
|
||||
import PasteExpenditureInput from './batch-expenditure/PasteExpenditureInput';
|
||||
import ExpenditureStats from './batch-expenditure/ExpenditureStats';
|
||||
import ExpenditureTable from './batch-expenditure/ExpenditureTable';
|
||||
import ExpenditureActions from './batch-expenditure/ExpenditureActions';
|
||||
import { useBatchExpenditureLogic } from '@/hooks/useBatchExpenditureLogic';
|
||||
|
||||
interface BatchExpenditureFormProps {
|
||||
onClose: () => void;
|
||||
onTransactionsAssigned: (assignments: { jobId: string, transactions: IndTransactionRecordNoId[] }[]) => void;
|
||||
jobs: IndJob[];
|
||||
}
|
||||
|
||||
const BatchExpenditureForm: React.FC<BatchExpenditureFormProps> = ({ onClose, onTransactionsAssigned, jobs }) => {
|
||||
const {
|
||||
pastedData,
|
||||
transactionGroups,
|
||||
duplicatesFound,
|
||||
eligibleJobs,
|
||||
handlePaste,
|
||||
handleAssignJob,
|
||||
canSubmit
|
||||
} = useBatchExpenditureLogic(jobs);
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Group transactions by assigned job
|
||||
const assignments = transactionGroups
|
||||
.flatMap(group => group.transactions)
|
||||
.filter(tx => tx.assignedJobId)
|
||||
.reduce((acc, tx) => {
|
||||
const jobId = tx.assignedJobId!;
|
||||
const existing = acc.find(a => a.jobId === jobId);
|
||||
if (existing) {
|
||||
existing.transactions.push(tx);
|
||||
} else {
|
||||
acc.push({ jobId, transactions: [tx] });
|
||||
}
|
||||
return acc;
|
||||
}, [] as { jobId: string, transactions: IndTransactionRecordNoId[] }[]);
|
||||
|
||||
onTransactionsAssigned(assignments);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Card
|
||||
className="bg-gray-900 border-gray-700 text-white w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<BatchExpenditureHeader onClose={onClose} />
|
||||
<CardContent className="space-y-4">
|
||||
<PasteExpenditureInput pastedData={pastedData} onPaste={handlePaste} />
|
||||
|
||||
{transactionGroups.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<ExpenditureStats
|
||||
totalExpenditures={transactionGroups.length}
|
||||
duplicatesFound={duplicatesFound}
|
||||
/>
|
||||
|
||||
<ExpenditureActions
|
||||
onCancel={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
/>
|
||||
|
||||
<ExpenditureTable
|
||||
transactionGroups={transactionGroups}
|
||||
jobs={jobs}
|
||||
eligibleJobs={eligibleJobs}
|
||||
onAssignJob={handleAssignJob}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchExpenditureForm;
|
214
src/components/BatchImportDialog.tsx
Normal file
214
src/components/BatchImportDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IndJobStatusOptions } from '@/types/industry';
|
||||
import { parseTransactionLine, formatISK } from '@/utils/currency';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { FileUp } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function BatchImportDialog() {
|
||||
const { jobs, updateJob } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const [groupedTransactions, setGroupedTransactions] = useState<Record<string, any[]>>({});
|
||||
|
||||
const targetStatuses = [IndJobStatusOptions.Running, IndJobStatusOptions.Selling, IndJobStatusOptions.Tracked];
|
||||
const eligibleJobs = jobs.filter(job => targetStatuses.includes(job.status));
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!pasteText.trim()) return;
|
||||
|
||||
const lines = pasteText.split('\n').filter(line => line.trim());
|
||||
const transactions: any[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseTransactionLine(line);
|
||||
if (parsed) {
|
||||
transactions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
toast({
|
||||
title: "No Transactions Found",
|
||||
description: "No valid transactions found in the pasted text.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by item name
|
||||
const grouped: Record<string, any[]> = {};
|
||||
transactions.forEach(tx => {
|
||||
if (!grouped[tx.itemName]) {
|
||||
grouped[tx.itemName] = [];
|
||||
}
|
||||
grouped[tx.itemName].push(tx);
|
||||
});
|
||||
|
||||
setGroupedTransactions(grouped);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
let totalImported = 0;
|
||||
|
||||
Object.entries(groupedTransactions).forEach(([itemName, transactions]) => {
|
||||
// Find matching job
|
||||
const matchingJob = eligibleJobs.find(job =>
|
||||
job.outputItem.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
|
||||
if (matchingJob) {
|
||||
// Deduplicate against existing income transactions
|
||||
const existingIncome = matchingJob.income || [];
|
||||
const newTransactions = transactions.filter(newTx => {
|
||||
return !existingIncome.some(existing =>
|
||||
existing.date === newTx.date &&
|
||||
existing.itemName === newTx.itemName &&
|
||||
existing.quantity === newTx.quantity &&
|
||||
existing.totalPrice === newTx.totalPrice &&
|
||||
existing.buyer === newTx.buyer
|
||||
);
|
||||
});
|
||||
|
||||
if (newTransactions.length > 0) {
|
||||
const updatedIncome = [...existingIncome];
|
||||
newTransactions.forEach(tx => {
|
||||
updatedIncome.push({
|
||||
...tx,
|
||||
id: crypto.randomUUID(),
|
||||
job: matchingJob.id,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
updateJob(matchingJob.id, { income: updatedIncome });
|
||||
totalImported += newTransactions.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Batch Import Complete",
|
||||
description: `Imported ${totalImported} transactions across ${Object.keys(groupedTransactions).length} items.`,
|
||||
});
|
||||
|
||||
setPasteText('');
|
||||
setGroupedTransactions({});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const getJobForItem = (itemName: string) => {
|
||||
return eligibleJobs.find(job =>
|
||||
job.outputItem.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Batch Import Sales
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Batch Import Sales Transactions</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Paste sale transaction data. Transactions will be automatically grouped by item name and matched to running/selling/tracked jobs.
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="Paste EVE sales transaction data here..."
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleAnalyze} disabled={!pasteText.trim()}>
|
||||
Analyze Transactions
|
||||
</Button>
|
||||
{Object.keys(groupedTransactions).length > 0 && (
|
||||
<Button onClick={handleImport}>
|
||||
Import All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(groupedTransactions).length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Import Preview</h3>
|
||||
{Object.entries(groupedTransactions).map(([itemName, transactions]) => {
|
||||
const matchingJob = getJobForItem(itemName);
|
||||
const totalValue = transactions.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const totalQuantity = transactions.reduce((sum, tx) => sum + tx.quantity, 0);
|
||||
|
||||
return (
|
||||
<div key={itemName} className="border rounded p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{itemName}</h4>
|
||||
{matchingJob ? (
|
||||
<Badge variant="outline" className="text-success">
|
||||
→ {matchingJob.outputItem} ({matchingJob.status})
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">No matching job</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{transactions.length} transactions, {totalQuantity.toLocaleString()} units, {formatISK(totalValue)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Quantity</TableHead>
|
||||
<TableHead>Unit Price</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Buyer</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.slice(0, 5).map((tx, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{new Date(tx.date).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{tx.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>{formatISK(tx.unitPrice)}</TableCell>
|
||||
<TableCell>{formatISK(tx.totalPrice)}</TableCell>
|
||||
<TableCell>{tx.buyer || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{transactions.length > 5 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
... and {transactions.length - 5} more transactions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -1,14 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { parseTransactionLine, formatISK } from '@/utils/priceUtils';
|
||||
import { IndTransactionRecordNoId, IndJobStatusOptions } from '@/lib/pbtypes';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { IndTransactionRecordNoId } from '@/lib/pbtypes';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { X } from 'lucide-react';
|
||||
import { useBatchTransactionLogic } from '@/hooks/useBatchTransactionLogic';
|
||||
import BatchTransactionHeader from '@/components/batch-transaction/BatchTransactionHeader';
|
||||
import PasteTransactionInput from '@/components/batch-transaction/PasteTransactionInput';
|
||||
import TransactionStats from '@/components/batch-transaction/TransactionStats';
|
||||
import TransactionTable from '@/components/batch-transaction/TransactionTable';
|
||||
import TransactionActions from '@/components/batch-transaction/TransactionActions';
|
||||
|
||||
interface BatchTransactionFormProps {
|
||||
onClose: () => void;
|
||||
@@ -16,326 +15,62 @@ interface BatchTransactionFormProps {
|
||||
jobs: IndJob[];
|
||||
}
|
||||
|
||||
interface ParsedTransaction extends IndTransactionRecordNoId {
|
||||
assignedJobId?: string;
|
||||
isDuplicate?: boolean;
|
||||
}
|
||||
|
||||
interface TransactionGroup {
|
||||
itemName: string;
|
||||
transactions: ParsedTransaction[];
|
||||
totalQuantity: number;
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
const BatchTransactionForm: React.FC<BatchTransactionFormProps> = ({ onClose, onTransactionsAssigned, jobs }) => {
|
||||
const [pastedData, setPastedData] = useState('');
|
||||
const [transactionGroups, setTransactionGroups] = useState<TransactionGroup[]>([]);
|
||||
const [duplicatesFound, setDuplicatesFound] = useState(0);
|
||||
|
||||
// Filter jobs that are either running, selling, or tracked
|
||||
const eligibleJobs = jobs.filter(job =>
|
||||
job.status === IndJobStatusOptions.Running ||
|
||||
job.status === IndJobStatusOptions.Selling ||
|
||||
job.status === IndJobStatusOptions.Tracked
|
||||
);
|
||||
|
||||
const findMatchingJob = (itemName: string): string | undefined => {
|
||||
// First try exact match
|
||||
const exactMatch = eligibleJobs.find(job => job.outputItem === itemName);
|
||||
if (exactMatch) return exactMatch.id;
|
||||
|
||||
// Then try case-insensitive match
|
||||
const caseInsensitiveMatch = eligibleJobs.find(job =>
|
||||
job.outputItem.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
if (caseInsensitiveMatch) return caseInsensitiveMatch.id;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeDate = (dateStr: string): string => {
|
||||
// Convert any ISO date string to consistent format with space
|
||||
return dateStr.replace('T', ' ');
|
||||
};
|
||||
|
||||
const createTransactionKey = (parsed: ReturnType<typeof parseTransactionLine>): string => {
|
||||
if (!parsed) return '';
|
||||
const key = [
|
||||
normalizeDate(parsed.date.toISOString()),
|
||||
parsed.itemName,
|
||||
parsed.quantity.toString(),
|
||||
parsed.totalAmount.toString(),
|
||||
parsed.buyer,
|
||||
parsed.location
|
||||
].join('|');
|
||||
console.log('Created key from parsed transaction:', {
|
||||
key,
|
||||
date: normalizeDate(parsed.date.toISOString()),
|
||||
itemName: parsed.itemName,
|
||||
quantity: parsed.quantity,
|
||||
totalAmount: parsed.totalAmount,
|
||||
buyer: parsed.buyer,
|
||||
location: parsed.location
|
||||
});
|
||||
return key;
|
||||
};
|
||||
|
||||
const createTransactionKeyFromRecord = (tx: IndTransactionRecordNoId): string => {
|
||||
const key = [
|
||||
normalizeDate(tx.date),
|
||||
tx.itemName,
|
||||
tx.quantity.toString(),
|
||||
tx.totalPrice.toString(),
|
||||
tx.buyer,
|
||||
tx.location
|
||||
].join('|');
|
||||
console.log('Created key from existing transaction:', {
|
||||
key,
|
||||
date: normalizeDate(tx.date),
|
||||
itemName: tx.itemName,
|
||||
quantity: tx.quantity,
|
||||
totalPrice: tx.totalPrice,
|
||||
buyer: tx.buyer,
|
||||
location: tx.location
|
||||
});
|
||||
return key;
|
||||
};
|
||||
|
||||
const handlePaste = (value: string) => {
|
||||
setPastedData(value);
|
||||
const lines = value.trim().split('\n');
|
||||
const transactions: ParsedTransaction[] = [];
|
||||
const seenTransactions = new Set<string>();
|
||||
const pasteTransactionMap = new Map<string, ParsedTransaction>();
|
||||
|
||||
// Pre-populate seenTransactions with existing transactions from jobs
|
||||
eligibleJobs.forEach(job => {
|
||||
job.income.forEach(tx => {
|
||||
const key = createTransactionKeyFromRecord(tx);
|
||||
seenTransactions.add(key);
|
||||
});
|
||||
});
|
||||
|
||||
let duplicates = 0;
|
||||
lines.forEach((line, index) => {
|
||||
const parsed = parseTransactionLine(line);
|
||||
if (parsed) {
|
||||
const transactionKey = createTransactionKey(parsed);
|
||||
const isDuplicate = seenTransactions.has(transactionKey);
|
||||
|
||||
if (isDuplicate) {
|
||||
duplicates++;
|
||||
}
|
||||
|
||||
// Check if this exact transaction already exists in our paste data
|
||||
if (pasteTransactionMap.has(transactionKey)) {
|
||||
// Merge with existing transaction in paste
|
||||
const existing = pasteTransactionMap.get(transactionKey)!;
|
||||
existing.quantity += parsed.quantity;
|
||||
existing.totalPrice += Math.abs(parsed.totalAmount);
|
||||
} else {
|
||||
// Add new transaction
|
||||
const newTransaction: ParsedTransaction = {
|
||||
date: parsed.date.toISOString(),
|
||||
quantity: parsed.quantity,
|
||||
itemName: parsed.itemName,
|
||||
unitPrice: parsed.unitPrice,
|
||||
totalPrice: Math.abs(parsed.totalAmount),
|
||||
buyer: parsed.buyer,
|
||||
location: parsed.location,
|
||||
corporation: parsed.corporation,
|
||||
wallet: parsed.wallet,
|
||||
assignedJobId: !isDuplicate ? findMatchingJob(parsed.itemName) : undefined,
|
||||
isDuplicate
|
||||
};
|
||||
pasteTransactionMap.set(transactionKey, newTransaction);
|
||||
|
||||
if (!isDuplicate) {
|
||||
seenTransactions.add(transactionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert map to array for display - each transaction is individual
|
||||
const transactionList = Array.from(pasteTransactionMap.values());
|
||||
setDuplicatesFound(duplicates);
|
||||
|
||||
// Create individual transaction groups (no grouping by item name)
|
||||
const groups = transactionList.map(tx => ({
|
||||
itemName: tx.itemName,
|
||||
transactions: [tx],
|
||||
totalQuantity: tx.quantity,
|
||||
totalValue: tx.totalPrice
|
||||
}));
|
||||
|
||||
setTransactionGroups(groups);
|
||||
};
|
||||
|
||||
const handleAssignJob = (groupIndex: number, jobId: string) => {
|
||||
setTransactionGroups(prev => {
|
||||
const newGroups = [...prev];
|
||||
newGroups[groupIndex].transactions.forEach(tx => {
|
||||
tx.assignedJobId = jobId;
|
||||
});
|
||||
return newGroups;
|
||||
});
|
||||
};
|
||||
const {
|
||||
pastedData,
|
||||
transactionGroups,
|
||||
duplicatesFound,
|
||||
eligibleJobs,
|
||||
handlePaste,
|
||||
handleAssignJob,
|
||||
getAssignments,
|
||||
canSubmit
|
||||
} = useBatchTransactionLogic(jobs);
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Group transactions by assigned job
|
||||
const assignments = transactionGroups
|
||||
.flatMap(group => group.transactions)
|
||||
.filter(tx => tx.assignedJobId)
|
||||
.reduce((acc, tx) => {
|
||||
const jobId = tx.assignedJobId!;
|
||||
const existing = acc.find(a => a.jobId === jobId);
|
||||
if (existing) {
|
||||
existing.transactions.push(tx);
|
||||
} else {
|
||||
acc.push({ jobId, transactions: [tx] });
|
||||
}
|
||||
return acc;
|
||||
}, [] as { jobId: string, transactions: IndTransactionRecordNoId[] }[]);
|
||||
|
||||
const assignments = getAssignments();
|
||||
onTransactionsAssigned(assignments);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const allAssigned = transactionGroups.every(group =>
|
||||
group.transactions.every(tx => tx.assignedJobId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="bg-gray-900 border-gray-700 text-white w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="flex flex-row items-center justify-between sticky top-0 bg-gray-900 border-b border-gray-700 z-10">
|
||||
<CardTitle className="text-blue-400">Batch Transaction Assignment</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Card
|
||||
className="bg-gray-900 border-gray-700 text-white w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<BatchTransactionHeader onClose={onClose} />
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300">
|
||||
Paste EVE transaction data:
|
||||
</label>
|
||||
<Textarea
|
||||
value={pastedData}
|
||||
onChange={(e) => handlePaste(e.target.value)}
|
||||
placeholder="Paste your EVE transaction data here..."
|
||||
className="min-h-32 bg-gray-800 border-gray-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
<PasteTransactionInput
|
||||
pastedData={pastedData}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
{transactionGroups.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="text-blue-400 border-blue-400">
|
||||
{transactionGroups.length} transactions found
|
||||
</Badge>
|
||||
{duplicatesFound > 0 && (
|
||||
<Badge variant="outline" className="text-yellow-400 border-yellow-400">
|
||||
{duplicatesFound} duplicates found
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<TransactionStats
|
||||
transactionCount={transactionGroups.length}
|
||||
duplicatesFound={duplicatesFound}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-gray-700">
|
||||
<TableHead className="text-gray-300">Item</TableHead>
|
||||
<TableHead className="text-gray-300">Quantity</TableHead>
|
||||
<TableHead className="text-gray-300">Total Value</TableHead>
|
||||
<TableHead className="text-gray-300">Assign To Job</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactionGroups.map((group, index) => {
|
||||
const autoAssigned = group.transactions[0]?.assignedJobId;
|
||||
const isDuplicate = group.transactions[0]?.isDuplicate;
|
||||
const matchingJob = autoAssigned ? jobs.find(j => j.id === autoAssigned) : undefined;
|
||||
<TransactionActions
|
||||
onCancel={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
canSubmit={canSubmit}
|
||||
/>
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={group.itemName}
|
||||
className={`border-gray-700 ${isDuplicate ? 'bg-red-900/30' : ''}`}
|
||||
>
|
||||
<TableCell className="text-white flex items-center gap-2">
|
||||
{group.itemName}
|
||||
{isDuplicate && (
|
||||
<Badge variant="destructive" className="bg-red-600">
|
||||
Duplicate
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{group.totalQuantity.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-green-400">
|
||||
{formatISK(group.totalValue)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isDuplicate ? (
|
||||
<div className="text-red-400 text-sm">
|
||||
Transaction already exists
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={group.transactions[0]?.assignedJobId || ''}
|
||||
onValueChange={(value) => handleAssignJob(index, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`bg-gray-800 border-gray-600 text-white ${autoAssigned ? 'border-green-600' : ''}`}
|
||||
>
|
||||
<SelectValue placeholder={autoAssigned ? `Auto-assigned to ${matchingJob?.outputItem}` : 'Select a job'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-800 border-gray-600">
|
||||
{eligibleJobs
|
||||
.filter(job => job.outputItem.includes(group.itemName) || job.status === 'Tracked')
|
||||
.map(job => (
|
||||
<SelectItem
|
||||
key={job.id}
|
||||
value={job.id}
|
||||
className="text-white"
|
||||
>
|
||||
{job.outputItem} ({job.status})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!transactionGroups.some(g => g.transactions.some(tx => !tx.isDuplicate && tx.assignedJobId))}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Assign Transactions
|
||||
</Button>
|
||||
</div>
|
||||
<TransactionTable
|
||||
transactionGroups={transactionGroups}
|
||||
jobs={jobs}
|
||||
eligibleJobs={eligibleJobs}
|
||||
onAssignJob={handleAssignJob}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -344,4 +79,4 @@ const BatchTransactionForm: React.FC<BatchTransactionFormProps> = ({ onClose, on
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchTransactionForm;
|
||||
export default BatchTransactionForm;
|
||||
|
160
src/components/BillOfMaterialsManager.tsx
Normal file
160
src/components/BillOfMaterialsManager.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { IndJob } from '@/types/industry';
|
||||
import { parseBillOfMaterials, exportBillOfMaterials } from '@/utils/currency';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { Plus, Trash2, FileDown } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface BillOfMaterialsManagerProps {
|
||||
job: IndJob;
|
||||
}
|
||||
|
||||
export function BillOfMaterialsManager({ job }: BillOfMaterialsManagerProps) {
|
||||
const { updateJob, createMultipleBillItems } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
|
||||
const handleImport = () => {
|
||||
if (!pasteText.trim()) return;
|
||||
|
||||
const materials = parseBillOfMaterials(pasteText);
|
||||
if (materials.length === 0) {
|
||||
toast({
|
||||
title: "Import Failed",
|
||||
description: "No valid materials found in the pasted text.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate against existing materials
|
||||
const existingMaterials = job.billOfMaterials || [];
|
||||
const newMaterials = materials.filter(newMat => {
|
||||
return !existingMaterials.some(existing => existing.name === newMat.name);
|
||||
});
|
||||
|
||||
if (newMaterials.length === 0) {
|
||||
toast({
|
||||
title: "No New Materials",
|
||||
description: "All materials already exist in this job.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedMaterials = [...existingMaterials];
|
||||
newMaterials.forEach(material => {
|
||||
updatedMaterials.push({
|
||||
...material,
|
||||
id: crypto.randomUUID(),
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
updateJob(job.id, { billOfMaterials: updatedMaterials });
|
||||
|
||||
toast({
|
||||
title: "Import Successful",
|
||||
description: `Added ${newMaterials.length} new materials.`,
|
||||
});
|
||||
|
||||
setPasteText('');
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const materials = job.billOfMaterials || [];
|
||||
if (materials.length === 0) {
|
||||
toast({
|
||||
title: "No Materials",
|
||||
description: "No bill of materials to export.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const exportText = exportBillOfMaterials(materials);
|
||||
navigator.clipboard.writeText(exportText).then(() => {
|
||||
toast({
|
||||
title: "Exported",
|
||||
description: "Bill of materials copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteMaterial = (materialId: string) => {
|
||||
const updatedMaterials = (job.billOfMaterials || []).filter(m => m.id !== materialId);
|
||||
updateJob(job.id, { billOfMaterials: updatedMaterials });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
Bill of Materials Management
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<FileDown className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder="Paste bill of materials here (Material Name [tab] Quantity format)..."
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<Button onClick={handleImport} disabled={!pasteText.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Import Materials
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Materials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(job.billOfMaterials?.length || 0) > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Material</TableHead>
|
||||
<TableHead>Quantity</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{job.billOfMaterials?.map(material => (
|
||||
<TableRow key={material.id}>
|
||||
<TableCell>{material.name}</TableCell>
|
||||
<TableCell>{material.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteMaterial(material.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">No materials defined</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
144
src/components/CreateJobDialog.tsx
Normal file
144
src/components/CreateJobDialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { IndJobStatusOptions } from '@/types/industry';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function CreateJobDialog() {
|
||||
const { createJob } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
outputItem: '',
|
||||
outputQuantity: 1,
|
||||
status: IndJobStatusOptions.Planned,
|
||||
projectedCost: 0,
|
||||
projectedRevenue: 0,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.outputItem.trim()) {
|
||||
toast({
|
||||
title: "Validation Error",
|
||||
description: "Output item name is required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createJob({
|
||||
outputItem: formData.outputItem,
|
||||
outputQuantity: formData.outputQuantity,
|
||||
status: formData.status,
|
||||
projectedCost: formData.projectedCost,
|
||||
projectedRevenue: formData.projectedRevenue,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Job Created",
|
||||
description: `Created job for ${formData.outputItem}`,
|
||||
});
|
||||
|
||||
setFormData({
|
||||
outputItem: '',
|
||||
outputQuantity: 1,
|
||||
status: IndJobStatusOptions.Planned,
|
||||
projectedCost: 0,
|
||||
projectedRevenue: 0,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Job
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Job</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="outputItem">Output Item</Label>
|
||||
<Input
|
||||
id="outputItem"
|
||||
value={formData.outputItem}
|
||||
onChange={(e) => setFormData({ ...formData, outputItem: e.target.value })}
|
||||
placeholder="e.g., Inertial Stabilizers I"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="outputQuantity">Quantity</Label>
|
||||
<Input
|
||||
id="outputQuantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.outputQuantity}
|
||||
onChange={(e) => setFormData({ ...formData, outputQuantity: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData({ ...formData, status: value as IndJobStatusOptions })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(IndJobStatusOptions).map(status => (
|
||||
<SelectItem key={status} value={status}>{status}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="projectedCost">Projected Cost (ISK)</Label>
|
||||
<Input
|
||||
id="projectedCost"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.projectedCost}
|
||||
onChange={(e) => setFormData({ ...formData, projectedCost: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="projectedRevenue">Projected Revenue (ISK)</Label>
|
||||
<Input
|
||||
id="projectedRevenue"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.projectedRevenue}
|
||||
onChange={(e) => setFormData({ ...formData, projectedRevenue: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create Job</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
103
src/components/DashboardStats.tsx
Normal file
103
src/components/DashboardStats.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Factory, TrendingUp, Briefcase, BarChart3 } from 'lucide-react';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import OptimizedRecapPopover from './OptimizedRecapPopover';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
interface DashboardStatsProps {
|
||||
totalJobs: number;
|
||||
totalRevenue: number;
|
||||
totalProfit: number;
|
||||
jobs: IndJob[];
|
||||
calculateJobRevenue: (job: IndJob) => number;
|
||||
calculateJobProfit: (job: IndJob) => number;
|
||||
onTotalRevenueChart: () => void;
|
||||
onTotalProfitChart: () => void;
|
||||
}
|
||||
|
||||
const DashboardStats = ({
|
||||
totalJobs,
|
||||
totalRevenue,
|
||||
totalProfit,
|
||||
jobs,
|
||||
calculateJobRevenue,
|
||||
calculateJobProfit,
|
||||
onTotalRevenueChart,
|
||||
onTotalProfitChart
|
||||
}: DashboardStatsProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Factory className="w-5 h-5" />
|
||||
Active Jobs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalJobs}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
Total Revenue
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 ml-auto"
|
||||
onClick={onTotalRevenueChart}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OptimizedRecapPopover
|
||||
title="Revenue Breakdown"
|
||||
jobs={jobs}
|
||||
calculateJobValue={calculateJobRevenue}
|
||||
>
|
||||
<div className="text-2xl font-bold text-green-400 cursor-pointer hover:text-green-300 transition-colors">
|
||||
{formatISK(totalRevenue)}
|
||||
</div>
|
||||
</OptimizedRecapPopover>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Briefcase className="w-5 h-5" />
|
||||
Total Profit
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 ml-auto"
|
||||
onClick={onTotalProfitChart}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OptimizedRecapPopover
|
||||
title="Profit Breakdown"
|
||||
jobs={jobs}
|
||||
calculateJobValue={calculateJobProfit}
|
||||
>
|
||||
<div className={`text-2xl font-bold cursor-pointer transition-colors ${totalProfit >= 0 ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300'}`}>
|
||||
{formatISK(totalProfit)}
|
||||
</div>
|
||||
</OptimizedRecapPopover>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardStats;
|
55
src/components/EditableField.tsx
Normal file
55
src/components/EditableField.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EditableFieldProps {
|
||||
value: string | number;
|
||||
onSave: (value: string) => void;
|
||||
type?: 'text' | 'number' | 'date';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EditableField({ value, onSave, type = 'text', className }: EditableFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(String(value));
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(editValue);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditValue(String(value));
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
type={type}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-6 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 px-1 py-0.5 rounded min-h-[24px] flex items-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{value || <span className="text-muted-foreground italic">Click to edit</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
69
src/components/EditableProduced.tsx
Normal file
69
src/components/EditableProduced.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
interface EditableProducedProps {
|
||||
job: IndJob;
|
||||
onUpdateProduced?: (jobId: string, produced: number) => void;
|
||||
}
|
||||
|
||||
const EditableProduced: React.FC<EditableProducedProps> = ({ job, onUpdateProduced }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(job.produced?.toString() || '0');
|
||||
|
||||
const handleUpdate = () => {
|
||||
const newValue = parseInt(value);
|
||||
if (!isNaN(newValue) && onUpdateProduced) {
|
||||
onUpdateProduced(job.id, newValue);
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
setValue(job.produced?.toString() || '0');
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUpdate();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
setValue(job.produced?.toString() || '0');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (job.status !== 'Closed') {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing && job.status !== 'Closed') {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={handleUpdate}
|
||||
onKeyDown={handleKeyPress}
|
||||
className="w-24 h-5 px-2 py-0 inline-block bg-gray-800 border-gray-600 text-white text-xs leading-5"
|
||||
min="0"
|
||||
autoFocus
|
||||
data-no-navigate
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={handleClick}
|
||||
className={`inline-block w-20 h-5 leading-5 text-left ${job.status !== 'Closed' ? "cursor-pointer hover:text-blue-400" : ""}`}
|
||||
title={job.status !== 'Closed' ? "Click to edit" : undefined}
|
||||
data-no-navigate
|
||||
>
|
||||
{(job.produced || 0).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableProduced;
|
@@ -1,6 +1,9 @@
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { getStatusBackgroundColor } from '@/utils/jobStatusUtils';
|
||||
import { jobNeedsAttention, getAttentionGlowClasses } from '@/utils/jobAttentionUtils';
|
||||
import JobCardHeader from './JobCardHeader';
|
||||
import JobCardDetails from './JobCardDetails';
|
||||
import JobCardMetrics from './JobCardMetrics';
|
||||
@@ -23,37 +26,22 @@ const JobCard: React.FC<JobCardProps> = ({
|
||||
isTracked = false
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getStatusBackgroundColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Planned': return 'bg-gray-600/20';
|
||||
case 'Acquisition': return 'bg-yellow-600/20';
|
||||
case 'Running': return 'bg-blue-600/20';
|
||||
case 'Done': return 'bg-purple-600/20';
|
||||
case 'Selling': return 'bg-orange-600/20';
|
||||
case 'Closed': return 'bg-green-600/20';
|
||||
case 'Tracked': return 'bg-cyan-600/20';
|
||||
default: return 'bg-gray-600/20';
|
||||
}
|
||||
};
|
||||
const needsAttention = jobNeedsAttention(job);
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// Check if the click target or any of its parents has the data-no-navigate attribute
|
||||
const target = e.target as HTMLElement;
|
||||
const hasNoNavigate = target.closest('[data-no-navigate]');
|
||||
|
||||
|
||||
if (hasNoNavigate) {
|
||||
// Don't navigate if clicking on elements marked as non-navigating
|
||||
return;
|
||||
}
|
||||
|
||||
// Only navigate if clicking on areas that aren't marked as non-navigating
|
||||
|
||||
navigate(`/${job.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`bg-gray-900 border-gray-700 text-white h-full flex flex-col cursor-pointer hover:bg-gray-800/50 transition-colors ${job.status === 'Tracked' ? 'border-l-4 border-l-cyan-600' : ''} ${getStatusBackgroundColor(job.status)}`}
|
||||
className={`bg-gray-900 border-gray-700 text-white h-full flex flex-col cursor-pointer hover:bg-gray-800/50 transition-colors ${job.status === 'Tracked' ? 'border-l-4 border-l-cyan-600' : ''} ${getStatusBackgroundColor(job.status)} ${needsAttention ? getAttentionGlowClasses() : ''}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardHeader className="flex-shrink-0">
|
||||
|
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Calendar, Factory, Clock } from 'lucide-react';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calendar, Factory, Clock, Copy, DollarSign } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useClipboard } from '@/hooks/useClipboard';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import { formatDuration, calculateRemainingTime } from '@/utils/timeUtils';
|
||||
|
||||
interface JobCardDetailsProps {
|
||||
job: IndJob;
|
||||
@@ -13,9 +16,26 @@ interface JobCardDetailsProps {
|
||||
const JobCardDetails: React.FC<JobCardDetailsProps> = ({ job }) => {
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [tempValues, setTempValues] = useState<{ [key: string]: string }>({});
|
||||
const [remainingTime, setRemainingTime] = useState<number>(0);
|
||||
const { updateJob } = useJobs();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { copying, copyToClipboard } = useClipboard();
|
||||
|
||||
// Update remaining time for running jobs
|
||||
useEffect(() => {
|
||||
if (job.status === 'Running' && job.jobStart && job.runtime) {
|
||||
const updateRemainingTime = () => {
|
||||
const remaining = calculateRemainingTime(job.jobStart, job.runtime);
|
||||
setRemainingTime(remaining);
|
||||
};
|
||||
|
||||
updateRemainingTime();
|
||||
const interval = setInterval(updateRemainingTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [job.status, job.jobStart, job.runtime]);
|
||||
|
||||
const formatDateTime = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return 'Not set';
|
||||
return new Date(dateString).toLocaleString('en-CA', {
|
||||
@@ -27,16 +47,23 @@ const JobCardDetails: React.FC<JobCardDetailsProps> = ({ job }) => {
|
||||
}).replace(',', '');
|
||||
};
|
||||
|
||||
const handleFieldClick = (fieldName: string, currentValue: string | null, e: React.MouseEvent) => {
|
||||
setEditingField(fieldName);
|
||||
setTempValues({ ...tempValues, [fieldName]: currentValue || '' });
|
||||
const handleJobIdClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(job.id, 'id', 'Job ID copied to clipboard');
|
||||
};
|
||||
|
||||
const handleFieldUpdate = async (fieldName: string, value: string) => {
|
||||
try {
|
||||
const dateValue = value ? new Date(value).toISOString() : null;
|
||||
await updateJob(job.id, { [fieldName]: dateValue });
|
||||
let updateValue: any;
|
||||
if (fieldName === 'parallel') {
|
||||
updateValue = Math.max(1, parseInt(value) || 1);
|
||||
} else {
|
||||
updateValue = value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
await updateJob(job.id, { [fieldName]: updateValue });
|
||||
setEditingField(null);
|
||||
setTempValues({});
|
||||
toast({
|
||||
title: "Updated",
|
||||
description: `${fieldName} updated successfully`,
|
||||
@@ -61,47 +88,84 @@ const JobCardDetails: React.FC<JobCardDetailsProps> = ({ job }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateForInput = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const handleBlur = (fieldName: string) => {
|
||||
const value = tempValues[fieldName];
|
||||
if (value !== (job[fieldName as keyof IndJob] || '')) {
|
||||
handleFieldUpdate(fieldName, value);
|
||||
} else {
|
||||
setEditingField(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (fieldName: string, value: string | null, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Allow editing regardless of whether value exists or not
|
||||
setEditingField(fieldName);
|
||||
setTempValues({ ...tempValues, [fieldName]: formatDateForInput(value) });
|
||||
};
|
||||
|
||||
const DateField = ({ label, value, fieldName, icon }: { label: string; value: string | null; fieldName: string; icon: React.ReactNode }) => (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
{icon}
|
||||
<span className="w-16">{label}:</span>
|
||||
{editingField === fieldName ? (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={tempValues[fieldName] || ''}
|
||||
onChange={(e) => setTempValues({ ...tempValues, [fieldName]: e.target.value })}
|
||||
onBlur={() => handleFieldUpdate(fieldName, tempValues[fieldName])}
|
||||
onKeyDown={(e) => handleKeyPress(fieldName, e)}
|
||||
className="h-6 px-2 py-1 bg-gray-800 border-gray-600 text-white text-xs flex-1 min-w-0"
|
||||
autoFocus
|
||||
data-no-navigate
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => handleFieldClick(fieldName, value, e)}
|
||||
className="cursor-pointer hover:text-blue-400 flex-1 min-w-0 h-6 flex items-center"
|
||||
title="Click to edit"
|
||||
data-no-navigate
|
||||
>
|
||||
{formatDateTime(value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
{icon}
|
||||
<span>{label}:</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{editingField === fieldName ? (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={tempValues[fieldName] || ''}
|
||||
onChange={(e) => setTempValues({ ...tempValues, [fieldName]: e.target.value })}
|
||||
onBlur={() => handleBlur(fieldName)}
|
||||
onKeyDown={(e) => handleKeyPress(fieldName, e)}
|
||||
className="h-6 px-2 py-1 bg-gray-800 border-gray-600 text-white text-sm w-full"
|
||||
autoFocus
|
||||
data-no-navigate
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => handleClick(fieldName, value, e)}
|
||||
className="cursor-pointer hover:text-blue-400 h-6 flex items-center text-white text-sm w-full"
|
||||
title="Click to edit"
|
||||
data-no-navigate
|
||||
>
|
||||
{formatDateTime(value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div className="grid gap-x-4 gap-y-2" style={{ gridTemplateColumns: 'auto 1fr auto 1fr' }}>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Factory className="w-4 h-4" />
|
||||
<span className="w-16">Job ID:</span>
|
||||
<span>{job.id}</span>
|
||||
<span>Job ID:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="cursor-pointer hover:text-blue-400 transition-colors inline-flex items-center gap-1 text-sm text-white"
|
||||
onClick={handleJobIdClick}
|
||||
title="Click to copy job ID"
|
||||
data-no-navigate
|
||||
>
|
||||
{job.id}
|
||||
{copying === 'id' && <Copy className="w-3 h-3 text-green-400" />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="w-16">Created:</span>
|
||||
<span>{formatDateTime(job.created)}</span>
|
||||
<span>Created:</span>
|
||||
</div>
|
||||
<div className="text-sm text-white">
|
||||
{formatDateTime(job.created)}
|
||||
</div>
|
||||
|
||||
<DateField
|
||||
@@ -131,35 +195,225 @@ const JobCardDetails: React.FC<JobCardDetailsProps> = ({ job }) => {
|
||||
fieldName="saleEnd"
|
||||
icon={<Calendar className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
{job.runtime && job.runtime > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Runtime:</span>
|
||||
</div>
|
||||
<div className="text-sm text-white flex items-center gap-1">
|
||||
{formatDuration(job.runtime / (job.parallel || 1))}
|
||||
<span className="text-gray-400">(</span>
|
||||
{editingField === 'parallel' ? (
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={tempValues.parallel || job.parallel?.toString() || '1'}
|
||||
onChange={(e) => setTempValues({ ...tempValues, parallel: e.target.value })}
|
||||
onBlur={() => handleFieldUpdate('parallel', tempValues.parallel || '1')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleFieldUpdate('parallel', tempValues.parallel || '1');
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingField(null);
|
||||
setTempValues({});
|
||||
}
|
||||
}}
|
||||
className="w-12 h-5 px-1 py-0 text-xs bg-gray-800 border-gray-600"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingField('parallel');
|
||||
setTempValues({ parallel: job.parallel?.toString() || '1' });
|
||||
}}
|
||||
>
|
||||
{job.parallel || 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400">)</span>
|
||||
</div>
|
||||
|
||||
{job.status === 'Running' && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Remaining:</span>
|
||||
</div>
|
||||
<div className="text-sm text-green-400">
|
||||
{formatDuration(remainingTime)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.billOfMaterials && job.billOfMaterials.length > 0 && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div
|
||||
className="text-sm text-gray-400 mt-2 cursor-pointer hover:text-blue-400"
|
||||
data-no-navigate
|
||||
>
|
||||
BOM: {job.billOfMaterials.length} items (hover to view)
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 bg-gray-800/50 border-gray-600 text-white">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-blue-400">Bill of Materials</h4>
|
||||
<div className="text-xs space-y-1 max-h-48 overflow-y-auto">
|
||||
{job.billOfMaterials.map((item, index) => (
|
||||
<div key={index} className="flex justify-between">
|
||||
<span>{item.name}</span>
|
||||
<span className="text-gray-300">{item.quantity.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{job.projectedRevenue > 0 && job.produced > 0 && (
|
||||
<div className="mt-2">
|
||||
<PriceDisplay job={job} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PriceDisplayProps {
|
||||
job: IndJob;
|
||||
}
|
||||
|
||||
const PriceDisplay: React.FC<PriceDisplayProps> = ({ job }) => {
|
||||
const { copying, copyToClipboard } = useClipboard();
|
||||
const [salesTax, setSalesTax] = useState(() => parseFloat(localStorage.getItem('salesTax') || '0') / 100);
|
||||
|
||||
// Listen for storage changes to update tax rate
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
setSalesTax(parseFloat(localStorage.getItem('salesTax') || '0') / 100);
|
||||
};
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, []);
|
||||
|
||||
const roundToSignificantDigits = (num: number, digits: number = 4): number => {
|
||||
if (num === 0) return 0;
|
||||
const magnitude = Math.floor(Math.log10(Math.abs(num)));
|
||||
const factor = Math.pow(10, digits - 1 - magnitude);
|
||||
return Math.round(num * factor) / factor;
|
||||
};
|
||||
|
||||
// Calculate total costs and income
|
||||
const totalCosts = job.expenditures?.reduce((sum, tx) => sum + tx.totalPrice, 0) || 0;
|
||||
const totalIncome = job.income?.reduce((sum, tx) => sum + tx.totalPrice, 0) || 0;
|
||||
const itemsSold = job.income?.reduce((sum, tx) => sum + tx.quantity, 0) || 0;
|
||||
const itemsRemaining = (job.produced || 0) - itemsSold;
|
||||
|
||||
// Original calculations (based on full revenue and costs)
|
||||
const targetPricePerUnit = job.projectedRevenue / job.produced;
|
||||
const targetPriceWithTax = roundToSignificantDigits(targetPricePerUnit * (1 + salesTax));
|
||||
|
||||
const breakEvenPricePerUnit = totalCosts / job.produced;
|
||||
const breakEvenPriceWithTax = roundToSignificantDigits(breakEvenPricePerUnit * (1 + salesTax));
|
||||
|
||||
// Adjusted calculations (based on remaining revenue and uncovered costs)
|
||||
const remainingRevenue = job.projectedRevenue - totalIncome;
|
||||
const uncoveredCosts = totalCosts - totalIncome;
|
||||
|
||||
const adjustedTargetPricePerUnit = itemsRemaining > 0 ? remainingRevenue / itemsRemaining : 0;
|
||||
const adjustedTargetPriceWithTax = roundToSignificantDigits(adjustedTargetPricePerUnit * (1 + salesTax));
|
||||
|
||||
const adjustedBreakEvenPricePerUnit = itemsRemaining > 0 ? Math.max(0, uncoveredCosts / itemsRemaining) : 0;
|
||||
const adjustedBreakEvenPriceWithTax = roundToSignificantDigits(adjustedBreakEvenPricePerUnit * (1 + salesTax));
|
||||
|
||||
const handleCopyTargetPrice = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(
|
||||
targetPriceWithTax.toString(),
|
||||
'targetPrice',
|
||||
'Target price copied to clipboard'
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyBreakEvenPrice = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(
|
||||
breakEvenPriceWithTax.toString(),
|
||||
'breakEvenPrice',
|
||||
'Break-even price copied to clipboard'
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyAdjustedTargetPrice = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(
|
||||
adjustedTargetPriceWithTax.toString(),
|
||||
'adjustedTargetPrice',
|
||||
'Adjusted target price copied to clipboard'
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyAdjustedBreakEvenPrice = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(
|
||||
adjustedBreakEvenPriceWithTax.toString(),
|
||||
'adjustedBreakEvenPrice',
|
||||
'Adjusted break-even price copied to clipboard'
|
||||
);
|
||||
};
|
||||
|
||||
const taxSuffix = salesTax > 0 ? ` (+${(salesTax * 100).toFixed(1)}% tax)` : '';
|
||||
|
||||
return (
|
||||
<div className="grid gap-x-4 gap-y-2 text-sm" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||||
<div className="flex items-center gap-2 text-gray-400 justify-center">
|
||||
<Factory className="w-4 h-4" />
|
||||
<span>Target Price{taxSuffix}:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400 justify-center">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Break-even{taxSuffix}:</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-lg justify-center">
|
||||
<span
|
||||
className="cursor-pointer hover:text-blue-400 transition-colors inline-flex items-center gap-1 text-white"
|
||||
onClick={handleCopyTargetPrice}
|
||||
title="Click to copy target price per unit (based on projected revenue)"
|
||||
data-no-navigate
|
||||
>
|
||||
{formatISK(targetPriceWithTax)}
|
||||
{copying === 'targetPrice' && <Copy className="w-3 h-3 text-green-400" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-lg justify-center">
|
||||
<span
|
||||
className="cursor-pointer hover:text-yellow-400 transition-colors inline-flex items-center gap-1 text-white"
|
||||
onClick={handleCopyBreakEvenPrice}
|
||||
title="Click to copy break-even price per unit (based on actual costs)"
|
||||
data-no-navigate
|
||||
>
|
||||
{formatISK(breakEvenPriceWithTax)}
|
||||
{copying === 'breakEvenPrice' && <Copy className="w-3 h-3 text-green-400" />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-400 justify-center">
|
||||
<Factory className="w-4 h-4" />
|
||||
<span>Adjusted Target{taxSuffix}:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400 justify-center">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Adjusted Break-even{taxSuffix}:</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-lg justify-center">
|
||||
<span
|
||||
className="cursor-pointer hover:text-blue-400 transition-colors inline-flex items-center gap-1 text-white"
|
||||
onClick={handleCopyAdjustedTargetPrice}
|
||||
title="Click to copy adjusted target price per unit (based on remaining revenue)"
|
||||
data-no-navigate
|
||||
>
|
||||
{formatISK(adjustedTargetPriceWithTax)}
|
||||
{copying === 'adjustedTargetPrice' && <Copy className="w-3 h-3 text-green-400" />}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-lg justify-center">
|
||||
<span
|
||||
className="cursor-pointer hover:text-yellow-400 transition-colors inline-flex items-center gap-1 text-white"
|
||||
onClick={handleCopyAdjustedBreakEvenPrice}
|
||||
title="Click to copy adjusted break-even price per unit (based on uncovered costs)"
|
||||
data-no-navigate
|
||||
>
|
||||
{formatISK(adjustedBreakEvenPriceWithTax)}
|
||||
{copying === 'adjustedBreakEvenPrice' && <Copy className="w-3 h-3 text-green-400" />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobCardDetails;
|
||||
|
@@ -1,17 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Import, Upload, Check, Copy } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Copy, BarChart3 } from 'lucide-react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useClipboard } from '@/hooks/useClipboard';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { useState } from 'react';
|
||||
import JobStatusNavigation from './JobStatusNavigation';
|
||||
import BOMActions from './BOMActions';
|
||||
import EditableProduced from './EditableProduced';
|
||||
import TransactionChart from './TransactionChart';
|
||||
|
||||
interface JobCardHeaderProps {
|
||||
job: IndJob;
|
||||
@@ -28,320 +25,111 @@ const JobCardHeader: React.FC<JobCardHeaderProps> = ({
|
||||
onUpdateProduced,
|
||||
onImportBOM
|
||||
}) => {
|
||||
const [isEditingProduced, setIsEditingProduced] = useState(false);
|
||||
const [producedValue, setProducedValue] = useState(job.produced?.toString() || '0');
|
||||
const [copyingBom, setCopyingBom] = useState(false);
|
||||
const [copyingName, setCopyingName] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const { updateJob } = useJobs();
|
||||
|
||||
const statuses = ['Planned', 'Acquisition', 'Running', 'Done', 'Selling', 'Closed', 'Tracked'];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Planned': return 'bg-gray-600';
|
||||
case 'Acquisition': return 'bg-yellow-600';
|
||||
case 'Running': return 'bg-blue-600';
|
||||
case 'Done': return 'bg-purple-600';
|
||||
case 'Selling': return 'bg-orange-600';
|
||||
case 'Closed': return 'bg-green-600';
|
||||
case 'Tracked': return 'bg-cyan-600';
|
||||
default: return 'bg-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: string, e: React.MouseEvent) => {
|
||||
try {
|
||||
await updateJob(job.id, { status: newStatus });
|
||||
toast({
|
||||
title: "Status Updated",
|
||||
description: `Job status changed to ${newStatus}`,
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update status",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProducedUpdate = () => {
|
||||
const newValue = parseInt(producedValue);
|
||||
if (!isNaN(newValue) && onUpdateProduced) {
|
||||
onUpdateProduced(job.id, newValue);
|
||||
setIsEditingProduced(false);
|
||||
} else {
|
||||
setProducedValue(job.produced?.toString() || '0');
|
||||
setIsEditingProduced(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProducedKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleProducedUpdate();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditingProduced(false);
|
||||
setProducedValue(job.produced?.toString() || '0');
|
||||
}
|
||||
};
|
||||
|
||||
const importBillOfMaterials = async () => {
|
||||
if (!onImportBOM) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Import functionality is not available",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
const lines = clipboardText.split('\n').filter(line => line.trim());
|
||||
const items: { name: string; quantity: number }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/[\s\t]+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts.slice(0, -1).join(' ');
|
||||
const quantityPart = parts[parts.length - 1].replace(/,/g, '');
|
||||
const quantity = parseInt(quantityPart);
|
||||
if (name && !isNaN(quantity)) {
|
||||
items.push({ name, quantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
onImportBOM(job.id, items);
|
||||
toast({
|
||||
title: "BOM Imported",
|
||||
description: `Successfully imported ${items.length} items`,
|
||||
duration: 3000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "No Valid Items",
|
||||
description: "No valid items found in clipboard. Format: 'Item Name Quantity' per line",
|
||||
variant: "destructive",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to read from clipboard",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exportBillOfMaterials = async () => {
|
||||
if (!job.billOfMaterials?.length) {
|
||||
toast({
|
||||
title: "Nothing to Export",
|
||||
description: "No bill of materials found for this job",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = job.billOfMaterials
|
||||
.map(item => `${item.name}\t${item.quantity.toLocaleString()}`)
|
||||
.join('\n');
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopyingBom(true);
|
||||
toast({
|
||||
title: "Exported!",
|
||||
description: "Bill of materials copied to clipboard",
|
||||
duration: 2000,
|
||||
});
|
||||
setTimeout(() => setCopyingBom(false), 1000);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to copy to clipboard",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobNameClick = async (e: React.MouseEvent) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(job.outputItem);
|
||||
setCopyingName(true);
|
||||
toast({
|
||||
title: "Copied!",
|
||||
description: "Job name copied to clipboard",
|
||||
duration: 2000,
|
||||
});
|
||||
setTimeout(() => setCopyingName(false), 1000);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to copy to clipboard",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProducedClick = (e: React.MouseEvent) => {
|
||||
if (job.status !== 'Closed') {
|
||||
setIsEditingProduced(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (e: React.MouseEvent) => {
|
||||
onEdit(job);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
onDelete(job.id);
|
||||
};
|
||||
|
||||
const handleImportClick = (e: React.MouseEvent) => {
|
||||
importBillOfMaterials();
|
||||
};
|
||||
|
||||
const handleExportClick = (e: React.MouseEvent) => {
|
||||
exportBillOfMaterials();
|
||||
};
|
||||
const { copying, copyToClipboard } = useClipboard();
|
||||
const { jobs } = useJobs();
|
||||
const [overviewChartOpen, setOverviewChartOpen] = useState(false);
|
||||
const [totalRevenueChartOpen, setTotalRevenueChartOpen] = useState(false);
|
||||
const [totalProfitChartOpen, setTotalProfitChartOpen] = useState(false);
|
||||
|
||||
const sortedIncome = [...job.income].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
const itemsSold = sortedIncome.reduce((sum, tx) => sum + tx.quantity, 0);
|
||||
|
||||
const handleJobNameClick = async (e: React.MouseEvent) => {
|
||||
await copyToClipboard(job.outputItem, 'name', 'Job name copied to clipboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CardTitle
|
||||
className="text-blue-400 truncate cursor-pointer hover:text-blue-300 transition-colors flex items-center gap-1"
|
||||
onClick={handleJobNameClick}
|
||||
title="Click to copy job name"
|
||||
data-no-navigate
|
||||
>
|
||||
{job.outputItem}
|
||||
{copyingName && <Copy className="w-4 h-4 text-green-400" />}
|
||||
</CardTitle>
|
||||
<>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CardTitle
|
||||
className="text-blue-400 truncate cursor-pointer hover:text-blue-300 transition-colors flex items-center gap-1 leading-normal"
|
||||
onClick={handleJobNameClick}
|
||||
title="Click to copy job name"
|
||||
data-no-navigate
|
||||
style={{ lineHeight: '1.4' }}
|
||||
>
|
||||
{job.outputItem}
|
||||
{copying === 'name' && <Copy className="w-4 h-4 text-green-400" />}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm leading-relaxed" style={{ lineHeight: '1.4' }}>
|
||||
<div className="mb-1">
|
||||
Runs: {job.outputQuantity.toLocaleString()}
|
||||
<span className="ml-4">
|
||||
Produced: <EditableProduced job={job} onUpdateProduced={onUpdateProduced} />
|
||||
</span>
|
||||
<span className="ml-4 items-center gap-1">
|
||||
Sold: <span className="text-green-400">{itemsSold.toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Runs: {job.outputQuantity.toLocaleString()}
|
||||
<span className="ml-4">
|
||||
Produced: {
|
||||
isEditingProduced && job.status !== 'Closed' ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={producedValue}
|
||||
onChange={(e) => setProducedValue(e.target.value)}
|
||||
onBlur={handleProducedUpdate}
|
||||
onKeyDown={handleProducedKeyPress}
|
||||
className="w-24 h-5 px-2 py-0 inline-block bg-gray-800 border-gray-600 text-white text-xs leading-5"
|
||||
min="0"
|
||||
autoFocus
|
||||
data-no-navigate
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={handleProducedClick}
|
||||
className={`inline-block w-20 h-5 leading-5 text-left ${job.status !== 'Closed' ? "cursor-pointer hover:text-blue-400" : ""}`}
|
||||
title={job.status !== 'Closed' ? "Click to edit" : undefined}
|
||||
data-no-navigate
|
||||
>
|
||||
{(job.produced || 0).toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
<span className="ml-4">
|
||||
Sold: <span className="text-green-400">{itemsSold.toLocaleString()}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={`${getStatusColor(job.status)} text-white px-3 py-1 rounded-sm text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity`}
|
||||
data-no-navigate
|
||||
>
|
||||
{job.status}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-gray-800/50 border-gray-600 text-white">
|
||||
{statuses.map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={(e) => handleStatusChange(status, e)}
|
||||
className="hover:bg-gray-700 cursor-pointer"
|
||||
data-no-navigate
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-sm ${getStatusColor(status)} mr-2`} />
|
||||
{status}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditClick}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
data-no-navigate
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
data-no-navigate
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6 w-6"
|
||||
onClick={handleImportClick}
|
||||
title="Import BOM from clipboard"
|
||||
data-no-navigate
|
||||
>
|
||||
<Import className="w-4 h-4 text-blue-400" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6 w-6"
|
||||
onClick={handleExportClick}
|
||||
disabled={!job.billOfMaterials?.length}
|
||||
title="Export BOM to clipboard"
|
||||
data-no-navigate
|
||||
>
|
||||
{copyingBom ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 text-blue-400" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2 flex-shrink-0 items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(job)}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
data-no-navigate
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(job.id)}
|
||||
data-no-navigate
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
className="text-gray-400 hover:text-blue-300 transition-colors px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOverviewChartOpen(true);
|
||||
}}
|
||||
data-no-navigate
|
||||
title="View transaction charts"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</button>
|
||||
<BOMActions job={job} onImportBOM={onImportBOM} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-2 mb-2">
|
||||
<JobStatusNavigation job={job} />
|
||||
</div>
|
||||
|
||||
<TransactionChart
|
||||
job={job}
|
||||
type="profit"
|
||||
isOpen={overviewChartOpen}
|
||||
onClose={() => setOverviewChartOpen(false)}
|
||||
/>
|
||||
|
||||
<TransactionChart
|
||||
jobs={jobs}
|
||||
type="total-revenue"
|
||||
isOpen={totalRevenueChartOpen}
|
||||
onClose={() => setTotalRevenueChartOpen(false)}
|
||||
/>
|
||||
|
||||
<TransactionChart
|
||||
jobs={jobs}
|
||||
type="total-profit"
|
||||
isOpen={totalProfitChartOpen}
|
||||
onClose={() => setTotalProfitChartOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -4,6 +4,10 @@ import { IndJob } from '@/lib/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import JobTransactionPopover from './JobTransactionPopover';
|
||||
import TransactionChart from './TransactionChart';
|
||||
import { useJobCardMetrics } from '@/hooks/useJobCardMetrics';
|
||||
|
||||
interface JobCardMetricsProps {
|
||||
job: IndJob;
|
||||
@@ -12,22 +16,26 @@ interface JobCardMetricsProps {
|
||||
const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [tempValues, setTempValues] = useState<{ [key: string]: string }>({});
|
||||
const [chartModal, setChartModal] = useState<{ type: 'costs' | 'revenue' | 'profit'; isOpen: boolean }>({ type: 'costs', isOpen: false });
|
||||
const { updateJob } = useJobs();
|
||||
const { toast } = useToast();
|
||||
|
||||
const sortedExpenditures = [...job.expenditures].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
const sortedIncome = [...job.income].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
|
||||
const totalExpenditure = sortedExpenditures.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const totalIncome = sortedIncome.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const profit = totalIncome - totalExpenditure;
|
||||
const margin = totalIncome > 0 ? ((profit / totalIncome) * 100) : 0;
|
||||
// Use optimized hook for all expensive calculations
|
||||
const {
|
||||
sortedExpenditures,
|
||||
sortedIncome,
|
||||
totalExpenditure,
|
||||
totalIncome,
|
||||
profit,
|
||||
margin,
|
||||
itemsSold,
|
||||
produced,
|
||||
showPerformanceIndicator,
|
||||
performancePercentage
|
||||
} = useJobCardMetrics(job);
|
||||
|
||||
const handleFieldClick = (fieldName: string, currentValue: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingField(fieldName);
|
||||
setTempValues({ ...tempValues, [fieldName]: formatISK(currentValue) });
|
||||
};
|
||||
@@ -61,14 +69,36 @@ const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const openChart = (type: 'costs' | 'revenue' | 'profit', e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setChartModal({ type, isOpen: true });
|
||||
};
|
||||
|
||||
const closeChart = () => {
|
||||
setChartModal({ type: 'costs', isOpen: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-gray-700/50 flex-shrink-0">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="text-xs font-medium text-red-400 uppercase tracking-wide">Costs</div>
|
||||
<div className="text-lg font-bold text-red-400">{formatISK(totalExpenditure)}</div>
|
||||
<div className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center justify-center gap-1">
|
||||
Costs
|
||||
<button
|
||||
className="hover:text-red-300 transition-colors"
|
||||
onClick={(e) => openChart('costs', e)}
|
||||
data-no-navigate
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<JobTransactionPopover job={job} type="costs">
|
||||
<div className="text-lg font-bold text-red-400 cursor-pointer hover:text-red-300 transition-colors" data-no-navigate>
|
||||
{formatISK(totalExpenditure)}
|
||||
</div>
|
||||
</JobTransactionPopover>
|
||||
{job.projectedCost > 0 && (
|
||||
<div className="text-xs text-gray-400">
|
||||
vs {editingField === 'projectedCost' ? (
|
||||
<div className="text-xs text-gray-400 space-y-1">
|
||||
<div>vs {editingField === 'projectedCost' ? (
|
||||
<Input
|
||||
value={tempValues.projectedCost || ''}
|
||||
onChange={(e) => setTempValues({ ...tempValues, projectedCost: e.target.value })}
|
||||
@@ -87,19 +117,38 @@ const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
||||
>
|
||||
{formatISK(job.projectedCost)}
|
||||
</span>
|
||||
)}
|
||||
<div className={`text-xs font-medium ${totalExpenditure <= job.projectedCost ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{((totalExpenditure / job.projectedCost) * 100).toFixed(0)}%
|
||||
)}</div>
|
||||
<div
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full inline-block ${totalExpenditure <= job.projectedCost
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-red-900/50 text-red-400'
|
||||
}`}
|
||||
title={`Cost efficiency: ${((totalExpenditure / job.projectedCost) * 100).toFixed(1)}% of projected cost`}
|
||||
>
|
||||
{totalExpenditure <= job.projectedCost ? '✅' : '⚠️'} {((totalExpenditure / job.projectedCost) * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="text-xs font-medium text-green-400 uppercase tracking-wide">Revenue</div>
|
||||
<div className="text-lg font-bold text-green-400">{formatISK(totalIncome)}</div>
|
||||
<div className="text-xs font-medium text-green-400 uppercase tracking-wide flex items-center justify-center gap-1">
|
||||
Revenue
|
||||
<button
|
||||
className="hover:text-green-300 transition-colors"
|
||||
onClick={(e) => openChart('revenue', e)}
|
||||
data-no-navigate
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<JobTransactionPopover job={job} type="revenue">
|
||||
<div className="text-lg font-bold text-green-400 cursor-pointer hover:text-green-300 transition-colors" data-no-navigate>
|
||||
{formatISK(totalIncome)}
|
||||
</div>
|
||||
</JobTransactionPopover>
|
||||
{job.projectedRevenue > 0 && (
|
||||
<div className="text-xs text-gray-400">
|
||||
vs {editingField === 'projectedRevenue' ? (
|
||||
<div className="text-xs text-gray-400 space-y-1">
|
||||
<div>vs {editingField === 'projectedRevenue' ? (
|
||||
<Input
|
||||
value={tempValues.projectedRevenue || ''}
|
||||
onChange={(e) => setTempValues({ ...tempValues, projectedRevenue: e.target.value })}
|
||||
@@ -118,18 +167,50 @@ const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
||||
>
|
||||
{formatISK(job.projectedRevenue)}
|
||||
</span>
|
||||
)}
|
||||
<div className={`text-xs font-medium ${totalIncome >= job.projectedRevenue ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{((totalIncome / job.projectedRevenue) * 100).toFixed(0)}%
|
||||
)}</div>
|
||||
<div className="flex justify-center gap-2">
|
||||
<div
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full inline-block ${totalIncome >= job.projectedRevenue
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-yellow-900/50 text-yellow-400'
|
||||
}`}
|
||||
title={`Revenue progress: ${((totalIncome / job.projectedRevenue) * 100).toFixed(1)}% of projected revenue`}
|
||||
>
|
||||
{totalIncome >= job.projectedRevenue ? '🎯' : '📊'} {((totalIncome / job.projectedRevenue) * 100).toFixed(0)}%
|
||||
</div>
|
||||
{showPerformanceIndicator && (
|
||||
<div
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full inline-block ${performancePercentage >= 100
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: performancePercentage >= 90
|
||||
? 'bg-yellow-900/50 text-yellow-400'
|
||||
: 'bg-red-900/50 text-red-400'
|
||||
}`}
|
||||
title={`Price performance: ${formatISK(totalIncome / itemsSold)}/unit vs ${formatISK(job.projectedRevenue / produced)}/unit expected (${performancePercentage.toFixed(1)}%)`}
|
||||
>
|
||||
{performancePercentage >= 100 ? '📈' : performancePercentage >= 90 ? '⚠️' : '📉'} {performancePercentage.toFixed(0)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="text-xs font-medium text-gray-300 uppercase tracking-wide">Profit</div>
|
||||
<div className={`text-lg font-bold ${profit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatISK(profit)}
|
||||
<div className="text-xs font-medium text-gray-300 uppercase tracking-wide flex items-center justify-center gap-1">
|
||||
Profit
|
||||
<button
|
||||
className="hover:text-gray-100 transition-colors"
|
||||
onClick={(e) => openChart('profit', e)}
|
||||
data-no-navigate
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<JobTransactionPopover job={job} type="profit">
|
||||
<div className={`text-lg font-bold cursor-pointer transition-colors ${profit >= 0 ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300'}`} data-no-navigate>
|
||||
{formatISK(profit)}
|
||||
</div>
|
||||
</JobTransactionPopover>
|
||||
<div className={`text-xs font-medium ${profit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{margin.toFixed(1)}% margin
|
||||
</div>
|
||||
@@ -139,8 +220,15 @@ const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TransactionChart
|
||||
job={job}
|
||||
type={chartModal.type}
|
||||
isOpen={chartModal.isOpen}
|
||||
onClose={closeChart}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobCardMetrics;
|
||||
export default JobCardMetrics;
|
@@ -1,8 +1,10 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { IndJobStatusOptions, IndJobRecordNoId } from '@/lib/pbtypes';
|
||||
import { IndJob } from '@/lib/types';
|
||||
@@ -10,56 +12,100 @@ import { parseISKAmount } from '@/utils/priceUtils';
|
||||
|
||||
interface JobFormProps {
|
||||
job?: IndJob;
|
||||
onSubmit: (job: IndJobRecordNoId) => void;
|
||||
onSubmit: (job: IndJobRecordNoId, billOfMaterials?: { name: string; quantity: number }[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const formatDateForInput = (dateString: string | undefined | null): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
// Create a date object in local timezone
|
||||
const date = new Date(dateString);
|
||||
|
||||
// Format to YYYY-MM-DD
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
// Format to HH:MM
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
// Combine into format required by datetime-local (YYYY-MM-DDTHH:MM)
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const JobForm: React.FC<JobFormProps> = ({ job, onSubmit, onCancel }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
outputItem: job?.outputItem || '',
|
||||
outputQuantity: job?.outputQuantity || 0,
|
||||
jobStart: formatDateForInput(job?.jobStart),
|
||||
jobEnd: formatDateForInput(job?.jobEnd),
|
||||
saleStart: formatDateForInput(job?.saleStart),
|
||||
saleEnd: formatDateForInput(job?.saleEnd),
|
||||
status: job?.status || IndJobStatusOptions.Planned,
|
||||
projectedCost: job?.projectedCost || 0,
|
||||
projectedRevenue: job?.projectedRevenue || 0
|
||||
projectedRevenue: job?.projectedRevenue || 0,
|
||||
runtime: job?.runtime || 0,
|
||||
parallel: job?.parallel || 1
|
||||
});
|
||||
|
||||
const [jobDump, setJobDump] = useState('');
|
||||
const [parsedBillOfMaterials, setParsedBillOfMaterials] = useState<{ name: string; quantity: number }[]>([]);
|
||||
|
||||
const parseJobDump = (dumpText: string) => {
|
||||
if (!dumpText.trim()) {
|
||||
setParsedBillOfMaterials([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = dumpText.trim().split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length >= 4) {
|
||||
// Parse first line: "Item Name Quantity"
|
||||
const firstLine = lines[0].trim();
|
||||
const lastSpaceIndex = firstLine.lastIndexOf(' ');
|
||||
|
||||
if (lastSpaceIndex > 0) {
|
||||
const itemName = firstLine.substring(0, lastSpaceIndex).trim();
|
||||
const quantity = parseInt(firstLine.substring(lastSpaceIndex + 1).trim()) || 0;
|
||||
|
||||
// Parse runtime (second line)
|
||||
const runtime = parseInt(lines[1].replace(/,/g, '')) || 0;
|
||||
|
||||
// Parse cost (third line)
|
||||
const cost = parseISKAmount(lines[2].replace(/,/g, ''));
|
||||
|
||||
// Parse revenue (fourth line)
|
||||
const revenue = parseISKAmount(lines[3].replace(/,/g, ''));
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
outputItem: itemName,
|
||||
outputQuantity: quantity,
|
||||
runtime: runtime,
|
||||
projectedCost: cost,
|
||||
projectedRevenue: revenue
|
||||
}));
|
||||
|
||||
// Parse BOM - everything after the first 4 lines is BOM
|
||||
const bomLines = lines.slice(4); // Start from line 5 (index 4)
|
||||
const billOfMaterials: { name: string; quantity: number }[] = [];
|
||||
|
||||
for (const line of bomLines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine) {
|
||||
const lastSpaceIndex = trimmedLine.lastIndexOf(' ');
|
||||
if (lastSpaceIndex > 0) {
|
||||
const materialName = trimmedLine.substring(0, lastSpaceIndex).trim();
|
||||
const materialQuantity = parseInt(trimmedLine.substring(lastSpaceIndex + 1).trim()) || 0;
|
||||
if (materialName && materialQuantity > 0) {
|
||||
billOfMaterials.push({ name: materialName, quantity: materialQuantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setParsedBillOfMaterials(billOfMaterials);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobDumpChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setJobDump(value);
|
||||
parseJobDump(value);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
onSubmit({
|
||||
outputItem: formData.outputItem,
|
||||
outputQuantity: formData.outputQuantity,
|
||||
jobStart: formData.jobStart || undefined,
|
||||
jobEnd: formData.jobEnd || undefined,
|
||||
saleStart: formData.saleStart || undefined,
|
||||
saleEnd: formData.saleEnd || undefined,
|
||||
status: formData.status,
|
||||
projectedCost: formData.projectedCost,
|
||||
projectedRevenue: formData.projectedRevenue
|
||||
});
|
||||
projectedRevenue: formData.projectedRevenue,
|
||||
runtime: formData.runtime,
|
||||
parallel: formData.parallel
|
||||
}, parsedBillOfMaterials.length > 0 ? parsedBillOfMaterials : undefined);
|
||||
};
|
||||
|
||||
const statusOptions = Object.values(IndJobStatusOptions);
|
||||
@@ -122,65 +168,34 @@ const JobForm: React.FC<JobFormProps> = ({ job, onSubmit, onCancel }) => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate" className="text-gray-300">Start Date & Time</Label>
|
||||
<Label htmlFor="runtime" className="text-gray-300">Runtime (seconds)</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="datetime-local"
|
||||
value={formData.jobStart}
|
||||
id="runtime"
|
||||
type="number"
|
||||
value={formData.runtime}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
jobStart: e.target.value
|
||||
runtime: parseInt(e.target.value) || 0
|
||||
})}
|
||||
className="bg-gray-800 border-gray-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endDate" className="text-gray-300">End Date & Time</Label>
|
||||
<Label htmlFor="parallel" className="text-gray-300">Parallel Jobs</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
type="datetime-local"
|
||||
value={formData.jobEnd}
|
||||
id="parallel"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.parallel}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
jobEnd: e.target.value
|
||||
parallel: Math.max(1, parseInt(e.target.value) || 1)
|
||||
})}
|
||||
className="bg-gray-800 border-gray-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="saleStartDate" className="text-gray-300">Sale Start Date & Time</Label>
|
||||
<Input
|
||||
id="saleStartDate"
|
||||
type="datetime-local"
|
||||
value={formData.saleStart}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
saleStart: e.target.value
|
||||
})}
|
||||
className="bg-gray-800 border-gray-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="saleEndDate" className="text-gray-300">Sale End Date & Time</Label>
|
||||
<Input
|
||||
id="saleEndDate"
|
||||
type="datetime-local"
|
||||
value={formData.saleEnd}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
saleEnd: e.target.value
|
||||
})}
|
||||
className="bg-gray-800 border-gray-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectedCost" className="text-gray-300">Projected Cost</Label>
|
||||
<Input
|
||||
@@ -209,6 +224,23 @@ const JobForm: React.FC<JobFormProps> = ({ job, onSubmit, onCancel }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jobDump" className="text-gray-300">Job Dump Import</Label>
|
||||
<Textarea
|
||||
id="jobDump"
|
||||
value={jobDump}
|
||||
onChange={handleJobDumpChange}
|
||||
placeholder="Paste job dump here: Discovery Survey Probe I 800 468000 158,670,288 237,484,800 Tritanium 8888800 Pyerite 711200 ..."
|
||||
className="bg-gray-800 border-gray-600 text-white min-h-[120px]"
|
||||
rows={6}
|
||||
/>
|
||||
{parsedBillOfMaterials.length > 0 && (
|
||||
<div className="text-sm text-gray-400">
|
||||
<p>Parsed {parsedBillOfMaterials.length} materials from dump</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button type="submit" className="flex-1 bg-blue-600 hover:bg-blue-700">
|
||||
{job ? 'Update Job' : 'Create Job'}
|
||||
|
80
src/components/JobGroup.tsx
Normal file
80
src/components/JobGroup.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { getStatusColor } from '@/utils/jobStatusUtils';
|
||||
import { jobNeedsAttention, getAttentionGlowClasses } from '@/utils/jobAttentionUtils';
|
||||
import JobCard from './JobCard';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface JobGroupProps {
|
||||
status: string;
|
||||
jobs: IndJob[];
|
||||
isCollapsed: boolean;
|
||||
onToggle: (status: string) => void;
|
||||
onEdit: (job: IndJob) => void;
|
||||
onDelete: (jobId: string) => void;
|
||||
onUpdateProduced?: (jobId: string, produced: number) => void;
|
||||
onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void;
|
||||
isTracked?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const JobGroup: React.FC<JobGroupProps> = ({
|
||||
status,
|
||||
jobs,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateProduced,
|
||||
onImportBOM,
|
||||
isTracked = false,
|
||||
isLoading = false
|
||||
}) => {
|
||||
// Check if any jobs in this group need attention
|
||||
const hasAttentionJobs = jobs.some(job => jobNeedsAttention(job));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`${getStatusColor(status)} rounded-lg cursor-pointer select-none transition-colors hover:opacity-90 ${hasAttentionJobs ? getAttentionGlowClasses() : ''}`}
|
||||
onClick={() => onToggle(status)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="text-xl font-semibold text-white flex items-center gap-3">
|
||||
<span>{status}</span>
|
||||
<span className="text-gray-200 text-lg">({jobs.length} jobs)</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
</h3>
|
||||
<div className={`text-white text-lg transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}>
|
||||
⌄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center p-8 text-gray-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading jobs...
|
||||
</div>
|
||||
) : (
|
||||
jobs.map(job => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
isTracked={isTracked}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobGroup;
|
120
src/components/JobStatusDropdown.tsx
Normal file
120
src/components/JobStatusDropdown.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { getStatusColor, JOB_STATUSES } from '@/utils/jobStatusUtils';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface JobStatusDropdownProps {
|
||||
job: IndJob;
|
||||
}
|
||||
|
||||
const JobStatusDropdown: React.FC<JobStatusDropdownProps> = ({ job }) => {
|
||||
const { updateJob } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleStatusChange = async (newStatus: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Prevent duplicate calls
|
||||
if (isUpdating || job.status === newStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending timeout
|
||||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
const currentTime = new Date().toISOString();
|
||||
const updates: { status: string; [key: string]: any } = { status: newStatus };
|
||||
|
||||
// Automatically assign dates based on status
|
||||
switch (newStatus) {
|
||||
case 'Running':
|
||||
updates.jobStart = currentTime;
|
||||
break;
|
||||
case 'Done':
|
||||
updates.jobEnd = currentTime;
|
||||
break;
|
||||
case 'Selling':
|
||||
updates.saleStart = currentTime;
|
||||
break;
|
||||
case 'Closed':
|
||||
updates.saleEnd = currentTime;
|
||||
break;
|
||||
}
|
||||
|
||||
await updateJob(job.id, updates);
|
||||
|
||||
const dateMessages = [];
|
||||
if (updates.jobStart) dateMessages.push('job start date set');
|
||||
if (updates.jobEnd) dateMessages.push('job end date set');
|
||||
if (updates.saleStart) dateMessages.push('sale start date set');
|
||||
if (updates.saleEnd) dateMessages.push('sale end date set');
|
||||
|
||||
const description = dateMessages.length > 0
|
||||
? `Job status changed to ${newStatus} and ${dateMessages.join(', ')}`
|
||||
: `Job status changed to ${newStatus}`;
|
||||
|
||||
toast({
|
||||
title: "Status Updated",
|
||||
description,
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update status",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
} finally {
|
||||
// Reset updating state after a short delay
|
||||
updateTimeoutRef.current = setTimeout(() => {
|
||||
setIsUpdating(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={`${getStatusColor(job.status)} text-white px-3 py-1 rounded-sm text-xs font-semibold cursor-pointer hover:opacity-80 transition-opacity`}
|
||||
data-no-navigate
|
||||
>
|
||||
{job.status}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-gray-800/50 border-gray-600 text-white">
|
||||
{JOB_STATUSES.map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={(e) => handleStatusChange(status, e)}
|
||||
className="hover:bg-gray-700 cursor-pointer"
|
||||
data-no-navigate
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-sm ${getStatusColor(status)} mr-2`} />
|
||||
{status}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobStatusDropdown;
|
103
src/components/JobStatusNavigation.tsx
Normal file
103
src/components/JobStatusNavigation.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { getNextStatus, getPreviousStatus, getStatusBackgroundColor, getStatusBackgroundColorBright, getStatusColor } from '@/utils/jobStatusUtils';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface JobStatusNavigationProps {
|
||||
job: IndJob;
|
||||
}
|
||||
|
||||
const JobStatusNavigation: React.FC<JobStatusNavigationProps> = ({ job }) => {
|
||||
const { updateJob } = useJobs();
|
||||
const { toast } = useToast();
|
||||
|
||||
const nextStatus = getNextStatus(job.status);
|
||||
const previousStatus = getPreviousStatus(job.status);
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
try {
|
||||
const currentTime = new Date().toISOString();
|
||||
const updates: { status: string; [key: string]: any } = { status: newStatus };
|
||||
|
||||
// Automatically assign dates based on status
|
||||
switch (newStatus) {
|
||||
case 'Running':
|
||||
updates.jobStart = currentTime;
|
||||
break;
|
||||
case 'Done':
|
||||
updates.jobEnd = currentTime;
|
||||
break;
|
||||
case 'Selling':
|
||||
updates.saleStart = currentTime;
|
||||
break;
|
||||
case 'Closed':
|
||||
updates.saleEnd = currentTime;
|
||||
break;
|
||||
}
|
||||
|
||||
await updateJob(job.id, updates);
|
||||
|
||||
const dateMessages = [];
|
||||
if (updates.jobStart) dateMessages.push('job start date set');
|
||||
if (updates.jobEnd) dateMessages.push('job end date set');
|
||||
if (updates.saleStart) dateMessages.push('sale start date set');
|
||||
if (updates.saleEnd) dateMessages.push('sale end date set');
|
||||
|
||||
const description = dateMessages.length > 0
|
||||
? `Job status changed to ${newStatus} and ${dateMessages.join(', ')}`
|
||||
: `Job status changed to ${newStatus}`;
|
||||
|
||||
toast({
|
||||
title: "Status Updated",
|
||||
description,
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update status",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
{previousStatus && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusChange(previousStatus);
|
||||
}}
|
||||
className={`${getStatusBackgroundColorBright(previousStatus)} text-white px-4 py-2 rounded flex items-center justify-center gap-1 hover:opacity-80 transition-opacity w-32`}
|
||||
data-no-navigate
|
||||
title={`Move to ${previousStatus}`}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="text-center flex-1">{previousStatus}</span>
|
||||
</button>
|
||||
)}
|
||||
{nextStatus && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusChange(nextStatus);
|
||||
}}
|
||||
className={`${getStatusBackgroundColorBright(nextStatus)} text-white px-4 py-2 rounded flex items-center justify-center gap-1 hover:opacity-80 transition-opacity w-32`}
|
||||
data-no-navigate
|
||||
title={`Move to ${nextStatus}`}
|
||||
>
|
||||
<span className="text-center flex-1">{nextStatus}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobStatusNavigation;
|
135
src/components/JobTransactionPopover.tsx
Normal file
135
src/components/JobTransactionPopover.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
interface JobTransactionPopoverProps {
|
||||
job: IndJob;
|
||||
type: 'costs' | 'revenue' | 'profit';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const JobTransactionPopover: React.FC<JobTransactionPopoverProps> = ({
|
||||
job,
|
||||
type,
|
||||
children
|
||||
}) => {
|
||||
const [sortDescending, setSortDescending] = useState(true);
|
||||
|
||||
const getTransactions = () => {
|
||||
switch (type) {
|
||||
case 'costs':
|
||||
return job.expenditures || [];
|
||||
case 'revenue':
|
||||
return job.income || [];
|
||||
case 'profit':
|
||||
return [...(job.expenditures || []), ...(job.income || [])];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
switch (type) {
|
||||
case 'costs':
|
||||
return 'Cost Breakdown';
|
||||
case 'revenue':
|
||||
return 'Revenue Breakdown';
|
||||
case 'profit':
|
||||
return 'Transaction History';
|
||||
default:
|
||||
return 'Transactions';
|
||||
}
|
||||
};
|
||||
|
||||
const transactions = getTransactions()
|
||||
.map(transaction => ({
|
||||
...transaction,
|
||||
displayValue: type === 'costs' ? transaction.totalPrice :
|
||||
type === 'revenue' ? transaction.totalPrice :
|
||||
transaction.totalPrice
|
||||
}))
|
||||
.filter(transaction => transaction.displayValue !== 0)
|
||||
.sort((a, b) => {
|
||||
const aValue = Math.abs(a.displayValue);
|
||||
const bValue = Math.abs(b.displayValue);
|
||||
return sortDescending ? bValue - aValue : aValue - bValue;
|
||||
});
|
||||
|
||||
const toggleSort = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSortDescending(!sortDescending);
|
||||
};
|
||||
|
||||
const getTransactionColor = (transaction: any) => {
|
||||
if (type === 'profit') {
|
||||
// For profit view, show costs as red and revenue as green
|
||||
const isExpenditure = (job.expenditures || []).some(exp => exp.id === transaction.id);
|
||||
return isExpenditure ? 'text-red-400' : 'text-green-400';
|
||||
}
|
||||
return type === 'costs' ? 'text-red-400' : 'text-green-400';
|
||||
};
|
||||
|
||||
const formatTransactionValue = (transaction: any) => {
|
||||
if (type === 'profit') {
|
||||
const isExpenditure = (job.expenditures || []).some(exp => exp.id === transaction.id);
|
||||
return isExpenditure ? `-${formatISK(transaction.totalPrice)}` : formatISK(transaction.totalPrice);
|
||||
}
|
||||
return formatISK(transaction.displayValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[30rem] bg-gray-800/95 border-gray-600 text-white max-h-[40rem] overflow-y-auto">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg text-white flex items-center justify-between">
|
||||
<span>{getTitle()}</span>
|
||||
<button
|
||||
onClick={toggleSort}
|
||||
className="flex items-center gap-1 text-sm font-normal text-gray-300 hover:text-white transition-colors"
|
||||
title="Click to toggle sort order"
|
||||
data-no-navigate
|
||||
>
|
||||
Sort {sortDescending ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
</CardTitle>
|
||||
<div className="text-sm text-gray-400">
|
||||
{job.outputItem} (ID: {job.id})
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">No transactions to display</p>
|
||||
) : (
|
||||
transactions.map((transaction) => (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="flex justify-between items-center p-2 rounded hover:bg-gray-700/50 transition-colors border-l-2 border-l-gray-600"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate" title={transaction.itemName}>
|
||||
{transaction.itemName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Qty: {transaction.quantity.toLocaleString()} • {new Date(transaction.date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ml-2 ${getTransactionColor(transaction)}`}>
|
||||
{formatTransactionValue(transaction)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobTransactionPopover;
|
76
src/components/JobsSection.tsx
Normal file
76
src/components/JobsSection.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
|
||||
import { IndJob } from '@/lib/types';
|
||||
import JobGroup from './JobGroup';
|
||||
|
||||
interface JobsSectionProps {
|
||||
regularJobs: IndJob[];
|
||||
trackedJobs: IndJob[];
|
||||
collapsedGroups: Record<string, boolean>;
|
||||
loadingStatuses: Set<string>;
|
||||
onToggleGroup: (status: string) => void;
|
||||
onEdit: (job: IndJob) => void;
|
||||
onDelete: (jobId: string) => void;
|
||||
onUpdateProduced: (jobId: string, produced: number) => void;
|
||||
onImportBOM: (jobId: string, items: { name: string; quantity: number }[]) => void;
|
||||
}
|
||||
|
||||
const JobsSection = ({
|
||||
regularJobs,
|
||||
trackedJobs,
|
||||
collapsedGroups,
|
||||
loadingStatuses,
|
||||
onToggleGroup,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateProduced,
|
||||
onImportBOM
|
||||
}: JobsSectionProps) => {
|
||||
const jobGroups = regularJobs.reduce((groups, job) => {
|
||||
const status = job.status;
|
||||
if (!groups[status]) {
|
||||
groups[status] = [];
|
||||
}
|
||||
groups[status].push(job);
|
||||
return groups;
|
||||
}, {} as Record<string, IndJob[]>);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{Object.entries(jobGroups).map(([status, statusJobs]) => (
|
||||
<JobGroup
|
||||
key={status}
|
||||
status={status}
|
||||
jobs={statusJobs}
|
||||
isCollapsed={collapsedGroups[status] || false}
|
||||
onToggle={onToggleGroup}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
isLoading={loadingStatuses.has(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{trackedJobs.length > 0 && (
|
||||
<div className="space-y-4 mt-8 pt-8 border-t border-gray-700">
|
||||
<JobGroup
|
||||
status="Tracked"
|
||||
jobs={trackedJobs}
|
||||
isCollapsed={collapsedGroups['Tracked'] || false}
|
||||
onToggle={onToggleGroup}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
isTracked={true}
|
||||
isLoading={loadingStatuses.has('Tracked')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobsSection;
|
46
src/components/JobsToolbar.tsx
Normal file
46
src/components/JobsToolbar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, FileText, ShoppingCart } from 'lucide-react';
|
||||
import SalesTaxConfig from './SalesTaxConfig';
|
||||
|
||||
interface JobsToolbarProps {
|
||||
onNewJob: () => void;
|
||||
onBatchIncome: () => void;
|
||||
onBatchExpenditure: () => void;
|
||||
}
|
||||
|
||||
const JobsToolbar = ({ onNewJob, onBatchIncome, onBatchExpenditure }: JobsToolbarProps) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold text-white">Jobs</h2>
|
||||
<div className="flex gap-2">
|
||||
<SalesTaxConfig />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBatchIncome}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Batch Income
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBatchExpenditure}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
Batch Expenditure
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNewJob}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Job
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobsToolbar;
|
60
src/components/MaterialsActions.tsx
Normal file
60
src/components/MaterialsActions.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Import, Download, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface MaterialsActionsProps {
|
||||
onImport: () => void;
|
||||
onExport: () => void;
|
||||
onExportMissing?: () => void;
|
||||
importDisabled?: boolean;
|
||||
missingDisabled?: boolean;
|
||||
type: 'bom' | 'consumed';
|
||||
}
|
||||
|
||||
const MaterialsActions: React.FC<MaterialsActionsProps> = ({
|
||||
onImport,
|
||||
onExport,
|
||||
onExportMissing,
|
||||
importDisabled = false,
|
||||
missingDisabled = false,
|
||||
type
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onExport}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
{type === 'bom' && onExportMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onExportMissing}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
disabled={missingDisabled}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Missing
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onImport}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={importDisabled}
|
||||
>
|
||||
<Import className="w-4 h-4 mr-2" />
|
||||
Import {type === 'bom' ? 'Bill of Materials' : 'Consumed Materials'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsActions;
|
@@ -1,12 +1,18 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Import, Download, FileText } from 'lucide-react';
|
||||
import { IndBillitemRecord, IndBillitemRecordNoId } from '@/lib/pbtypes';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { IndBillitemRecord } from '@/lib/pbtypes';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { dataService } from '@/services/dataService';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useMaterialsCalculations } from '@/hooks/useMaterialsCalculations';
|
||||
import { parseBillOfMaterials, parseConsumedMaterials } from '@/utils/materialsParser';
|
||||
import { exportBillOfMaterials, exportConsumedMaterials, exportMissingMaterials } from '@/utils/materialsExporter';
|
||||
import MaterialsActions from './MaterialsActions';
|
||||
import MaterialsList from './MaterialsList';
|
||||
|
||||
interface MaterialsImportExportProps {
|
||||
job?: IndJob;
|
||||
@@ -25,50 +31,8 @@ const MaterialsImportExport: React.FC<MaterialsImportExportProps> = ({
|
||||
}) => {
|
||||
const [bomInput, setBomInput] = useState('');
|
||||
const [consumedInput, setConsumedInput] = useState('');
|
||||
|
||||
const parseBillOfMaterials = (text: string): IndBillitemRecordNoId[] => {
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const materials: IndBillitemRecordNoId[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts.slice(0, -1).join(' ');
|
||||
const quantity = parseInt(parts[parts.length - 1]);
|
||||
if (name && !isNaN(quantity)) {
|
||||
materials.push({ name, quantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return materials;
|
||||
};
|
||||
|
||||
const parseConsumedMaterials = (text: string): IndBillitemRecordNoId[] => {
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const materials: IndBillitemRecordNoId[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split('\t');
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const quantity = parseInt(parts[1]);
|
||||
if (name && !isNaN(quantity)) {
|
||||
materials.push({ name, quantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return materials;
|
||||
};
|
||||
|
||||
const exportBillOfMaterials = (): string => {
|
||||
return billOfMaterials.map(item => `${item.name} ${item.quantity}`).join('\n');
|
||||
};
|
||||
|
||||
const exportConsumedMaterials = (): string => {
|
||||
return consumedMaterials.map(item => `${item.name}\t${item.quantity}`).join('\n');
|
||||
};
|
||||
const { toast } = useToast();
|
||||
const { calculateMissingMaterials } = useMaterialsCalculations(job, billOfMaterials);
|
||||
|
||||
const handleImportBom = async () => {
|
||||
if (!job) return;
|
||||
@@ -101,15 +65,46 @@ const MaterialsImportExport: React.FC<MaterialsImportExportProps> = ({
|
||||
};
|
||||
|
||||
const handleExportBom = () => {
|
||||
const exported = exportBillOfMaterials();
|
||||
const exported = exportBillOfMaterials(billOfMaterials);
|
||||
navigator.clipboard.writeText(exported);
|
||||
toast({
|
||||
title: "Exported",
|
||||
description: "Bill of materials copied to clipboard",
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportConsumed = () => {
|
||||
const exported = exportConsumedMaterials();
|
||||
const exported = exportConsumedMaterials(consumedMaterials);
|
||||
navigator.clipboard.writeText(exported);
|
||||
toast({
|
||||
title: "Exported",
|
||||
description: "Consumed materials copied to clipboard",
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportMissing = () => {
|
||||
const missingMaterials = calculateMissingMaterials();
|
||||
const exported = exportMissingMaterials(missingMaterials);
|
||||
if (exported) {
|
||||
navigator.clipboard.writeText(exported);
|
||||
toast({
|
||||
title: "Exported",
|
||||
description: "Missing materials copied to clipboard",
|
||||
duration: 2000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Nothing Missing",
|
||||
description: "All materials are satisfied for this job",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const missingMaterials = calculateMissingMaterials();
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||
<CardHeader>
|
||||
@@ -120,18 +115,15 @@ const MaterialsImportExport: React.FC<MaterialsImportExportProps> = ({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">Bill of Materials</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleExportBom}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-gray-300">Bill of Materials</Label>
|
||||
<MaterialsActions
|
||||
type="bom"
|
||||
onImport={handleImportBom}
|
||||
onExport={handleExportBom}
|
||||
onExportMissing={handleExportMissing}
|
||||
importDisabled={!job}
|
||||
missingDisabled={!job || missingMaterials.length === 0}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Paste bill of materials here (e.g., Mexallon 1000)"
|
||||
value={bomInput}
|
||||
@@ -139,29 +131,16 @@ const MaterialsImportExport: React.FC<MaterialsImportExportProps> = ({
|
||||
className="bg-gray-800 border-gray-600 text-white"
|
||||
rows={4}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleImportBom}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={!job}
|
||||
>
|
||||
<Import className="w-4 h-4 mr-2" />
|
||||
Import Bill of Materials
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">Consumed Materials</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleExportConsumed}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-gray-300">Consumed Materials</Label>
|
||||
<MaterialsActions
|
||||
type="consumed"
|
||||
onImport={handleImportConsumed}
|
||||
onExport={handleExportConsumed}
|
||||
importDisabled={!job}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Paste consumed materials here (tab-separated: Item\tRequired)"
|
||||
value={consumedInput}
|
||||
@@ -169,37 +148,23 @@ const MaterialsImportExport: React.FC<MaterialsImportExportProps> = ({
|
||||
className="bg-gray-800 border-gray-600 text-white"
|
||||
rows={4}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleImportConsumed}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={!job}
|
||||
>
|
||||
<Import className="w-4 h-4 mr-2" />
|
||||
Import Consumed Materials
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{billOfMaterials.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">Current Bill of Materials:</Label>
|
||||
<div className="text-sm text-gray-400 max-h-32 overflow-y-auto">
|
||||
{billOfMaterials.map((item, index) => (
|
||||
<div key={index}>{item.name}: {item.quantity.toLocaleString()}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MaterialsList
|
||||
title="Current Bill of Materials"
|
||||
materials={billOfMaterials}
|
||||
/>
|
||||
|
||||
{consumedMaterials.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">Current Consumed Materials:</Label>
|
||||
<div className="text-sm text-gray-400 max-h-32 overflow-y-auto">
|
||||
{consumedMaterials.map((item, index) => (
|
||||
<div key={index}>{item.name}: {item.quantity.toLocaleString()}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MaterialsList
|
||||
title="Current Consumed Materials"
|
||||
materials={consumedMaterials}
|
||||
/>
|
||||
|
||||
<MaterialsList
|
||||
title="Missing Materials"
|
||||
materials={missingMaterials}
|
||||
className="text-red-400"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
27
src/components/MaterialsList.tsx
Normal file
27
src/components/MaterialsList.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { IndBillitemRecord } from '@/lib/pbtypes';
|
||||
|
||||
interface MaterialsListProps {
|
||||
title: string;
|
||||
materials: IndBillitemRecord[] | { name: string; quantity: number }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MaterialsList: React.FC<MaterialsListProps> = ({ title, materials, className = "text-gray-400" }) => {
|
||||
if (materials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">{title}:</Label>
|
||||
<div className={`text-sm ${className} max-h-32 overflow-y-auto`}>
|
||||
{materials.map((item, index) => (
|
||||
<div key={index}>{item.name}: {item.quantity.toLocaleString()}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsList;
|
68
src/components/OptimizedJobCard.tsx
Normal file
68
src/components/OptimizedJobCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { getStatusBackgroundColor } from '@/utils/jobStatusUtils';
|
||||
import { getAttentionGlowClasses } from '@/utils/jobAttentionUtils';
|
||||
import JobCardHeader from './JobCardHeader';
|
||||
import JobCardDetails from './JobCardDetails';
|
||||
import JobCardMetrics from './JobCardMetrics';
|
||||
|
||||
interface OptimizedJobCardProps {
|
||||
job: IndJob;
|
||||
onEdit: (job: any) => void;
|
||||
onDelete: (jobId: string) => void;
|
||||
onUpdateProduced?: (jobId: string, produced: number) => void;
|
||||
onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void;
|
||||
needsAttention: boolean; // Pre-calculated
|
||||
isTracked?: boolean;
|
||||
}
|
||||
|
||||
const OptimizedJobCard: React.FC<OptimizedJobCardProps> = React.memo(({
|
||||
job,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateProduced,
|
||||
onImportBOM,
|
||||
needsAttention,
|
||||
isTracked = false
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const hasNoNavigate = target.closest('[data-no-navigate]');
|
||||
|
||||
if (hasNoNavigate) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/${job.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`bg-gray-900 border-gray-700 text-white h-full flex flex-col cursor-pointer hover:bg-gray-800/50 transition-colors ${job.status === 'Tracked' ? 'border-l-4 border-l-cyan-600' : ''} ${getStatusBackgroundColor(job.status)} ${needsAttention ? getAttentionGlowClasses() : ''}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<JobCardHeader
|
||||
job={job}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col space-y-4">
|
||||
<JobCardDetails job={job} />
|
||||
<div className="flex-1" />
|
||||
<JobCardMetrics job={job} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
OptimizedJobCard.displayName = 'OptimizedJobCard';
|
||||
|
||||
export default OptimizedJobCard;
|
84
src/components/OptimizedJobGroup.tsx
Normal file
84
src/components/OptimizedJobGroup.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { getStatusColor } from '@/utils/jobStatusUtils';
|
||||
import { getAttentionGlowClasses } from '@/utils/jobAttentionUtils';
|
||||
import OptimizedJobCard from './OptimizedJobCard';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface OptimizedJobGroupProps {
|
||||
status: string;
|
||||
jobs: IndJob[];
|
||||
isCollapsed: boolean;
|
||||
onToggle: (status: string) => void;
|
||||
onEdit: (job: IndJob) => void;
|
||||
onDelete: (jobId: string) => void;
|
||||
onUpdateProduced?: (jobId: string, produced: number) => void;
|
||||
onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void;
|
||||
hasAttentionJobs: boolean; // Pre-calculated
|
||||
jobAttentionMap: (jobId: string) => boolean; // Pre-calculated lookup
|
||||
isTracked?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const OptimizedJobGroup: React.FC<OptimizedJobGroupProps> = React.memo(({
|
||||
status,
|
||||
jobs,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateProduced,
|
||||
onImportBOM,
|
||||
hasAttentionJobs,
|
||||
jobAttentionMap,
|
||||
isTracked = false,
|
||||
isLoading = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`${getStatusColor(status)} rounded-lg cursor-pointer select-none transition-colors hover:opacity-90 ${hasAttentionJobs ? getAttentionGlowClasses() : ''}`}
|
||||
onClick={() => onToggle(status)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="text-xl font-semibold text-white flex items-center gap-3">
|
||||
<span>{status}</span>
|
||||
<span className="text-gray-200 text-lg">({jobs.length} jobs)</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
</h3>
|
||||
<div className={`text-white text-lg transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}>
|
||||
⌄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center p-8 text-gray-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading jobs...
|
||||
</div>
|
||||
) : (
|
||||
jobs.map(job => (
|
||||
<OptimizedJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
needsAttention={jobAttentionMap(job.id)}
|
||||
isTracked={isTracked}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
OptimizedJobGroup.displayName = 'OptimizedJobGroup';
|
||||
|
||||
export default OptimizedJobGroup;
|
90
src/components/OptimizedJobsSection.tsx
Normal file
90
src/components/OptimizedJobsSection.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import OptimizedJobGroup from './OptimizedJobGroup';
|
||||
import { useJobAttentionMetrics } from '@/hooks/useJobAttentionMetrics';
|
||||
|
||||
interface OptimizedJobsSectionProps {
|
||||
regularJobs: IndJob[];
|
||||
trackedJobs: IndJob[];
|
||||
collapsedGroups: Record<string, boolean>;
|
||||
loadingStatuses: Set<string>;
|
||||
onToggleGroup: (status: string) => void;
|
||||
onEdit: (job: IndJob) => void;
|
||||
onDelete: (jobId: string) => void;
|
||||
onUpdateProduced: (jobId: string, produced: number) => void;
|
||||
onImportBOM: (jobId: string, items: { name: string; quantity: number }[]) => void;
|
||||
}
|
||||
|
||||
const OptimizedJobsSection = React.memo(({
|
||||
regularJobs,
|
||||
trackedJobs,
|
||||
collapsedGroups,
|
||||
loadingStatuses,
|
||||
onToggleGroup,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onUpdateProduced,
|
||||
onImportBOM
|
||||
}: OptimizedJobsSectionProps) => {
|
||||
// Memoize expensive grouping operation
|
||||
const jobGroups = useMemo(() => {
|
||||
return regularJobs.reduce((groups, job) => {
|
||||
const status = job.status;
|
||||
if (!groups[status]) {
|
||||
groups[status] = [];
|
||||
}
|
||||
groups[status].push(job);
|
||||
return groups;
|
||||
}, {} as Record<string, IndJob[]>);
|
||||
}, [regularJobs]);
|
||||
|
||||
// Pre-calculate all attention metrics once
|
||||
const allJobs = useMemo(() => [...regularJobs, ...trackedJobs], [regularJobs, trackedJobs]);
|
||||
const { jobNeedsAttention, groupNeedsAttention } = useJobAttentionMetrics(allJobs);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{Object.entries(jobGroups).map(([status, statusJobs]) => (
|
||||
<OptimizedJobGroup
|
||||
key={status}
|
||||
status={status}
|
||||
jobs={statusJobs}
|
||||
isCollapsed={collapsedGroups[status] || false}
|
||||
onToggle={onToggleGroup}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
hasAttentionJobs={groupNeedsAttention(status)}
|
||||
jobAttentionMap={jobNeedsAttention}
|
||||
isLoading={loadingStatuses.has(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{trackedJobs.length > 0 && (
|
||||
<div className="space-y-4 mt-8 pt-8 border-t border-gray-700">
|
||||
<OptimizedJobGroup
|
||||
status="Tracked"
|
||||
jobs={trackedJobs}
|
||||
isCollapsed={collapsedGroups['Tracked'] || false}
|
||||
onToggle={onToggleGroup}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
hasAttentionJobs={groupNeedsAttention('Tracked')}
|
||||
jobAttentionMap={jobNeedsAttention}
|
||||
isTracked={true}
|
||||
isLoading={loadingStatuses.has('Tracked')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
OptimizedJobsSection.displayName = 'OptimizedJobsSection';
|
||||
|
||||
export default OptimizedJobsSection;
|
117
src/components/OptimizedRecapPopover.tsx
Normal file
117
src/components/OptimizedRecapPopover.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { getStatusBackgroundColor } from '@/utils/jobStatusUtils';
|
||||
|
||||
interface OptimizedRecapPopoverProps {
|
||||
title: string;
|
||||
jobs: IndJob[];
|
||||
children: React.ReactNode;
|
||||
calculateJobValue: (job: IndJob) => number;
|
||||
}
|
||||
|
||||
const OptimizedRecapPopover: React.FC<OptimizedRecapPopoverProps> = React.memo(({
|
||||
title,
|
||||
jobs,
|
||||
children,
|
||||
calculateJobValue
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [sortDescending, setSortDescending] = useState(true);
|
||||
|
||||
// Memoize expensive calculations
|
||||
const jobContributions = useMemo(() => {
|
||||
const contributions = jobs
|
||||
.map(job => ({
|
||||
job,
|
||||
value: calculateJobValue(job)
|
||||
}))
|
||||
.filter(({ value }) => value !== 0);
|
||||
|
||||
return contributions.sort((a, b) => {
|
||||
if (sortDescending) {
|
||||
if (a.value < 0 && b.value >= 0) return -1;
|
||||
if (a.value >= 0 && b.value < 0) return 1;
|
||||
return Math.abs(b.value) - Math.abs(a.value);
|
||||
} else {
|
||||
if (a.value >= 0 && b.value < 0) return -1;
|
||||
if (a.value < 0 && b.value >= 0) return 1;
|
||||
return Math.abs(a.value) - Math.abs(b.value);
|
||||
}
|
||||
});
|
||||
}, [jobs, calculateJobValue, sortDescending]);
|
||||
|
||||
const handleJobClick = (jobId: string) => {
|
||||
navigate(`/${jobId}`);
|
||||
};
|
||||
|
||||
const toggleSort = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSortDescending(!sortDescending);
|
||||
};
|
||||
|
||||
const handleItemClick = (e: React.MouseEvent, jobId: string) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const hasNoNavigate = target.closest('[data-no-navigate]');
|
||||
|
||||
if (!hasNoNavigate) {
|
||||
handleJobClick(jobId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[30rem] bg-gray-800/95 border-gray-600 text-white max-h-[40rem] overflow-y-auto">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg text-white flex items-center justify-between">
|
||||
<span>{title}</span>
|
||||
<button
|
||||
onClick={toggleSort}
|
||||
className="flex items-center gap-1 text-sm font-normal text-gray-300 hover:text-white transition-colors"
|
||||
title="Click to toggle sort order"
|
||||
data-no-navigate
|
||||
>
|
||||
Sort {sortDescending ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{jobContributions.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">No contributions to display</p>
|
||||
) : (
|
||||
jobContributions.map(({ job, value }) => (
|
||||
<div
|
||||
key={job.id}
|
||||
onClick={(e) => handleItemClick(e, job.id)}
|
||||
className={`flex justify-between items-center p-2 rounded hover:bg-gray-700/50 cursor-pointer transition-colors border-l-2 border-l-gray-600 ${getStatusBackgroundColor(job.status)}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-blue-400 truncate" title={job.outputItem}>
|
||||
{job.outputItem}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
ID: {job.id}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ml-2 ${value >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatISK(value)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
OptimizedRecapPopover.displayName = 'OptimizedRecapPopover';
|
||||
|
||||
export default OptimizedRecapPopover;
|
117
src/components/RecapPopover.tsx
Normal file
117
src/components/RecapPopover.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { getStatusBackgroundColor } from '@/utils/jobStatusUtils';
|
||||
|
||||
interface RecapPopoverProps {
|
||||
title: string;
|
||||
jobs: IndJob[];
|
||||
children: React.ReactNode;
|
||||
calculateJobValue: (job: IndJob) => number;
|
||||
}
|
||||
|
||||
const RecapPopover: React.FC<RecapPopoverProps> = ({
|
||||
title,
|
||||
jobs,
|
||||
children,
|
||||
calculateJobValue
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [sortDescending, setSortDescending] = useState(true);
|
||||
|
||||
const jobContributions = jobs
|
||||
.map(job => ({
|
||||
job,
|
||||
value: calculateJobValue(job)
|
||||
}))
|
||||
.filter(({ value }) => value !== 0)
|
||||
.sort((a, b) => {
|
||||
if (sortDescending) {
|
||||
// For descending: negative values first, then positive values
|
||||
// Within each group, sort by magnitude
|
||||
if (a.value < 0 && b.value >= 0) return -1; // a (negative) comes first
|
||||
if (a.value >= 0 && b.value < 0) return 1; // b (negative) comes first
|
||||
return Math.abs(b.value) - Math.abs(a.value); // same sign, sort by magnitude
|
||||
} else {
|
||||
// For ascending: positive values first, then negative values
|
||||
// Within each group, sort by magnitude
|
||||
if (a.value >= 0 && b.value < 0) return -1; // a (positive) comes first
|
||||
if (a.value < 0 && b.value >= 0) return 1; // b (positive) comes first
|
||||
return Math.abs(a.value) - Math.abs(b.value); // same sign, sort by magnitude
|
||||
}
|
||||
});
|
||||
|
||||
const handleJobClick = (jobId: string) => {
|
||||
navigate(`/${jobId}`);
|
||||
};
|
||||
|
||||
const toggleSort = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSortDescending(!sortDescending);
|
||||
};
|
||||
|
||||
const handleItemClick = (e: React.MouseEvent, jobId: string) => {
|
||||
// Check if the clicked element or its parent has data-no-navigate
|
||||
const target = e.target as HTMLElement;
|
||||
const hasNoNavigate = target.closest('[data-no-navigate]');
|
||||
|
||||
if (!hasNoNavigate) {
|
||||
handleJobClick(jobId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[30rem] bg-gray-800/95 border-gray-600 text-white max-h-[40rem] overflow-y-auto">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg text-white flex items-center justify-between">
|
||||
<span>{title}</span>
|
||||
<button
|
||||
onClick={toggleSort}
|
||||
className="flex items-center gap-1 text-sm font-normal text-gray-300 hover:text-white transition-colors"
|
||||
title="Click to toggle sort order"
|
||||
data-no-navigate
|
||||
>
|
||||
Sort {sortDescending ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{jobContributions.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">No contributions to display</p>
|
||||
) : (
|
||||
jobContributions.map(({ job, value }) => (
|
||||
<div
|
||||
key={job.id}
|
||||
onClick={(e) => handleItemClick(e, job.id)}
|
||||
className={`flex justify-between items-center p-2 rounded hover:bg-gray-700/50 cursor-pointer transition-colors border-l-2 border-l-gray-600 ${getStatusBackgroundColor(job.status)}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-blue-400 truncate" title={job.outputItem}>
|
||||
{job.outputItem}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
ID: {job.id}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ml-2 ${value >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatISK(value)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecapPopover;
|
68
src/components/SalesTaxConfig.tsx
Normal file
68
src/components/SalesTaxConfig.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
const SalesTaxConfig = () => {
|
||||
const [salesTax, setSalesTax] = useState(() => {
|
||||
return localStorage.getItem('salesTax') || '0';
|
||||
});
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem('salesTax', salesTax);
|
||||
setIsOpen(false);
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: 'salesTax',
|
||||
newValue: salesTax
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Tax Config
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 bg-gray-900 border-gray-700 text-white">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salesTax" className="text-sm font-medium text-gray-300">
|
||||
Sales Tax (%)
|
||||
</Label>
|
||||
<Input
|
||||
id="salesTax"
|
||||
type="number"
|
||||
value={salesTax}
|
||||
onChange={(e) => setSalesTax(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
className="bg-gray-800 border-gray-600 text-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">
|
||||
Applied to minimum price calculations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesTaxConfig;
|
425
src/components/TransactionChart.tsx
Normal file
425
src/components/TransactionChart.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
interface TransactionChartProps {
|
||||
job?: IndJob;
|
||||
jobs?: IndJob[];
|
||||
type: 'costs' | 'revenue' | 'profit' | 'overview' | 'total-revenue' | 'total-profit';
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TransactionChart: React.FC<TransactionChartProps> = ({
|
||||
job,
|
||||
jobs,
|
||||
type,
|
||||
isOpen,
|
||||
onClose
|
||||
}) => {
|
||||
const [hiddenLines, setHiddenLines] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleLine = (dataKey: string) => {
|
||||
const newHidden = new Set(hiddenLines);
|
||||
if (newHidden.has(dataKey)) {
|
||||
newHidden.delete(dataKey);
|
||||
} else {
|
||||
newHidden.add(dataKey);
|
||||
}
|
||||
setHiddenLines(newHidden);
|
||||
};
|
||||
|
||||
const getSmartTimeFormat = (transactions: any[]) => {
|
||||
if (transactions.length === 0) return 'day';
|
||||
|
||||
// Calculate time span
|
||||
const dates = transactions.map(tx => new Date(tx.date));
|
||||
const minDate = new Date(Math.min(...dates.map(d => d.getTime())));
|
||||
const maxDate = new Date(Math.max(...dates.map(d => d.getTime())));
|
||||
const timeSpanDays = Math.max((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24), 1);
|
||||
|
||||
// Calculate transaction density per day
|
||||
const transactionsPerDay = transactions.length / timeSpanDays;
|
||||
|
||||
// Smart scoping: if many transactions per day or short timespan with multiple transactions, use hourly
|
||||
if ((transactionsPerDay > 5 && timeSpanDays < 7) || (transactions.length > 3 && timeSpanDays <= 1)) {
|
||||
return 'hour';
|
||||
}
|
||||
return 'day';
|
||||
};
|
||||
|
||||
const getJobChartData = (job: IndJob) => {
|
||||
// Combine all transactions and group by date
|
||||
const allTransactions = [
|
||||
...job.expenditures.map(tx => ({ ...tx, type: 'expenditure' })),
|
||||
...job.income.map(tx => ({ ...tx, type: 'income' }))
|
||||
];
|
||||
|
||||
const timeFormat = getSmartTimeFormat(allTransactions);
|
||||
|
||||
// Group by appropriate time unit
|
||||
const dateMap = new Map<string, { costs: number; revenue: number; date: string }>();
|
||||
|
||||
allTransactions.forEach(tx => {
|
||||
const dateStr = timeFormat === 'hour'
|
||||
? format(parseISO(tx.date), 'yyyy-MM-dd HH:00')
|
||||
: format(parseISO(tx.date), 'yyyy-MM-dd');
|
||||
|
||||
if (!dateMap.has(dateStr)) {
|
||||
dateMap.set(dateStr, { costs: 0, revenue: 0, date: dateStr });
|
||||
}
|
||||
const entry = dateMap.get(dateStr)!;
|
||||
if (tx.type === 'expenditure') {
|
||||
entry.costs += tx.totalPrice;
|
||||
} else {
|
||||
entry.revenue += tx.totalPrice;
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array and calculate profit
|
||||
const sortedData = Array.from(dateMap.values())
|
||||
.map(entry => ({
|
||||
...entry,
|
||||
profit: entry.revenue - entry.costs,
|
||||
formattedDate: timeFormat === 'hour'
|
||||
? format(new Date(entry.date), 'MMM dd HH:mm')
|
||||
: format(new Date(entry.date), 'MMM dd')
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Add cumulative values
|
||||
let cumulativeCosts = 0;
|
||||
let cumulativeRevenue = 0;
|
||||
let cumulativeProfit = 0;
|
||||
|
||||
return sortedData.map(entry => {
|
||||
cumulativeCosts += entry.costs;
|
||||
cumulativeRevenue += entry.revenue;
|
||||
cumulativeProfit += entry.profit;
|
||||
return {
|
||||
...entry,
|
||||
cumulativeCosts,
|
||||
cumulativeRevenue,
|
||||
cumulativeProfit
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getOverviewChartData = (jobs: IndJob[]) => {
|
||||
// Combine all transactions from all jobs
|
||||
const allTransactions = jobs.flatMap(job => [
|
||||
...job.expenditures.map(tx => ({ ...tx, type: 'expenditure' })),
|
||||
...job.income.map(tx => ({ ...tx, type: 'income' }))
|
||||
]);
|
||||
|
||||
const timeFormat = getSmartTimeFormat(allTransactions);
|
||||
const dateMap = new Map<string, { revenue: number; profit: number; date: string }>();
|
||||
|
||||
allTransactions.forEach(tx => {
|
||||
const dateStr = timeFormat === 'hour'
|
||||
? format(parseISO(tx.date), 'yyyy-MM-dd HH:00')
|
||||
: format(parseISO(tx.date), 'yyyy-MM-dd');
|
||||
|
||||
if (!dateMap.has(dateStr)) {
|
||||
dateMap.set(dateStr, { revenue: 0, profit: 0, date: dateStr });
|
||||
}
|
||||
const entry = dateMap.get(dateStr)!;
|
||||
if (tx.type === 'income') {
|
||||
entry.revenue += tx.totalPrice;
|
||||
entry.profit += tx.totalPrice;
|
||||
} else {
|
||||
entry.profit -= tx.totalPrice;
|
||||
}
|
||||
});
|
||||
|
||||
const sortedData = Array.from(dateMap.values())
|
||||
.map(entry => ({
|
||||
...entry,
|
||||
formattedDate: timeFormat === 'hour'
|
||||
? format(new Date(entry.date), 'MMM dd HH:mm')
|
||||
: format(new Date(entry.date), 'MMM dd')
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Add cumulative values
|
||||
let cumulativeRevenue = 0;
|
||||
let cumulativeProfit = 0;
|
||||
|
||||
return sortedData.map(entry => {
|
||||
cumulativeRevenue += entry.revenue;
|
||||
cumulativeProfit += entry.profit;
|
||||
return {
|
||||
...entry,
|
||||
cumulativeRevenue,
|
||||
cumulativeProfit
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const formatTooltipValue = (value: number) => formatISK(value);
|
||||
|
||||
const data = (type === 'overview' || type === 'total-revenue' || type === 'total-profit') && jobs ? getOverviewChartData(jobs) : job ? getJobChartData(job) : [];
|
||||
|
||||
const getTitle = () => {
|
||||
if (type === 'overview') return 'Overview - Revenue & Profit Over Time';
|
||||
if (type === 'total-revenue') return 'Total Revenue Over Time';
|
||||
if (type === 'total-profit') return 'Total Profit Over Time';
|
||||
if (job) {
|
||||
switch (type) {
|
||||
case 'costs': return `${job.outputItem} - Costs Over Time`;
|
||||
case 'revenue': return `${job.outputItem} - Revenue Over Time`;
|
||||
case 'profit': return `${job.outputItem} - Costs, Revenue & Profit Over Time`;
|
||||
default: return `${job.outputItem} - Transaction History`;
|
||||
}
|
||||
}
|
||||
return 'Transaction History';
|
||||
};
|
||||
|
||||
const renderChart = () => {
|
||||
if (type === 'total-revenue') {
|
||||
return (
|
||||
<AreaChart data={data} margin={{ top: 20, right: 80, left: 80, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} width={70} />
|
||||
<Tooltip
|
||||
formatter={formatTooltipValue}
|
||||
labelStyle={{ color: '#F3F4F6' }}
|
||||
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cumulativeRevenue"
|
||||
stroke="#10B981"
|
||||
fill="#10B981"
|
||||
fillOpacity={0.3}
|
||||
name="Cumulative Revenue"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#059669"
|
||||
strokeWidth={2}
|
||||
name="Revenue per Day"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'total-profit') {
|
||||
return (
|
||||
<AreaChart data={data} margin={{ top: 20, right: 80, left: 80, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} width={70} />
|
||||
<Tooltip
|
||||
formatter={formatTooltipValue}
|
||||
labelStyle={{ color: '#F3F4F6' }}
|
||||
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cumulativeProfit"
|
||||
stroke="#3B82F6"
|
||||
fill="#3B82F6"
|
||||
fillOpacity={0.3}
|
||||
name="Cumulative Profit"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="profit"
|
||||
stroke="#1E40AF"
|
||||
strokeWidth={2}
|
||||
name="Profit per Day"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'overview') {
|
||||
return (
|
||||
<AreaChart data={data} margin={{ top: 20, right: 80, left: 80, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} width={70} />
|
||||
<Tooltip
|
||||
formatter={formatTooltipValue}
|
||||
labelStyle={{ color: '#F3F4F6' }}
|
||||
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cumulativeRevenue"
|
||||
stroke="#10B981"
|
||||
fill="#10B981"
|
||||
fillOpacity={0.3}
|
||||
name="Cumulative Revenue"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cumulativeProfit"
|
||||
stroke="#3B82F6"
|
||||
fill="#3B82F6"
|
||||
fillOpacity={0.3}
|
||||
name="Cumulative Profit"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#059669"
|
||||
strokeWidth={2}
|
||||
name="Revenue per Day"
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="profit"
|
||||
stroke="#1E40AF"
|
||||
strokeWidth={2}
|
||||
name="Profit per Day"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'costs') {
|
||||
return (
|
||||
<AreaChart data={data} margin={{ top: 20, right: 80, left: 80, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} width={70} />
|
||||
<Tooltip
|
||||
formatter={formatTooltipValue}
|
||||
labelStyle={{ color: '#F3F4F6' }}
|
||||
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cumulativeCosts"
|
||||
stroke="#EF4444"
|
||||
fill="#EF4444"
|
||||
fillOpacity={0.3}
|
||||
name="Cumulative Costs"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="costs"
|
||||
stroke="#DC2626"
|
||||
strokeWidth={2}
|
||||
name="Costs per Day"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'revenue') {
|
||||
return (
|
||||
<AreaChart data={data} margin={{ top: 20, right: 80, left: 80, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} width={70} />
|
||||
<Tooltip
|
||||
formatter={formatTooltipValue}
|
||||
labelStyle={{ color: '#F3F4F6' }}
|
||||
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cumulativeRevenue"
|
||||
stroke="#10B981"
|
||||
fill="#10B981"
|
||||
fillOpacity={0.3}
|
||||
name="Cumulative Revenue"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#059669"
|
||||
strokeWidth={2}
|
||||
name="Revenue per Day"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'profit') {
|
||||
return (
|
||||
<AreaChart data={data} margin={{ top: 20, right: 80, left: 80, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} width={70} />
|
||||
<Tooltip
|
||||
formatter={formatTooltipValue}
|
||||
labelStyle={{ color: '#F3F4F6' }}
|
||||
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cumulativeProfit"
|
||||
stroke="#3B82F6"
|
||||
fill="#3B82F6"
|
||||
fillOpacity={0.3}
|
||||
name="Cumulative Profit"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="profit"
|
||||
stroke="#1E40AF"
|
||||
strokeWidth={2}
|
||||
name="Profit per Day"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-6xl w-[90vw] h-[80vh] bg-gray-900 border-gray-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">{getTitle()}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 h-full">
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
{renderChart()}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="border-gray-600"
|
||||
data-no-navigate
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionChart;
|
@@ -1,12 +1,11 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { parseTransactionLine, formatISK } from '@/utils/priceUtils';
|
||||
import { parseTransactionLine, formatISK, PastedTransaction } from '@/utils/priceUtils';
|
||||
import { IndTransactionRecordNoId } from '@/lib/pbtypes';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
@@ -19,6 +18,14 @@ const TransactionForm: React.FC<TransactionFormProps> = ({ jobId, onTransactions
|
||||
const [pastedData, setPastedData] = useState('');
|
||||
const [parsedTransactions, setParsedTransactions] = useState<IndTransactionRecordNoId[]>([]);
|
||||
const [transactionType, setTransactionType] = useState<'expenditure' | 'income'>('expenditure');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto focus the textarea when component mounts or when transaction type changes
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [transactionType]);
|
||||
|
||||
const handlePaste = (value: string) => {
|
||||
setPastedData(value);
|
||||
@@ -27,20 +34,10 @@ const TransactionForm: React.FC<TransactionFormProps> = ({ jobId, onTransactions
|
||||
const transactions: IndTransactionRecordNoId[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const parsed = parseTransactionLine(line);
|
||||
const parsed: PastedTransaction | null = parseTransactionLine(line);
|
||||
|
||||
if (parsed) {
|
||||
transactions.push({
|
||||
date: parsed.date.toISOString(),
|
||||
quantity: parsed.quantity,
|
||||
itemName: parsed.itemName,
|
||||
unitPrice: parsed.unitPrice,
|
||||
totalPrice: Math.abs(parsed.totalAmount),
|
||||
buyer: parsed.buyer,
|
||||
location: parsed.location,
|
||||
corporation: parsed.corporation,
|
||||
wallet: parsed.wallet,
|
||||
job: jobId
|
||||
});
|
||||
transactions.push(parsed);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,6 +76,7 @@ const TransactionForm: React.FC<TransactionFormProps> = ({ jobId, onTransactions
|
||||
Paste EVE transaction data (Ctrl+V):
|
||||
</label>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={pastedData}
|
||||
onChange={(e) => handlePaste(e.target.value)}
|
||||
placeholder="Paste your EVE transaction data here..."
|
||||
|
216
src/components/TransactionManager.tsx
Normal file
216
src/components/TransactionManager.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IndJob, IndTransactionRecord } from '@/types/industry';
|
||||
import { formatISK, parseTransactionLine } from '@/utils/currency';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface TransactionManagerProps {
|
||||
job: IndJob;
|
||||
}
|
||||
|
||||
export function TransactionManager({ job }: TransactionManagerProps) {
|
||||
const { updateJob, deleteTransaction } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const [transactionType, setTransactionType] = useState<'expenditure' | 'income'>('expenditure');
|
||||
|
||||
const handlePasteImport = () => {
|
||||
if (!pasteText.trim()) return;
|
||||
|
||||
const lines = pasteText.split('\n').filter(line => line.trim());
|
||||
const transactions: Omit<IndTransactionRecord, 'id' | 'created' | 'updated' | 'job'>[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseTransactionLine(line);
|
||||
if (parsed) {
|
||||
transactions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
toast({
|
||||
title: "Import Failed",
|
||||
description: "No valid transactions found in the pasted text.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate against existing transactions
|
||||
const existingTransactions = [
|
||||
...(job.expenditures || []),
|
||||
...(job.income || [])
|
||||
];
|
||||
|
||||
const newTransactions = transactions.filter(newTx => {
|
||||
return !existingTransactions.some(existing =>
|
||||
existing.date === newTx.date &&
|
||||
existing.itemName === newTx.itemName &&
|
||||
existing.quantity === newTx.quantity &&
|
||||
existing.totalPrice === newTx.totalPrice &&
|
||||
existing.buyer === newTx.buyer
|
||||
);
|
||||
});
|
||||
|
||||
if (newTransactions.length === 0) {
|
||||
toast({
|
||||
title: "No New Transactions",
|
||||
description: "All transactions already exist in this job.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to appropriate category
|
||||
const currentTransactions = transactionType === 'expenditure'
|
||||
? (job.expenditures || [])
|
||||
: (job.income || []);
|
||||
|
||||
const updatedTransactions = [...currentTransactions];
|
||||
newTransactions.forEach(tx => {
|
||||
updatedTransactions.push({
|
||||
...tx,
|
||||
id: crypto.randomUUID(),
|
||||
job: job.id,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
updateJob(job.id, {
|
||||
[transactionType === 'expenditure' ? 'expenditures' : 'income']: updatedTransactions
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Import Successful",
|
||||
description: `Added ${newTransactions.length} new transactions.`,
|
||||
});
|
||||
|
||||
setPasteText('');
|
||||
};
|
||||
|
||||
const handleDeleteTransaction = (transactionId: string) => {
|
||||
deleteTransaction(job.id, transactionId);
|
||||
};
|
||||
|
||||
const TransactionTable = ({
|
||||
transactions,
|
||||
title,
|
||||
type
|
||||
}: {
|
||||
transactions: IndTransactionRecord[];
|
||||
title: string;
|
||||
type: 'expenditure' | 'income';
|
||||
}) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
{title}
|
||||
<Badge variant="outline">
|
||||
{formatISK(transactions.reduce((sum, t) => sum + t.totalPrice, 0))}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transactions.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Unit Price</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Buyer/Seller</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map(transaction => (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell>{new Date(transaction.date).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{transaction.itemName}</TableCell>
|
||||
<TableCell>{transaction.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>{formatISK(transaction.unitPrice)}</TableCell>
|
||||
<TableCell className={type === 'income' ? 'text-success' : 'text-destructive'}>
|
||||
{formatISK(transaction.totalPrice)}
|
||||
</TableCell>
|
||||
<TableCell>{transaction.buyer || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTransaction(transaction.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">No transactions yet</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Import Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={transactionType === 'expenditure' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTransactionType('expenditure')}
|
||||
>
|
||||
Expenditures
|
||||
</Button>
|
||||
<Button
|
||||
variant={transactionType === 'income' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTransactionType('income')}
|
||||
>
|
||||
Income
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="Paste EVE transaction data here (Ctrl+V)..."
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<Button onClick={handlePasteImport} disabled={!pasteText.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Import Transactions
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TransactionTable
|
||||
transactions={job.expenditures || []}
|
||||
title="Expenditures"
|
||||
type="expenditure"
|
||||
/>
|
||||
|
||||
<TransactionTable
|
||||
transactions={job.income || []}
|
||||
title="Income"
|
||||
type="income"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
26
src/components/batch-expenditure/BatchExpenditureHeader.tsx
Normal file
26
src/components/batch-expenditure/BatchExpenditureHeader.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface BatchExpenditureHeaderProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BatchExpenditureHeader: React.FC<BatchExpenditureHeaderProps> = ({ onClose }) => {
|
||||
return (
|
||||
<CardHeader className="flex flex-row items-center justify-between sticky top-0 bg-gray-900 border-b border-gray-700 z-10">
|
||||
<CardTitle className="text-blue-400">Batch Expenditure Assignment</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchExpenditureHeader;
|
31
src/components/batch-expenditure/ExpenditureActions.tsx
Normal file
31
src/components/batch-expenditure/ExpenditureActions.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ExpenditureActionsProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
const ExpenditureActions: React.FC<ExpenditureActionsProps> = ({ onCancel, onSubmit, canSubmit }) => {
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Assign Expenditures
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenditureActions;
|
26
src/components/batch-expenditure/ExpenditureStats.tsx
Normal file
26
src/components/batch-expenditure/ExpenditureStats.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface ExpenditureStatsProps {
|
||||
totalExpenditures: number;
|
||||
duplicatesFound: number;
|
||||
}
|
||||
|
||||
const ExpenditureStats: React.FC<ExpenditureStatsProps> = ({ totalExpenditures, duplicatesFound }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="text-blue-400 border-blue-400">
|
||||
{totalExpenditures} expenditures found
|
||||
</Badge>
|
||||
{duplicatesFound > 0 && (
|
||||
<Badge variant="outline" className="text-yellow-400 border-yellow-400">
|
||||
{duplicatesFound} duplicates found
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenditureStats;
|
106
src/components/batch-expenditure/ExpenditureTable.tsx
Normal file
106
src/components/batch-expenditure/ExpenditureTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
interface TransactionGroup {
|
||||
itemName: string;
|
||||
transactions: any[];
|
||||
totalQuantity: number;
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
interface ExpenditureTableProps {
|
||||
transactionGroups: TransactionGroup[];
|
||||
jobs: IndJob[];
|
||||
eligibleJobs: IndJob[];
|
||||
onAssignJob: (groupIndex: number, jobId: string) => void;
|
||||
}
|
||||
|
||||
const ExpenditureTable: React.FC<ExpenditureTableProps> = ({
|
||||
transactionGroups,
|
||||
jobs,
|
||||
eligibleJobs,
|
||||
onAssignJob
|
||||
}) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-gray-700">
|
||||
<TableHead className="text-gray-300">Material</TableHead>
|
||||
<TableHead className="text-gray-300">Quantity</TableHead>
|
||||
<TableHead className="text-gray-300">Total Cost</TableHead>
|
||||
<TableHead className="text-gray-300">Assign To Job</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactionGroups.map((group, index) => {
|
||||
const autoAssigned = group.transactions[0]?.assignedJobId;
|
||||
const isDuplicate = group.transactions[0]?.isDuplicate;
|
||||
const matchingJob = autoAssigned ? jobs.find(j => j.id === autoAssigned) : undefined;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={`${group.itemName}-${index}`}
|
||||
className={`border-gray-700 ${isDuplicate ? 'bg-red-900/30' : ''}`}
|
||||
>
|
||||
<TableCell className="text-white flex items-center gap-2">
|
||||
{group.itemName}
|
||||
{isDuplicate && (
|
||||
<Badge variant="destructive" className="bg-red-600">
|
||||
Duplicate
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{group.totalQuantity.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-red-400">
|
||||
{formatISK(group.totalValue)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isDuplicate ? (
|
||||
<div className="text-red-400 text-sm">
|
||||
Transaction already exists
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={group.transactions[0]?.assignedJobId || ''}
|
||||
onValueChange={(value) => onAssignJob(index, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`bg-gray-800 border-gray-600 text-white ${autoAssigned ? 'border-green-600' : ''}`}
|
||||
>
|
||||
<SelectValue placeholder={autoAssigned ? `Auto-assigned to ${matchingJob?.outputItem}` : 'Select a job'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-800 border-gray-600">
|
||||
{eligibleJobs
|
||||
.filter(job =>
|
||||
job.billOfMaterials?.some(item =>
|
||||
item.name.toLowerCase().includes(group.itemName.toLowerCase())
|
||||
)
|
||||
)
|
||||
.map(job => (
|
||||
<SelectItem
|
||||
key={job.id}
|
||||
value={job.id}
|
||||
className="text-white"
|
||||
>
|
||||
{job.outputItem} (Acquisition)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenditureTable;
|
36
src/components/batch-expenditure/PasteExpenditureInput.tsx
Normal file
36
src/components/batch-expenditure/PasteExpenditureInput.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface PasteExpenditureInputProps {
|
||||
pastedData: string;
|
||||
onPaste: (value: string) => void;
|
||||
}
|
||||
|
||||
const PasteExpenditureInput: React.FC<PasteExpenditureInputProps> = ({ pastedData, onPaste }) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto focus the textarea when component mounts
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300">
|
||||
Paste EVE expenditure data (negative amounts):
|
||||
</label>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={pastedData}
|
||||
onChange={(e) => onPaste(e.target.value)}
|
||||
placeholder="Paste your EVE expenditure transaction data here..."
|
||||
className="min-h-32 bg-gray-800 border-gray-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteExpenditureInput;
|
26
src/components/batch-transaction/BatchTransactionHeader.tsx
Normal file
26
src/components/batch-transaction/BatchTransactionHeader.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface BatchTransactionHeaderProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BatchTransactionHeader: React.FC<BatchTransactionHeaderProps> = ({ onClose }) => {
|
||||
return (
|
||||
<CardHeader className="flex flex-row items-center justify-between sticky top-0 bg-gray-900 border-b border-gray-700 z-10">
|
||||
<CardTitle className="text-blue-400">Batch Transaction Assignment</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchTransactionHeader;
|
36
src/components/batch-transaction/PasteTransactionInput.tsx
Normal file
36
src/components/batch-transaction/PasteTransactionInput.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface PasteTransactionInputProps {
|
||||
pastedData: string;
|
||||
onPaste: (value: string) => void;
|
||||
}
|
||||
|
||||
const PasteTransactionInput: React.FC<PasteTransactionInputProps> = ({ pastedData, onPaste }) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto focus the textarea when component mounts
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-300">
|
||||
Paste EVE transaction data:
|
||||
</label>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={pastedData}
|
||||
onChange={(e) => onPaste(e.target.value)}
|
||||
placeholder="Paste your EVE transaction data here..."
|
||||
className="min-h-32 bg-gray-800 border-gray-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteTransactionInput;
|
35
src/components/batch-transaction/TransactionActions.tsx
Normal file
35
src/components/batch-transaction/TransactionActions.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface TransactionActionsProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
const TransactionActions: React.FC<TransactionActionsProps> = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
canSubmit
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Assign Transactions
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionActions;
|
24
src/components/batch-transaction/TransactionStats.tsx
Normal file
24
src/components/batch-transaction/TransactionStats.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface TransactionStatsProps {
|
||||
transactionCount: number;
|
||||
duplicatesFound: number;
|
||||
}
|
||||
|
||||
const TransactionStats: React.FC<TransactionStatsProps> = ({ transactionCount, duplicatesFound }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="text-blue-400 border-blue-400">
|
||||
{transactionCount} transactions found
|
||||
</Badge>
|
||||
{duplicatesFound > 0 && (
|
||||
<Badge variant="outline" className="text-yellow-400 border-yellow-400">
|
||||
{duplicatesFound} duplicates found
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionStats;
|
102
src/components/batch-transaction/TransactionTable.tsx
Normal file
102
src/components/batch-transaction/TransactionTable.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
interface TransactionGroup {
|
||||
itemName: string;
|
||||
transactions: any[];
|
||||
totalQuantity: number;
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
interface TransactionTableProps {
|
||||
transactionGroups: TransactionGroup[];
|
||||
jobs: IndJob[];
|
||||
eligibleJobs: IndJob[];
|
||||
onAssignJob: (groupIndex: number, jobId: string) => void;
|
||||
}
|
||||
|
||||
const TransactionTable: React.FC<TransactionTableProps> = ({
|
||||
transactionGroups,
|
||||
jobs,
|
||||
eligibleJobs,
|
||||
onAssignJob
|
||||
}) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-gray-700">
|
||||
<TableHead className="text-gray-300">Item</TableHead>
|
||||
<TableHead className="text-gray-300">Quantity</TableHead>
|
||||
<TableHead className="text-gray-300">Total Value</TableHead>
|
||||
<TableHead className="text-gray-300">Assign To Job</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactionGroups.map((group, index) => {
|
||||
const autoAssigned = group.transactions[0]?.assignedJobId;
|
||||
const isDuplicate = group.transactions[0]?.isDuplicate;
|
||||
const matchingJob = autoAssigned ? jobs.find(j => j.id === autoAssigned) : undefined;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={group.itemName}
|
||||
className={`border-gray-700 ${isDuplicate ? 'bg-red-900/30' : ''}`}
|
||||
>
|
||||
<TableCell className="text-white flex items-center gap-2">
|
||||
{group.itemName}
|
||||
{isDuplicate && (
|
||||
<Badge variant="destructive" className="bg-red-600">
|
||||
Duplicate
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{group.totalQuantity.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-green-400">
|
||||
{formatISK(group.totalValue)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isDuplicate ? (
|
||||
<div className="text-red-400 text-sm">
|
||||
Transaction already exists
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={group.transactions[0]?.assignedJobId || ''}
|
||||
onValueChange={(value) => onAssignJob(index, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`bg-gray-800 border-gray-600 text-white ${autoAssigned ? 'border-green-600' : ''}`}
|
||||
>
|
||||
<SelectValue placeholder={autoAssigned ? `Auto-assigned to ${matchingJob?.outputItem}` : 'Select a job'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-800 border-gray-600">
|
||||
{eligibleJobs
|
||||
.filter(job => job.outputItem.includes(group.itemName) || job.status === 'Tracked')
|
||||
.map(job => (
|
||||
<SelectItem
|
||||
key={job.id}
|
||||
value={job.id}
|
||||
className="text-white"
|
||||
>
|
||||
{job.outputItem} ({job.status})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionTable;
|
192
src/hooks/useBatchExpenditureLogic.ts
Normal file
192
src/hooks/useBatchExpenditureLogic.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import { parseTransactionLine, formatISK, PastedTransaction } from '@/utils/priceUtils';
|
||||
import { IndTransactionRecordNoId, IndJobStatusOptions } from '@/lib/pbtypes';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
interface TransactionGroup {
|
||||
itemName: string;
|
||||
transactions: PastedTransaction[];
|
||||
totalQuantity: number;
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
export const useBatchExpenditureLogic = (jobs: IndJob[]) => {
|
||||
const [pastedData, setPastedData] = useState('');
|
||||
const [transactionGroups, setTransactionGroups] = useState<TransactionGroup[]>([]);
|
||||
const [duplicatesFound, setDuplicatesFound] = useState(0);
|
||||
|
||||
// Filter jobs that are in acquisition status
|
||||
const eligibleJobs = jobs.filter(job => job.status === IndJobStatusOptions.Acquisition);
|
||||
|
||||
const findMatchingJob = (itemName: string): string | undefined => {
|
||||
// Find jobs where the item is in the bill of materials and not satisfied
|
||||
for (const job of eligibleJobs) {
|
||||
const billItem = job.billOfMaterials?.find(item =>
|
||||
item.name.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
|
||||
if (billItem) {
|
||||
// Check if this material is already satisfied
|
||||
const ownedQuantity = job.expenditures?.reduce((total, exp) =>
|
||||
exp.itemName.toLowerCase() === itemName.toLowerCase() ? total + exp.quantity : total, 0
|
||||
) || 0;
|
||||
|
||||
// Only return this job if we still need more of this material
|
||||
if (ownedQuantity < billItem.quantity) {
|
||||
return job.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeDate = (dateStr: string): string => {
|
||||
return dateStr.replace('T', ' ');
|
||||
};
|
||||
|
||||
const createTransactionKey = (parsed: PastedTransaction): string => {
|
||||
if (!parsed) return '';
|
||||
const key = [
|
||||
normalizeDate(parsed.date.toString()),
|
||||
parsed.itemName,
|
||||
parsed.quantity.toString(),
|
||||
Math.abs(parsed.totalPrice).toString(), // Use absolute value for expenditures
|
||||
parsed.buyer,
|
||||
parsed.location
|
||||
].join('|');
|
||||
return key;
|
||||
};
|
||||
|
||||
const createTransactionKeyFromRecord = (tx: IndTransactionRecordNoId): string => {
|
||||
const key = [
|
||||
normalizeDate(tx.date),
|
||||
tx.itemName,
|
||||
tx.quantity.toString(),
|
||||
Math.abs(tx.totalPrice).toString(), // Use absolute value for expenditures
|
||||
tx.buyer,
|
||||
tx.location
|
||||
].join('|');
|
||||
return key;
|
||||
};
|
||||
|
||||
const handlePaste = (value: string) => {
|
||||
console.log('Handling paste with value:', value);
|
||||
setPastedData(value);
|
||||
const lines = value.trim().split('\n').filter(line => line.trim().length > 0);
|
||||
console.log('Processing lines:', lines);
|
||||
|
||||
const pasteTransactionMap = new Map<string, PastedTransaction>();
|
||||
|
||||
// STEP 1: Combine identical transactions within the pasted data
|
||||
lines.forEach((line, index) => {
|
||||
console.log(`Processing line ${index}:`, line);
|
||||
const parsed: PastedTransaction | null = parseTransactionLine(line);
|
||||
|
||||
if (parsed) {
|
||||
console.log('Parsed transaction:', parsed);
|
||||
|
||||
// For expenditures, we expect negative amounts, but handle both cases
|
||||
const isExpenditure = parsed.totalPrice < 0;
|
||||
if (isExpenditure) {
|
||||
// Convert to positive values for expenditures
|
||||
parsed.totalPrice = Math.abs(parsed.totalPrice);
|
||||
parsed.unitPrice = Math.abs(parsed.unitPrice);
|
||||
} else {
|
||||
// If it's positive, we might still want to treat it as expenditure
|
||||
// based on context, but let's keep it as is for now
|
||||
console.log('Transaction has positive amount, treating as expenditure anyway');
|
||||
}
|
||||
|
||||
const transactionKey: string = createTransactionKey(parsed);
|
||||
console.log('Transaction key:', transactionKey);
|
||||
|
||||
if (pasteTransactionMap.has(transactionKey)) {
|
||||
const existing = pasteTransactionMap.get(transactionKey)!;
|
||||
existing.quantity += parsed.quantity;
|
||||
existing.totalPrice += parsed.totalPrice;
|
||||
const newKey = createTransactionKey(existing);
|
||||
pasteTransactionMap.set(newKey, existing);
|
||||
pasteTransactionMap.delete(transactionKey);
|
||||
} else {
|
||||
pasteTransactionMap.set(transactionKey, parsed);
|
||||
}
|
||||
} else {
|
||||
console.log('Failed to parse line:', line);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Parsed transactions map:', pasteTransactionMap);
|
||||
|
||||
// STEP 2: Identify which jobs these transactions belong to
|
||||
const relevantJobIds = new Set<string>();
|
||||
pasteTransactionMap.forEach((transaction) => {
|
||||
const matchingJobId = findMatchingJob(transaction.itemName);
|
||||
if (matchingJobId) {
|
||||
relevantJobIds.add(matchingJobId);
|
||||
transaction.assignedJobId = matchingJobId;
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 3: Check against existing expenditures from relevant jobs
|
||||
const existingTransactionKeys = new Set<string>();
|
||||
eligibleJobs.forEach(job => {
|
||||
if (relevantJobIds.has(job.id)) {
|
||||
job.expenditures?.forEach(tx => {
|
||||
const key = createTransactionKeyFromRecord(tx);
|
||||
existingTransactionKeys.add(key);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 4: Mark duplicates and assign jobs
|
||||
let duplicates = 0;
|
||||
pasteTransactionMap.forEach((transaction, key) => {
|
||||
const isDuplicate = existingTransactionKeys.has(key);
|
||||
transaction.isDuplicate = isDuplicate;
|
||||
|
||||
if (isDuplicate) {
|
||||
duplicates++;
|
||||
transaction.assignedJobId = undefined;
|
||||
} else if (!transaction.assignedJobId) {
|
||||
transaction.assignedJobId = findMatchingJob(transaction.itemName);
|
||||
}
|
||||
});
|
||||
|
||||
const transactionList = Array.from(pasteTransactionMap.values());
|
||||
console.log('Final transaction list:', transactionList);
|
||||
setDuplicatesFound(duplicates);
|
||||
|
||||
// Create individual transaction groups
|
||||
const groups = transactionList.map(tx => ({
|
||||
itemName: tx.itemName,
|
||||
transactions: [tx],
|
||||
totalQuantity: tx.quantity,
|
||||
totalValue: tx.totalPrice
|
||||
}));
|
||||
|
||||
console.log('Transaction groups:', groups);
|
||||
setTransactionGroups(groups);
|
||||
};
|
||||
|
||||
const handleAssignJob = (groupIndex: number, jobId: string) => {
|
||||
setTransactionGroups(prev => {
|
||||
const newGroups = [...prev];
|
||||
newGroups[groupIndex].transactions.forEach(tx => {
|
||||
tx.assignedJobId = jobId;
|
||||
});
|
||||
return newGroups;
|
||||
});
|
||||
};
|
||||
|
||||
const canSubmit = transactionGroups.some(g => g.transactions.some(tx => !tx.isDuplicate && tx.assignedJobId));
|
||||
|
||||
return {
|
||||
pastedData,
|
||||
transactionGroups,
|
||||
duplicatesFound,
|
||||
eligibleJobs,
|
||||
handlePaste,
|
||||
handleAssignJob,
|
||||
canSubmit
|
||||
};
|
||||
};
|
185
src/hooks/useBatchTransactionLogic.ts
Normal file
185
src/hooks/useBatchTransactionLogic.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { parseTransactionLine, PastedTransaction } from '@/utils/priceUtils';
|
||||
import { IndTransactionRecordNoId, IndJobStatusOptions } from '@/lib/pbtypes';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
interface TransactionGroup {
|
||||
itemName: string;
|
||||
transactions: PastedTransaction[];
|
||||
totalQuantity: number;
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
export const useBatchTransactionLogic = (jobs: IndJob[]) => {
|
||||
const [pastedData, setPastedData] = useState('');
|
||||
const [transactionGroups, setTransactionGroups] = useState<TransactionGroup[]>([]);
|
||||
const [duplicatesFound, setDuplicatesFound] = useState(0);
|
||||
|
||||
// Filter jobs that are either running, selling, or tracked
|
||||
const eligibleJobs = jobs.filter(job =>
|
||||
job.status === IndJobStatusOptions.Running ||
|
||||
job.status === IndJobStatusOptions.Selling ||
|
||||
job.status === IndJobStatusOptions.Tracked
|
||||
);
|
||||
|
||||
const findMatchingJob = (itemName: string): string | undefined => {
|
||||
// First try exact match
|
||||
const exactMatch = eligibleJobs.find(job => job.outputItem === itemName);
|
||||
if (exactMatch) return exactMatch.id;
|
||||
|
||||
// Then try case-insensitive match
|
||||
const caseInsensitiveMatch = eligibleJobs.find(job =>
|
||||
job.outputItem.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
if (caseInsensitiveMatch) return caseInsensitiveMatch.id;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeDate = (dateStr: string): string => {
|
||||
// Convert any ISO date string to consistent format with space
|
||||
return dateStr.replace('T', ' ');
|
||||
};
|
||||
|
||||
const createTransactionKey = (parsed: PastedTransaction): string => {
|
||||
if (!parsed) return '';
|
||||
const key = [
|
||||
normalizeDate(parsed.date.toString()),
|
||||
parsed.itemName,
|
||||
parsed.quantity.toString(),
|
||||
parsed.totalPrice.toString(),
|
||||
parsed.buyer,
|
||||
parsed.location
|
||||
].join('|');
|
||||
return key;
|
||||
};
|
||||
|
||||
const createTransactionKeyFromRecord = (tx: IndTransactionRecordNoId): string => {
|
||||
const key = [
|
||||
normalizeDate(tx.date),
|
||||
tx.itemName,
|
||||
tx.quantity.toString(),
|
||||
tx.totalPrice.toString(),
|
||||
tx.buyer,
|
||||
tx.location
|
||||
].join('|');
|
||||
return key;
|
||||
};
|
||||
|
||||
const handlePaste = (value: string) => {
|
||||
setPastedData(value);
|
||||
const lines = value.trim().split('\n');
|
||||
const pasteTransactionMap = new Map<string, PastedTransaction>();
|
||||
|
||||
// STEP 1: First combine all identical transactions within the pasted data
|
||||
lines.forEach((line) => {
|
||||
const parsed: PastedTransaction | null = parseTransactionLine(line);
|
||||
if (parsed) {
|
||||
const transactionKey: string = createTransactionKey(parsed);
|
||||
|
||||
if (pasteTransactionMap.has(transactionKey)) {
|
||||
// Merge with existing transaction in paste
|
||||
const existing = pasteTransactionMap.get(transactionKey)!;
|
||||
existing.quantity += parsed.quantity;
|
||||
existing.totalPrice += Math.abs(parsed.totalPrice);
|
||||
const newKey = createTransactionKey(existing);
|
||||
pasteTransactionMap.set(newKey, existing);
|
||||
pasteTransactionMap.delete(transactionKey); // Remove old key
|
||||
} else {
|
||||
// Add new transaction
|
||||
pasteTransactionMap.set(transactionKey, parsed);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 2: Identify which jobs these transactions belong to
|
||||
const relevantJobIds = new Set<string>();
|
||||
pasteTransactionMap.forEach((transaction) => {
|
||||
const matchingJobId = findMatchingJob(transaction.itemName);
|
||||
if (matchingJobId) {
|
||||
relevantJobIds.add(matchingJobId);
|
||||
transaction.assignedJobId = matchingJobId;
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 3: Only check against transactions from relevant jobs
|
||||
const existingTransactionKeys = new Set<string>();
|
||||
eligibleJobs.forEach(job => {
|
||||
if (relevantJobIds.has(job.id)) {
|
||||
job.income.forEach(tx => {
|
||||
const key = createTransactionKeyFromRecord(tx);
|
||||
existingTransactionKeys.add(key);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 4: Mark duplicates and assign jobs
|
||||
let duplicates = 0;
|
||||
pasteTransactionMap.forEach((transaction, key) => {
|
||||
const isDuplicate = existingTransactionKeys.has(key);
|
||||
transaction.isDuplicate = isDuplicate;
|
||||
|
||||
if (isDuplicate) {
|
||||
duplicates++;
|
||||
transaction.assignedJobId = undefined;
|
||||
} else if (!!transaction.assignedJobId) {
|
||||
transaction.assignedJobId = findMatchingJob(transaction.itemName);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert map to array for display
|
||||
const transactionList = Array.from(pasteTransactionMap.values());
|
||||
setDuplicatesFound(duplicates);
|
||||
|
||||
// Create individual transaction groups (no grouping by item name)
|
||||
const groups = transactionList.map(tx => ({
|
||||
itemName: tx.itemName,
|
||||
transactions: [tx],
|
||||
totalQuantity: tx.quantity,
|
||||
totalValue: tx.totalPrice
|
||||
}));
|
||||
|
||||
setTransactionGroups(groups);
|
||||
};
|
||||
|
||||
const handleAssignJob = (groupIndex: number, jobId: string) => {
|
||||
setTransactionGroups(prev => {
|
||||
const newGroups = [...prev];
|
||||
newGroups[groupIndex].transactions.forEach(tx => {
|
||||
tx.assignedJobId = jobId;
|
||||
});
|
||||
return newGroups;
|
||||
});
|
||||
};
|
||||
|
||||
const getAssignments = () => {
|
||||
// Group transactions by assigned job
|
||||
return transactionGroups
|
||||
.flatMap(group => group.transactions)
|
||||
.filter(tx => tx.assignedJobId)
|
||||
.reduce((acc, tx) => {
|
||||
const jobId = tx.assignedJobId!;
|
||||
const existing = acc.find(a => a.jobId === jobId);
|
||||
if (existing) {
|
||||
existing.transactions.push(tx);
|
||||
} else {
|
||||
acc.push({ jobId, transactions: [tx] });
|
||||
}
|
||||
return acc;
|
||||
}, [] as { jobId: string, transactions: IndTransactionRecordNoId[] }[]);
|
||||
};
|
||||
|
||||
const canSubmit = transactionGroups.some(g => g.transactions.some(tx => !tx.isDuplicate && tx.assignedJobId));
|
||||
|
||||
return {
|
||||
pastedData,
|
||||
transactionGroups,
|
||||
duplicatesFound,
|
||||
eligibleJobs,
|
||||
handlePaste,
|
||||
handleAssignJob,
|
||||
getAssignments,
|
||||
canSubmit
|
||||
};
|
||||
};
|
9
src/hooks/useCategorizedJobs.ts
Normal file
9
src/hooks/useCategorizedJobs.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { categorizeJobs } from '@/utils/jobFiltering';
|
||||
|
||||
export const useCategorizedJobs = (jobs: IndJob[], searchQuery: string) => {
|
||||
return useMemo(() => {
|
||||
return categorizeJobs(jobs, searchQuery);
|
||||
}, [jobs, searchQuery]);
|
||||
};
|
30
src/hooks/useClipboard.ts
Normal file
30
src/hooks/useClipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export const useClipboard = () => {
|
||||
const [copying, setCopying] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const copyToClipboard = async (text: string, key: string, successMessage: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopying(key);
|
||||
toast({
|
||||
title: "Copied!",
|
||||
description: successMessage,
|
||||
duration: 2000,
|
||||
});
|
||||
setTimeout(() => setCopying(null), 1000);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to copy to clipboard",
|
||||
variant: "destructive",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { copying, copyToClipboard };
|
||||
};
|
102
src/hooks/useDashboard.ts
Normal file
102
src/hooks/useDashboard.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { IndTransactionRecordNoId, IndJobRecordNoId } from '@/lib/pbtypes';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
|
||||
export function useDashboard() {
|
||||
const {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
loadingStatuses,
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
createMultipleTransactions,
|
||||
createMultipleBillItems,
|
||||
loadJobsForStatuses
|
||||
} = useJobs();
|
||||
|
||||
const [showJobForm, setShowJobForm] = useState(false);
|
||||
const [editingJob, setEditingJob] = useState<IndJob | null>(null);
|
||||
const [showBatchForm, setShowBatchForm] = useState(false);
|
||||
const [showBatchExpenditureForm, setShowBatchExpenditureForm] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [totalRevenueChartOpen, setTotalRevenueChartOpen] = useState(false);
|
||||
const [totalProfitChartOpen, setTotalProfitChartOpen] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>(() => {
|
||||
const saved = localStorage.getItem('jobGroupsCollapsed');
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
});
|
||||
|
||||
const scrollPositionRef = useRef<number>(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const attentionStatusesLoaded = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
scrollPositionRef.current = window.scrollY;
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Load attention-relevant statuses after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && !attentionStatusesLoaded.current) {
|
||||
const attentionStatuses = ['Acquisition', 'Running', 'Selling'];
|
||||
loadJobsForStatuses(attentionStatuses);
|
||||
attentionStatusesLoaded.current = true;
|
||||
}
|
||||
}, [loading, loadJobsForStatuses]);
|
||||
|
||||
return {
|
||||
// State
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
loadingStatuses,
|
||||
showJobForm,
|
||||
setShowJobForm,
|
||||
editingJob,
|
||||
setEditingJob,
|
||||
showBatchForm,
|
||||
setShowBatchForm,
|
||||
showBatchExpenditureForm,
|
||||
setShowBatchExpenditureForm,
|
||||
searchOpen,
|
||||
setSearchOpen,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
totalRevenueChartOpen,
|
||||
setTotalRevenueChartOpen,
|
||||
totalProfitChartOpen,
|
||||
setTotalProfitChartOpen,
|
||||
collapsedGroups,
|
||||
setCollapsedGroups,
|
||||
scrollPositionRef,
|
||||
containerRef,
|
||||
// Methods
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
createMultipleTransactions,
|
||||
createMultipleBillItems,
|
||||
loadJobsForStatuses
|
||||
};
|
||||
}
|
151
src/hooks/useDashboardHandlers.ts
Normal file
151
src/hooks/useDashboardHandlers.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { IndTransactionRecordNoId, IndJobRecordNoId } from '@/lib/pbtypes';
|
||||
|
||||
interface DashboardHandlersProps {
|
||||
createJob: (jobData: IndJobRecordNoId) => Promise<IndJob>;
|
||||
updateJob: (id: string, updates: Partial<IndJobRecordNoId>) => Promise<IndJob>;
|
||||
deleteJob: (id: string) => Promise<void>;
|
||||
createMultipleTransactions: (jobId: string, transactions: IndTransactionRecordNoId[], type: 'expenditure' | 'income') => Promise<IndJob>;
|
||||
createMultipleBillItems: (jobId: string, items: { name: string; quantity: number; unitPrice: number }[], type: 'billOfMaterials' | 'consumedMaterials') => Promise<IndJob>;
|
||||
loadJobsForStatuses: (statuses: string[]) => Promise<void>;
|
||||
setShowJobForm: (show: boolean) => void;
|
||||
setEditingJob: (job: IndJob | null) => void;
|
||||
collapsedGroups: Record<string, boolean>;
|
||||
setCollapsedGroups: (groups: Record<string, boolean>) => void;
|
||||
loadingStatuses: Set<string>;
|
||||
}
|
||||
|
||||
// Statuses that need attention checking
|
||||
const ATTENTION_STATUSES = ['Acquisition', 'Running', 'Selling'];
|
||||
|
||||
export function useDashboardHandlers({
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
createMultipleTransactions,
|
||||
createMultipleBillItems,
|
||||
loadJobsForStatuses,
|
||||
setShowJobForm,
|
||||
setEditingJob,
|
||||
collapsedGroups,
|
||||
setCollapsedGroups,
|
||||
loadingStatuses
|
||||
}: DashboardHandlersProps) {
|
||||
|
||||
const handleCreateJob = async (jobData: IndJobRecordNoId, billOfMaterials?: { name: string; quantity: number }[]) => {
|
||||
try {
|
||||
const newJob = await createJob(jobData);
|
||||
|
||||
if (billOfMaterials && billOfMaterials.length > 0) {
|
||||
const billItems = billOfMaterials.map(item => ({
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unitPrice: 0
|
||||
}));
|
||||
await createMultipleBillItems(newJob.id, billItems, 'billOfMaterials');
|
||||
}
|
||||
|
||||
setShowJobForm(false);
|
||||
} catch (error) {
|
||||
console.error('Error creating job:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditJob = (job: IndJob) => {
|
||||
setEditingJob(job);
|
||||
setShowJobForm(true);
|
||||
};
|
||||
|
||||
const handleUpdateJob = async (jobData: IndJobRecordNoId, editingJob: IndJob | null) => {
|
||||
if (!editingJob) return;
|
||||
|
||||
try {
|
||||
await updateJob(editingJob.id, jobData);
|
||||
setShowJobForm(false);
|
||||
setEditingJob(null);
|
||||
} catch (error) {
|
||||
console.error('Error updating job:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteJob = async (jobId: string) => {
|
||||
if (confirm('Are you sure you want to delete this job?')) {
|
||||
try {
|
||||
await deleteJob(jobId);
|
||||
} catch (error) {
|
||||
console.error('Error deleting job:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProduced = async (jobId: string, produced: number) => {
|
||||
try {
|
||||
await updateJob(jobId, { produced });
|
||||
} catch (error) {
|
||||
console.error('Error updating produced quantity:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportBOM = async (jobId: string, items: { name: string; quantity: number }[]) => {
|
||||
try {
|
||||
const billItems = items.map(item => ({
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unitPrice: 0
|
||||
}));
|
||||
await createMultipleBillItems(jobId, billItems, 'billOfMaterials');
|
||||
} catch (error) {
|
||||
console.error('Error importing BOM:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = async (status: string) => {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
const newState = { ...collapsedGroups, [status]: !collapsedGroups[status] };
|
||||
setCollapsedGroups(newState);
|
||||
localStorage.setItem('jobGroupsCollapsed', JSON.stringify(newState));
|
||||
|
||||
// If we're opening a group that needs attention checking, load its jobs
|
||||
if (collapsedGroups[status] && ATTENTION_STATUSES.includes(status) && !loadingStatuses.has(status)) {
|
||||
await loadJobsForStatuses([status]);
|
||||
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, currentScrollY);
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTransactionsAssigned = async (assignments: { jobId: string, transactions: IndTransactionRecordNoId[] }[]) => {
|
||||
try {
|
||||
for (const { jobId, transactions } of assignments) {
|
||||
await createMultipleTransactions(jobId, transactions, 'income');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error assigning batch transactions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchExpendituresAssigned = async (assignments: { jobId: string, transactions: IndTransactionRecordNoId[] }[]) => {
|
||||
try {
|
||||
for (const { jobId, transactions } of assignments) {
|
||||
await createMultipleTransactions(jobId, transactions, 'expenditure');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error assigning batch expenditures:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleCreateJob,
|
||||
handleEditJob,
|
||||
handleUpdateJob,
|
||||
handleDeleteJob,
|
||||
handleUpdateProduced,
|
||||
handleImportBOM,
|
||||
toggleGroup,
|
||||
handleBatchTransactionsAssigned,
|
||||
handleBatchExpendituresAssigned
|
||||
};
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { dataService } from '@/services/dataService';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
@@ -6,17 +7,21 @@ export function useJobs() {
|
||||
const [jobs, setJobs] = useState<IndJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingStatuses, setLoadingStatuses] = useState<Set<string>>(new Set());
|
||||
const initialLoadComplete = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadJobs = async (visibleStatuses?: string[]) => {
|
||||
|
||||
const loadJobs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const loadedJobs = await dataService.loadJobs(visibleStatuses);
|
||||
// Load all jobs initially to get accurate totals
|
||||
const loadedJobs = await dataService.loadJobs();
|
||||
if (mounted) {
|
||||
setJobs(loadedJobs);
|
||||
setError(null);
|
||||
initialLoadComplete.current = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
@@ -36,10 +41,28 @@ export function useJobs() {
|
||||
if (mounted) {
|
||||
const currentJobs = dataService.getJobs();
|
||||
setJobs(prevJobs => {
|
||||
// Only update if the jobs have actually changed
|
||||
const prevJson = JSON.stringify(prevJobs);
|
||||
const currentJson = JSON.stringify(currentJobs);
|
||||
return prevJson !== currentJson ? currentJobs : prevJobs;
|
||||
// Use simple reference check instead of expensive JSON.stringify
|
||||
// DataService already creates new arrays when data changes
|
||||
if (prevJobs === currentJobs) return prevJobs;
|
||||
|
||||
// Only do length check as additional safety - much faster than JSON.stringify
|
||||
if (prevJobs.length !== currentJobs.length) return currentJobs;
|
||||
|
||||
// For same length arrays, do a simple reference check on first few items
|
||||
// DataService creates new job objects when they change, so reference equality works
|
||||
if (prevJobs.length > 0 && currentJobs.length > 0) {
|
||||
// Check first, middle, and last items for reference equality
|
||||
const checkIndices = [0];
|
||||
if (prevJobs.length > 1) checkIndices.push(Math.floor(prevJobs.length / 2), prevJobs.length - 1);
|
||||
|
||||
for (const i of checkIndices) {
|
||||
if (prevJobs[i] !== currentJobs[i]) {
|
||||
return currentJobs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prevJobs;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -62,22 +85,38 @@ export function useJobs() {
|
||||
const createMultipleBillItems = useCallback(dataService.createMultipleBillItems.bind(dataService), []);
|
||||
|
||||
const loadJobsForStatuses = useCallback(async (visibleStatuses: string[]) => {
|
||||
// Prevent multiple concurrent loads of the same status
|
||||
const statusesToLoad = visibleStatuses.filter(status => !loadingStatuses.has(status));
|
||||
if (statusesToLoad.length === 0) return;
|
||||
|
||||
// Mark statuses as loading
|
||||
setLoadingStatuses(prev => {
|
||||
const newSet = new Set(prev);
|
||||
statusesToLoad.forEach(status => newSet.add(status));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Load jobs for specific statuses without showing global loading
|
||||
const loadedJobs = await dataService.loadJobs(visibleStatuses);
|
||||
setJobs(loadedJobs);
|
||||
setError(null);
|
||||
// Jobs will be updated via the subscription, no need to manually update state here
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load jobs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Remove statuses from loading set
|
||||
setLoadingStatuses(prev => {
|
||||
const newSet = new Set(prev);
|
||||
statusesToLoad.forEach(status => newSet.delete(status));
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [loadingStatuses]);
|
||||
|
||||
return {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
loadingStatuses,
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
|
80
src/hooks/useJobAttentionMetrics.ts
Normal file
80
src/hooks/useJobAttentionMetrics.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
// Optimized attention calculation with caching
|
||||
export const useJobAttentionMetrics = (jobs: IndJob[]) => {
|
||||
return useMemo(() => {
|
||||
const attentionMap = new Map<string, boolean>();
|
||||
const groupAttentionMap = new Map<string, boolean>();
|
||||
const statusGroups = new Map<string, IndJob[]>();
|
||||
|
||||
// Group jobs by status
|
||||
jobs.forEach(job => {
|
||||
const status = job.status;
|
||||
if (!statusGroups.has(status)) {
|
||||
statusGroups.set(status, []);
|
||||
}
|
||||
statusGroups.get(status)!.push(job);
|
||||
});
|
||||
|
||||
// Calculate attention for all jobs in one pass
|
||||
jobs.forEach(job => {
|
||||
let needsAttention = false;
|
||||
|
||||
// Acquisition jobs need attention when all materials are satisfied
|
||||
if (job.status === 'Acquisition') {
|
||||
if (job.billOfMaterials && job.billOfMaterials.length > 0) {
|
||||
const requiredMaterials = new Map<string, number>();
|
||||
job.billOfMaterials.forEach(item => {
|
||||
requiredMaterials.set(item.name, item.quantity);
|
||||
});
|
||||
|
||||
const ownedMaterials = new Map<string, number>();
|
||||
job.expenditures?.forEach(transaction => {
|
||||
const currentOwned = ownedMaterials.get(transaction.itemName) || 0;
|
||||
ownedMaterials.set(transaction.itemName, currentOwned + transaction.quantity);
|
||||
});
|
||||
|
||||
let allMaterialsSatisfied = true;
|
||||
requiredMaterials.forEach((required, materialName) => {
|
||||
const owned = ownedMaterials.get(materialName) || 0;
|
||||
if (owned < required) {
|
||||
allMaterialsSatisfied = false;
|
||||
}
|
||||
});
|
||||
|
||||
needsAttention = allMaterialsSatisfied;
|
||||
}
|
||||
}
|
||||
// Running jobs need attention when they have finished
|
||||
else if (job.status === 'Running') {
|
||||
if (job.jobStart && job.runtime) {
|
||||
const startTime = new Date(job.jobStart).getTime();
|
||||
const runtimeMs = job.runtime * 1000;
|
||||
const finishTime = startTime + runtimeMs;
|
||||
const currentTime = Date.now();
|
||||
needsAttention = currentTime >= finishTime;
|
||||
}
|
||||
}
|
||||
// Selling jobs need attention when sold count reaches produced count
|
||||
else if (job.status === 'Selling') {
|
||||
const produced = job.produced || 0;
|
||||
const sold = job.income?.reduce((sum, tx) => sum + tx.quantity, 0) || 0;
|
||||
needsAttention = sold >= produced && produced > 0;
|
||||
}
|
||||
|
||||
attentionMap.set(job.id, needsAttention);
|
||||
});
|
||||
|
||||
// Calculate group attention
|
||||
statusGroups.forEach((jobs, status) => {
|
||||
const hasAttention = jobs.some(job => attentionMap.get(job.id) || false);
|
||||
groupAttentionMap.set(status, hasAttention);
|
||||
});
|
||||
|
||||
return {
|
||||
jobNeedsAttention: (jobId: string) => attentionMap.get(jobId) || false,
|
||||
groupNeedsAttention: (status: string) => groupAttentionMap.get(status) || false
|
||||
};
|
||||
}, [jobs]);
|
||||
};
|
50
src/hooks/useJobCardMetrics.ts
Normal file
50
src/hooks/useJobCardMetrics.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
export const useJobCardMetrics = (job: IndJob) => {
|
||||
return useMemo(() => {
|
||||
// Sort transactions once and cache the results
|
||||
const sortedExpenditures = [...job.expenditures].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
const sortedIncome = [...job.income].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
|
||||
// Calculate core metrics
|
||||
const totalExpenditure = job.expenditures.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const totalIncome = job.income.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const profit = totalIncome - totalExpenditure;
|
||||
const margin = totalIncome > 0 ? ((profit / totalIncome) * 100) : 0;
|
||||
|
||||
// Performance metrics calculation
|
||||
const itemsSold = job.income.reduce((sum, tx) => sum + tx.quantity, 0);
|
||||
const produced = job.produced || 0;
|
||||
const showPerformanceIndicator = produced > 0 && itemsSold > 0 && job.projectedRevenue > 0;
|
||||
|
||||
let performancePercentage = 0;
|
||||
if (showPerformanceIndicator) {
|
||||
const expectedPPU = job.projectedRevenue / produced;
|
||||
const actualPPU = totalIncome / itemsSold;
|
||||
performancePercentage = (actualPPU / expectedPPU) * 100;
|
||||
}
|
||||
|
||||
return {
|
||||
sortedExpenditures,
|
||||
sortedIncome,
|
||||
totalExpenditure,
|
||||
totalIncome,
|
||||
profit,
|
||||
margin,
|
||||
itemsSold,
|
||||
produced,
|
||||
showPerformanceIndicator,
|
||||
performancePercentage
|
||||
};
|
||||
}, [
|
||||
job.expenditures,
|
||||
job.income,
|
||||
job.produced,
|
||||
job.projectedRevenue
|
||||
]);
|
||||
};
|
45
src/hooks/useJobMetrics.ts
Normal file
45
src/hooks/useJobMetrics.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
export const useJobMetrics = (jobs: IndJob[]) => {
|
||||
// Memoize individual job calculations to avoid recalculating on every render
|
||||
const calculateJobRevenue = useMemo(() => (job: IndJob) => {
|
||||
return job.income.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
}, []);
|
||||
|
||||
const calculateJobProfit = useMemo(() => (job: IndJob) => {
|
||||
const expenditure = job.expenditures.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const income = job.income.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
return income - expenditure;
|
||||
}, []);
|
||||
|
||||
// Memoize expensive aggregation calculations - only recalculate when jobs actually change
|
||||
const metrics = useMemo(() => {
|
||||
const totalJobs = jobs.length;
|
||||
|
||||
// Single pass through jobs to calculate both revenue and profit
|
||||
let totalRevenue = 0;
|
||||
let totalProfit = 0;
|
||||
|
||||
for (const job of jobs) {
|
||||
const expenditure = job.expenditures.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const income = job.income.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
|
||||
totalRevenue += income;
|
||||
totalProfit += (income - expenditure);
|
||||
}
|
||||
|
||||
return {
|
||||
totalJobs,
|
||||
totalRevenue,
|
||||
totalProfit
|
||||
};
|
||||
}, [jobs]);
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
calculateJobRevenue,
|
||||
calculateJobProfit
|
||||
};
|
||||
};
|
38
src/hooks/useMaterialsCalculations.ts
Normal file
38
src/hooks/useMaterialsCalculations.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
import { IndBillitemRecord } from '@/lib/pbtypes';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
export function useMaterialsCalculations(job?: IndJob, billOfMaterials: IndBillitemRecord[] = []) {
|
||||
const calculateMissingMaterials = () => {
|
||||
if (!job) return [];
|
||||
|
||||
// Create a map of required materials from bill of materials
|
||||
const requiredMaterials = new Map<string, number>();
|
||||
billOfMaterials.forEach(item => {
|
||||
requiredMaterials.set(item.name, item.quantity);
|
||||
});
|
||||
|
||||
// Create a map of owned materials from expenditures
|
||||
const ownedMaterials = new Map<string, number>();
|
||||
job.expenditures?.forEach(transaction => {
|
||||
const currentOwned = ownedMaterials.get(transaction.itemName) || 0;
|
||||
ownedMaterials.set(transaction.itemName, currentOwned + transaction.quantity);
|
||||
});
|
||||
|
||||
// Calculate missing materials
|
||||
const missingMaterials: { name: string; quantity: number }[] = [];
|
||||
requiredMaterials.forEach((required, materialName) => {
|
||||
const owned = ownedMaterials.get(materialName) || 0;
|
||||
const missing = required - owned;
|
||||
if (missing > 0) {
|
||||
missingMaterials.push({ name: materialName, quantity: missing });
|
||||
}
|
||||
});
|
||||
|
||||
return missingMaterials;
|
||||
};
|
||||
|
||||
return {
|
||||
calculateMissingMaterials
|
||||
};
|
||||
}
|
51
src/hooks/useOptimizedJobMetrics.ts
Normal file
51
src/hooks/useOptimizedJobMetrics.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
// Memoized job metrics calculation with single-pass optimization
|
||||
export const useOptimizedJobMetrics = (jobs: IndJob[]) => {
|
||||
// Single-pass calculation with memoization on jobs array reference
|
||||
const metrics = useMemo(() => {
|
||||
const totalJobs = jobs.length;
|
||||
let totalRevenue = 0;
|
||||
let totalProfit = 0;
|
||||
|
||||
// Pre-compute job metrics in single pass
|
||||
const jobMetricsMap = new Map<string, { revenue: number; profit: number; expenditure: number; income: number }>();
|
||||
|
||||
for (const job of jobs) {
|
||||
const expenditure = job.expenditures?.reduce((sum, tx) => sum + tx.totalPrice, 0) || 0;
|
||||
const income = job.income?.reduce((sum, tx) => sum + tx.totalPrice, 0) || 0;
|
||||
const profit = income - expenditure;
|
||||
|
||||
totalRevenue += income;
|
||||
totalProfit += profit;
|
||||
|
||||
jobMetricsMap.set(job.id, {
|
||||
revenue: income,
|
||||
profit,
|
||||
expenditure,
|
||||
income
|
||||
});
|
||||
}
|
||||
|
||||
// Create optimized calculation functions that use pre-computed values
|
||||
const calculateJobRevenue = (job: IndJob) => {
|
||||
return jobMetricsMap.get(job.id)?.revenue || 0;
|
||||
};
|
||||
|
||||
const calculateJobProfit = (job: IndJob) => {
|
||||
return jobMetricsMap.get(job.id)?.profit || 0;
|
||||
};
|
||||
|
||||
return {
|
||||
totalJobs,
|
||||
totalRevenue,
|
||||
totalProfit,
|
||||
calculateJobRevenue,
|
||||
calculateJobProfit,
|
||||
jobMetricsMap
|
||||
};
|
||||
}, [jobs]);
|
||||
|
||||
return metrics;
|
||||
};
|
@@ -12,7 +12,7 @@ export enum Collections {
|
||||
Otps = "_otps",
|
||||
Superusers = "_superusers",
|
||||
IndBillitem = "ind_billItem",
|
||||
IndFacility = "ind_facility",
|
||||
IndChar = "ind_char",
|
||||
IndJob = "ind_job",
|
||||
IndTransaction = "ind_transaction",
|
||||
Regionview = "regionview",
|
||||
@@ -107,10 +107,9 @@ export type IndBillitemRecord = {
|
||||
}
|
||||
export type IndBillitemRecordNoId = Omit<IndBillitemRecord, 'id' | 'created' | 'updated'>
|
||||
|
||||
export type IndFacilityRecord = {
|
||||
export type IndCharRecord = {
|
||||
created?: IsoDateString
|
||||
id: string
|
||||
location: string
|
||||
name: string
|
||||
updated?: IsoDateString
|
||||
}
|
||||
@@ -123,9 +122,15 @@ export enum IndJobStatusOptions {
|
||||
"Selling" = "Selling",
|
||||
"Closed" = "Closed",
|
||||
"Tracked" = "Tracked",
|
||||
"Staging" = "Staging",
|
||||
"Inbound" = "Inbound",
|
||||
"Outbound" = "Outbound",
|
||||
"Delivered" = "Delivered",
|
||||
"Queued" = "Queued",
|
||||
}
|
||||
export type IndJobRecord = {
|
||||
billOfMaterials?: RecordIdString[]
|
||||
character?: RecordIdString
|
||||
consumedMaterials?: RecordIdString[]
|
||||
created?: IsoDateString
|
||||
expenditures?: RecordIdString[]
|
||||
@@ -135,9 +140,11 @@ export type IndJobRecord = {
|
||||
jobStart?: IsoDateString
|
||||
outputItem: string
|
||||
outputQuantity: number
|
||||
parallel?: number
|
||||
produced?: number
|
||||
projectedCost?: number
|
||||
projectedRevenue?: number
|
||||
runtime?: number
|
||||
saleEnd?: IsoDateString
|
||||
saleStart?: IsoDateString
|
||||
status: IndJobStatusOptions
|
||||
@@ -222,7 +229,7 @@ export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemF
|
||||
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
|
||||
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
|
||||
export type IndBillitemResponse<Texpand = unknown> = Required<IndBillitemRecord> & BaseSystemFields<Texpand>
|
||||
export type IndFacilityResponse<Texpand = unknown> = Required<IndFacilityRecord> & BaseSystemFields<Texpand>
|
||||
export type IndCharResponse<Texpand = unknown> = Required<IndCharRecord> & BaseSystemFields<Texpand>
|
||||
export type IndJobResponse<Texpand = unknown> = Required<IndJobRecord> & BaseSystemFields<Texpand>
|
||||
export type IndTransactionResponse<Texpand = unknown> = Required<IndTransactionRecord> & BaseSystemFields<Texpand>
|
||||
export type RegionviewResponse<Texpand = unknown> = Required<RegionviewRecord> & BaseSystemFields<Texpand>
|
||||
@@ -231,6 +238,16 @@ export type SigviewResponse<Texpand = unknown> = Required<SigviewRecord> & BaseS
|
||||
export type SystemResponse<Texpand = unknown> = Required<SystemRecord> & BaseSystemFields<Texpand>
|
||||
export type WormholeSystemsResponse<Texpand = unknown> = Required<WormholeSystemsRecord> & BaseSystemFields<Texpand>
|
||||
|
||||
// Facility types (mock for compatibility)
|
||||
export type IndFacilityRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
created?: IsoDateString;
|
||||
updated?: IsoDateString;
|
||||
}
|
||||
|
||||
export type IndFacilityResponse<Texpand = unknown> = Required<IndFacilityRecord> & BaseSystemFields<Texpand>
|
||||
|
||||
// Types containing all Records and Responses, useful for creating typing helper functions
|
||||
|
||||
export type CollectionRecords = {
|
||||
@@ -240,7 +257,7 @@ export type CollectionRecords = {
|
||||
_otps: OtpsRecord
|
||||
_superusers: SuperusersRecord
|
||||
ind_billItem: IndBillitemRecord
|
||||
ind_facility: IndFacilityRecord
|
||||
ind_char: IndCharRecord
|
||||
ind_job: IndJobRecord
|
||||
ind_transaction: IndTransactionRecord
|
||||
regionview: RegionviewRecord
|
||||
@@ -257,7 +274,7 @@ export type CollectionResponses = {
|
||||
_otps: OtpsResponse
|
||||
_superusers: SuperusersResponse
|
||||
ind_billItem: IndBillitemResponse
|
||||
ind_facility: IndFacilityResponse
|
||||
ind_char: IndCharResponse
|
||||
ind_job: IndJobResponse
|
||||
ind_transaction: IndTransactionResponse
|
||||
regionview: RegionviewResponse
|
||||
@@ -277,7 +294,7 @@ export type TypedPocketBase = PocketBase & {
|
||||
collection(idOrName: '_otps'): RecordService<OtpsResponse>
|
||||
collection(idOrName: '_superusers'): RecordService<SuperusersResponse>
|
||||
collection(idOrName: 'ind_billItem'): RecordService<IndBillitemResponse>
|
||||
collection(idOrName: 'ind_facility'): RecordService<IndFacilityResponse>
|
||||
collection(idOrName: 'ind_char'): RecordService<IndCharResponse>
|
||||
collection(idOrName: 'ind_job'): RecordService<IndJobResponse>
|
||||
collection(idOrName: 'ind_transaction'): RecordService<IndTransactionResponse>
|
||||
collection(idOrName: 'regionview'): RecordService<RegionviewResponse>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import { TypedPocketBase } from './pbtypes';
|
||||
import { POCKETBASE_SUPERUSER_EMAIL, POCKETBASE_SUPERUSER_PASSWORD } from './pocketbaseAdmin';
|
||||
import { POCKETBASE_SUPERUSER_EMAIL, POCKETBASE_SUPERUSER_PASSWORD } from '@/lib/pocketbaseAdmin';
|
||||
|
||||
const pb = new PocketBase('https://evebase.site.quack-lab.dev') as TypedPocketBase;
|
||||
|
||||
@@ -41,7 +41,7 @@ const wrappedPb = new Proxy(pb, {
|
||||
return (collectionName: string) => {
|
||||
// Get the original collection
|
||||
const originalCollection = target.collection(collectionName);
|
||||
|
||||
|
||||
// Return a proxy for the collection that ensures auth
|
||||
return new Proxy(originalCollection, {
|
||||
get(collectionTarget, methodName) {
|
||||
@@ -61,7 +61,7 @@ const wrappedPb = new Proxy(pb, {
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return target[prop as keyof typeof target];
|
||||
}
|
||||
});
|
||||
|
3
src/lib/pocketbaseAdmin.ts
Normal file
3
src/lib/pocketbaseAdmin.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
const POCKETBASE_SUPERUSER_EMAIL = 'david.majdandzic@hotmail.com';
|
||||
const POCKETBASE_SUPERUSER_PASSWORD = 'YxrqmIHYanGy8l';
|
||||
export { POCKETBASE_SUPERUSER_EMAIL, POCKETBASE_SUPERUSER_PASSWORD };
|
@@ -1,3 +1,4 @@
|
||||
|
||||
import { IndJobStatusOptions, IndTransactionRecord } from "./pbtypes"
|
||||
import { IsoDateString } from "./pbtypes"
|
||||
import { IndBillitemRecord } from "./pbtypes"
|
||||
@@ -13,6 +14,7 @@ export type IndJob = {
|
||||
jobStart?: IsoDateString
|
||||
outputItem: string
|
||||
outputQuantity: number
|
||||
parallel?: number
|
||||
produced?: number
|
||||
saleEnd?: IsoDateString
|
||||
saleStart?: IsoDateString
|
||||
@@ -20,4 +22,7 @@ export type IndJob = {
|
||||
updated?: IsoDateString
|
||||
projectedCost?: number
|
||||
projectedRevenue?: number
|
||||
}
|
||||
runtime?: number
|
||||
}
|
||||
|
||||
export type IndTransaction = IndTransactionRecord;
|
||||
|
@@ -1,50 +1,77 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Plus, Factory, TrendingUp, Briefcase, FileText } from 'lucide-react';
|
||||
import { IndTransactionRecordNoId, IndJobRecordNoId, IndJobStatusOptions } from '@/lib/pbtypes';
|
||||
import { formatISK } from '@/utils/priceUtils';
|
||||
import JobCard from '@/components/JobCard';
|
||||
|
||||
import JobForm from '@/components/JobForm';
|
||||
import { IndJob } from '@/lib/types';
|
||||
import BatchTransactionForm from '@/components/BatchTransactionForm';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import BatchExpenditureForm from '@/components/BatchExpenditureForm';
|
||||
import SearchOverlay from '@/components/SearchOverlay';
|
||||
import TransactionChart from '@/components/TransactionChart';
|
||||
import DashboardStats from '@/components/DashboardStats';
|
||||
import JobsToolbar from '@/components/JobsToolbar';
|
||||
import OptimizedJobsSection from '@/components/OptimizedJobsSection';
|
||||
import { useDashboard } from '@/hooks/useDashboard';
|
||||
import { useDashboardHandlers } from '@/hooks/useDashboardHandlers';
|
||||
import { useOptimizedJobMetrics } from '@/hooks/useOptimizedJobMetrics';
|
||||
import { useCategorizedJobs } from '@/hooks/useCategorizedJobs';
|
||||
|
||||
const Index = () => {
|
||||
const {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
loadingStatuses,
|
||||
showJobForm,
|
||||
setShowJobForm,
|
||||
editingJob,
|
||||
setEditingJob,
|
||||
showBatchForm,
|
||||
setShowBatchForm,
|
||||
showBatchExpenditureForm,
|
||||
setShowBatchExpenditureForm,
|
||||
searchOpen,
|
||||
setSearchOpen,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
totalRevenueChartOpen,
|
||||
setTotalRevenueChartOpen,
|
||||
totalProfitChartOpen,
|
||||
setTotalProfitChartOpen,
|
||||
collapsedGroups,
|
||||
setCollapsedGroups,
|
||||
containerRef,
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
createMultipleTransactions,
|
||||
createMultipleBillItems,
|
||||
loadJobsForStatuses
|
||||
} = useJobs();
|
||||
} = useDashboard();
|
||||
|
||||
const [showJobForm, setShowJobForm] = useState(false);
|
||||
const [editingJob, setEditingJob] = useState<IndJob | null>(null);
|
||||
const [showBatchForm, setShowBatchForm] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>(() => {
|
||||
const saved = localStorage.getItem('jobGroupsCollapsed');
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
const {
|
||||
handleCreateJob,
|
||||
handleEditJob,
|
||||
handleUpdateJob,
|
||||
handleDeleteJob,
|
||||
handleUpdateProduced,
|
||||
handleImportBOM,
|
||||
toggleGroup,
|
||||
handleBatchTransactionsAssigned,
|
||||
handleBatchExpendituresAssigned
|
||||
} = useDashboardHandlers({
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
createMultipleTransactions,
|
||||
createMultipleBillItems,
|
||||
loadJobsForStatuses,
|
||||
setShowJobForm,
|
||||
setEditingJob,
|
||||
collapsedGroups,
|
||||
setCollapsedGroups,
|
||||
loadingStatuses
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
// Always call hooks before any conditional returns
|
||||
const { regularJobs, trackedJobs } = useCategorizedJobs(jobs, searchQuery);
|
||||
const { totalJobs, totalProfit, totalRevenue, calculateJobRevenue, calculateJobProfit } = useOptimizedJobMetrics(regularJobs);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -62,158 +89,13 @@ const Index = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusPriority = (status: IndJobStatusOptions): number => {
|
||||
switch (status) {
|
||||
case 'Planned': return 6;
|
||||
case 'Acquisition': return 1;
|
||||
case 'Running': return 2;
|
||||
case 'Done': return 3;
|
||||
case 'Selling': return 4;
|
||||
case 'Closed': return 5;
|
||||
case 'Tracked': return 7;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Planned': return 'bg-gray-600';
|
||||
case 'Acquisition': return 'bg-yellow-600';
|
||||
case 'Running': return 'bg-blue-600';
|
||||
case 'Done': return 'bg-purple-600';
|
||||
case 'Selling': return 'bg-orange-600';
|
||||
case 'Closed': return 'bg-green-600';
|
||||
case 'Tracked': return 'bg-cyan-600';
|
||||
default: return 'bg-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const filterJobs = (jobs: IndJob[]) => {
|
||||
if (!searchQuery) return jobs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return jobs.filter(job =>
|
||||
job.outputItem.toLowerCase().includes(query)
|
||||
);
|
||||
};
|
||||
|
||||
const sortedJobs = [...jobs].sort((a, b) => {
|
||||
const priorityA = getStatusPriority(a.status);
|
||||
const priorityB = getStatusPriority(b.status);
|
||||
if (priorityA === priorityB) {
|
||||
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
|
||||
}
|
||||
return priorityA - priorityB;
|
||||
});
|
||||
|
||||
const regularJobs = filterJobs(sortedJobs.filter(job => job.status !== 'Tracked'));
|
||||
const trackedJobs = filterJobs(sortedJobs.filter(job => job.status === 'Tracked'));
|
||||
|
||||
const totalJobs = regularJobs.length;
|
||||
const totalProfit = regularJobs.reduce((sum, job) => {
|
||||
const expenditure = job.expenditures.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const income = job.income.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
return sum + (income - expenditure);
|
||||
}, 0);
|
||||
|
||||
const totalRevenue = regularJobs.reduce((sum, job) =>
|
||||
sum + job.income.reduce((sum, tx) => sum + tx.totalPrice, 0), 0
|
||||
);
|
||||
|
||||
const handleCreateJob = async (jobData: IndJobRecordNoId) => {
|
||||
try {
|
||||
await createJob(jobData);
|
||||
setShowJobForm(false);
|
||||
} catch (error) {
|
||||
console.error('Error creating job:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditJob = (job: IndJob) => {
|
||||
setEditingJob(job);
|
||||
setShowJobForm(true);
|
||||
};
|
||||
|
||||
const handleUpdateJob = async (jobData: IndJobRecordNoId) => {
|
||||
if (!editingJob) return;
|
||||
|
||||
try {
|
||||
await updateJob(editingJob.id, jobData);
|
||||
setShowJobForm(false);
|
||||
setEditingJob(null);
|
||||
} catch (error) {
|
||||
console.error('Error updating job:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteJob = async (jobId: string) => {
|
||||
if (confirm('Are you sure you want to delete this job?')) {
|
||||
try {
|
||||
await deleteJob(jobId);
|
||||
} catch (error) {
|
||||
console.error('Error deleting job:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProduced = async (jobId: string, produced: number) => {
|
||||
try {
|
||||
await updateJob(jobId, { produced });
|
||||
} catch (error) {
|
||||
console.error('Error updating produced quantity:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportBOM = async (jobId: string, items: { name: string; quantity: number }[]) => {
|
||||
try {
|
||||
const billItems = items.map(item => ({
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unitPrice: 0
|
||||
}));
|
||||
await createMultipleBillItems(jobId, billItems, 'billOfMaterials');
|
||||
} catch (error) {
|
||||
console.error('Error importing BOM:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const jobGroups = regularJobs.reduce((groups, job) => {
|
||||
const status = job.status;
|
||||
if (!groups[status]) {
|
||||
groups[status] = [];
|
||||
}
|
||||
groups[status].push(job);
|
||||
return groups;
|
||||
}, {} as Record<string, IndJob[]>);
|
||||
|
||||
const toggleGroup = (status: string) => {
|
||||
const newState = { ...collapsedGroups, [status]: !collapsedGroups[status] };
|
||||
setCollapsedGroups(newState);
|
||||
localStorage.setItem('jobGroupsCollapsed', JSON.stringify(newState));
|
||||
|
||||
// Load jobs for newly opened groups
|
||||
if (collapsedGroups[status]) {
|
||||
// Group is becoming visible, load jobs for this status
|
||||
loadJobsForStatuses([status]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTransactionsAssigned = async (assignments: { jobId: string, transactions: IndTransactionRecordNoId[] }[]) => {
|
||||
try {
|
||||
for (const { jobId, transactions } of assignments) {
|
||||
await createMultipleTransactions(jobId, transactions, 'income');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error assigning batch transactions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (showJobForm) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<JobForm
|
||||
job={editingJob || undefined}
|
||||
onSubmit={editingJob ? handleUpdateJob : handleCreateJob}
|
||||
onSubmit={editingJob ? (jobData) => handleUpdateJob(jobData, editingJob) : handleCreateJob}
|
||||
onCancel={() => {
|
||||
setShowJobForm(false);
|
||||
setEditingJob(null);
|
||||
@@ -225,7 +107,7 @@ const Index = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 space-y-4">
|
||||
<div ref={containerRef} className="container mx-auto p-4 space-y-4">
|
||||
<SearchOverlay
|
||||
isOpen={searchOpen}
|
||||
onClose={() => {
|
||||
@@ -234,141 +116,41 @@ const Index = () => {
|
||||
}}
|
||||
onSearch={setSearchQuery}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Factory className="w-5 h-5" />
|
||||
Active Jobs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalJobs}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
Total Revenue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-400">{formatISK(totalRevenue)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Briefcase className="w-5 h-5" />
|
||||
Total Profit
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${totalProfit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatISK(totalProfit)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DashboardStats
|
||||
totalJobs={totalJobs}
|
||||
totalRevenue={totalRevenue}
|
||||
totalProfit={totalProfit}
|
||||
jobs={regularJobs}
|
||||
calculateJobRevenue={calculateJobRevenue}
|
||||
calculateJobProfit={calculateJobProfit}
|
||||
onTotalRevenueChart={() => setTotalRevenueChartOpen(true)}
|
||||
onTotalProfitChart={() => setTotalProfitChartOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold text-white">Jobs</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowBatchForm(true)}
|
||||
className="border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Batch Assign
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingJob(null);
|
||||
setShowJobForm(true);
|
||||
}}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Job
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<JobsToolbar
|
||||
onNewJob={() => {
|
||||
setEditingJob(null);
|
||||
setShowJobForm(true);
|
||||
}}
|
||||
onBatchIncome={() => setShowBatchForm(true)}
|
||||
onBatchExpenditure={() => setShowBatchExpenditureForm(true)}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{Object.entries(jobGroups).map(([status, statusJobs]) => (
|
||||
<div key={status} className="space-y-4">
|
||||
<div
|
||||
className={`${getStatusColor(status)} rounded-lg cursor-pointer select-none transition-colors hover:opacity-90`}
|
||||
onClick={() => toggleGroup(status)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="text-xl font-semibold text-white flex items-center gap-3">
|
||||
<span>{status}</span>
|
||||
<span className="text-gray-200 text-lg">({statusJobs.length} jobs)</span>
|
||||
</h3>
|
||||
<div className={`text-white text-lg transition-transform ${collapsedGroups[status] ? '-rotate-90' : 'rotate-0'}`}>
|
||||
⌄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!collapsedGroups[status] && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{statusJobs.map(job => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onEdit={handleEditJob}
|
||||
onDelete={handleDeleteJob}
|
||||
onUpdateProduced={handleUpdateProduced}
|
||||
onImportBOM={handleImportBOM}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<OptimizedJobsSection
|
||||
regularJobs={regularJobs}
|
||||
trackedJobs={trackedJobs}
|
||||
collapsedGroups={collapsedGroups}
|
||||
loadingStatuses={loadingStatuses}
|
||||
onToggleGroup={toggleGroup}
|
||||
onEdit={handleEditJob}
|
||||
onDelete={handleDeleteJob}
|
||||
onUpdateProduced={handleUpdateProduced}
|
||||
onImportBOM={handleImportBOM}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{trackedJobs.length > 0 && (
|
||||
<div className="space-y-4 mt-8 pt-8 border-t border-gray-700">
|
||||
<div
|
||||
className="bg-cyan-600 rounded-lg cursor-pointer select-none transition-colors hover:opacity-90"
|
||||
onClick={() => toggleGroup('Tracked')}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-3">
|
||||
<span>Tracked Transactions</span>
|
||||
<span className="text-gray-200 text-lg">({trackedJobs.length} jobs)</span>
|
||||
</h2>
|
||||
<div className={`text-white text-lg transition-transform ${collapsedGroups['Tracked'] ? '-rotate-90' : 'rotate-0'}`}>
|
||||
⌄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!collapsedGroups['Tracked'] && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{trackedJobs.map(job => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onEdit={handleEditJob}
|
||||
onDelete={handleDeleteJob}
|
||||
onUpdateProduced={handleUpdateProduced}
|
||||
onImportBOM={handleImportBOM}
|
||||
isTracked={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBatchForm && (
|
||||
<BatchTransactionForm
|
||||
jobs={jobs}
|
||||
@@ -376,6 +158,28 @@ const Index = () => {
|
||||
onTransactionsAssigned={handleBatchTransactionsAssigned}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBatchExpenditureForm && (
|
||||
<BatchExpenditureForm
|
||||
jobs={jobs}
|
||||
onClose={() => setShowBatchExpenditureForm(false)}
|
||||
onTransactionsAssigned={handleBatchExpendituresAssigned}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TransactionChart
|
||||
jobs={regularJobs}
|
||||
type="total-revenue"
|
||||
isOpen={totalRevenueChartOpen}
|
||||
onClose={() => setTotalRevenueChartOpen(false)}
|
||||
/>
|
||||
|
||||
<TransactionChart
|
||||
jobs={regularJobs}
|
||||
type="total-profit"
|
||||
isOpen={totalProfitChartOpen}
|
||||
onClose={() => setTotalProfitChartOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -1,3 +1,4 @@
|
||||
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { IndJobRecord, IndJobRecordNoId, IndTransactionRecord, IndTransactionRecordNoId, IndBillitemRecord, IndBillitemRecordNoId } from '@/lib/pbtypes';
|
||||
import * as jobService from './jobService';
|
||||
@@ -11,6 +12,7 @@ export class DataService {
|
||||
private listeners: Set<() => void> = new Set();
|
||||
private loadPromise: Promise<IndJob[]> | null = null;
|
||||
private initialized: Promise<void>;
|
||||
private loadedStatuses: Set<string> = new Set();
|
||||
|
||||
private constructor() {
|
||||
// Initialize with admin login
|
||||
@@ -33,11 +35,21 @@ export class DataService {
|
||||
}
|
||||
|
||||
private notifyListeners() {
|
||||
this.listeners.forEach(listener => listener());
|
||||
// Debounce notifications to prevent excessive re-renders
|
||||
if (this.notificationTimeout) {
|
||||
clearTimeout(this.notificationTimeout);
|
||||
}
|
||||
this.notificationTimeout = setTimeout(() => {
|
||||
this.listeners.forEach(listener => listener());
|
||||
this.notificationTimeout = null;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private notificationTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
getJobs(): IndJob[] {
|
||||
return [...this.jobs];
|
||||
// Return the same reference if no changes to prevent unnecessary re-renders
|
||||
return this.jobs;
|
||||
}
|
||||
|
||||
getJob(id: string): IndJob | null {
|
||||
@@ -53,22 +65,38 @@ export class DataService {
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
// If we already have jobs loaded and no specific statuses requested, return them immediately
|
||||
// If we already have all jobs loaded and no specific statuses requested, return them immediately
|
||||
if (this.jobs.length > 0 && !visibleStatuses) {
|
||||
return Promise.resolve(this.getJobs());
|
||||
}
|
||||
|
||||
// If requesting specific statuses that are already loaded, return current jobs
|
||||
if (visibleStatuses && visibleStatuses.every(status => this.loadedStatuses.has(status))) {
|
||||
return Promise.resolve(this.getJobs());
|
||||
}
|
||||
|
||||
// Start a new load
|
||||
console.log('Loading jobs from database', visibleStatuses ? `for statuses: ${visibleStatuses.join(', ')}` : '');
|
||||
this.loadPromise = jobService.getJobs(visibleStatuses).then(jobs => {
|
||||
if (visibleStatuses) {
|
||||
// If filtering by statuses, merge with existing jobs
|
||||
const existingJobs = this.jobs.filter(job => !visibleStatuses.includes(job.status));
|
||||
this.jobs = [...existingJobs, ...jobs];
|
||||
// Mark these statuses as loaded
|
||||
visibleStatuses.forEach(status => this.loadedStatuses.add(status));
|
||||
|
||||
// Merge with existing jobs, replacing jobs with same IDs
|
||||
const existingJobIds = new Set(jobs.map(job => job.id));
|
||||
const otherJobs = this.jobs.filter(job => !existingJobIds.has(job.id));
|
||||
this.jobs = [...otherJobs, ...jobs]; // Create new array to trigger updates
|
||||
} else {
|
||||
this.jobs = jobs;
|
||||
// Loading all jobs - create new array to trigger updates
|
||||
this.jobs = [...jobs];
|
||||
// Mark all unique statuses as loaded
|
||||
const allStatuses = new Set(jobs.map(job => job.status));
|
||||
allStatuses.forEach(status => this.loadedStatuses.add(status));
|
||||
}
|
||||
|
||||
// Notify listeners immediately since we now use efficient reference checking
|
||||
this.notifyListeners();
|
||||
|
||||
return this.getJobs();
|
||||
}).finally(() => {
|
||||
this.loadPromise = null;
|
||||
@@ -80,23 +108,56 @@ export class DataService {
|
||||
async createJob(jobData: IndJobRecordNoId): Promise<IndJob> {
|
||||
console.log('Creating job:', jobData);
|
||||
const newJob = await jobService.createJob(jobData);
|
||||
this.jobs.push(newJob);
|
||||
this.jobs = [...this.jobs, newJob]; // Create new array for reference change
|
||||
this.notifyListeners();
|
||||
return newJob;
|
||||
}
|
||||
|
||||
async updateJob(id: string, updates: Partial<IndJobRecord>): Promise<IndJob> {
|
||||
console.log('Updating job:', id, updates);
|
||||
const updatedRecord = await jobService.updateJob(id, updates);
|
||||
|
||||
|
||||
const jobIndex = this.jobs.findIndex(job => job.id === id);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedRecord;
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
if (jobIndex === -1) {
|
||||
throw new Error(`Job with id ${id} not found in local state`);
|
||||
}
|
||||
|
||||
throw new Error(`Job with id ${id} not found in local state`);
|
||||
// Optimistic update - immediately update local state (only for simple properties)
|
||||
const originalJob = { ...this.jobs[jobIndex] };
|
||||
|
||||
// Only apply optimistic updates for safe properties (not complex relations)
|
||||
const safeUpdates = Object.fromEntries(
|
||||
Object.entries(updates).filter(([key]) =>
|
||||
!['billOfMaterials', 'consumedMaterials', 'expenditures', 'income'].includes(key)
|
||||
)
|
||||
);
|
||||
|
||||
if (Object.keys(safeUpdates).length > 0) {
|
||||
// Create new array with updated job for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? { ...job, ...safeUpdates } : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
// Update in database
|
||||
const updatedRecord = await jobService.updateJob(id, updates);
|
||||
|
||||
// Replace with server response - create new array for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedRecord : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
|
||||
return this.jobs[jobIndex];
|
||||
} catch (error) {
|
||||
// Revert optimistic update on error - create new array for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? originalJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteJob(id: string): Promise<void> {
|
||||
@@ -128,10 +189,12 @@ export class DataService {
|
||||
const updatedJob = await jobService.getJob(jobId);
|
||||
if (!updatedJob) throw new Error(`Job with id ${jobId} not found after update`);
|
||||
|
||||
// Update local state with fresh data
|
||||
// Update local state with fresh data - create new array for reference change
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
@@ -145,36 +208,47 @@ export class DataService {
|
||||
const job = this.getJob(jobId);
|
||||
if (!job) throw new Error(`Job with id ${jobId} not found`);
|
||||
|
||||
const createdTransactions: IndTransactionRecord[] = [];
|
||||
|
||||
// Create all transactions
|
||||
for (const transaction of transactions) {
|
||||
transaction.job = jobId;
|
||||
const createdTransaction = await transactionService.createTransaction(job, transaction);
|
||||
createdTransactions.push(createdTransaction);
|
||||
}
|
||||
|
||||
// Update the job's transaction references in one database call
|
||||
const field = type === 'expenditure' ? 'expenditures' : 'income';
|
||||
const currentIds = (job[field] || []).map(tr => tr.id);
|
||||
const newIds = createdTransactions.map(tr => tr.id);
|
||||
await jobService.updateJob(jobId, {
|
||||
[field]: [...currentIds, ...newIds]
|
||||
});
|
||||
|
||||
// Fetch fresh job data from the server
|
||||
const updatedJob = await jobService.getJob(jobId);
|
||||
if (!updatedJob) throw new Error(`Job with id ${jobId} not found after update`);
|
||||
|
||||
// Update local state with fresh data
|
||||
// Optimistically update local state first for better UX
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
if (jobIndex === -1) throw new Error(`Job with id ${jobId} not found`);
|
||||
|
||||
const originalJob = { ...this.jobs[jobIndex] };
|
||||
|
||||
try {
|
||||
// Create all transactions in parallel for better performance
|
||||
const transactionPromises = transactions.map(transaction => {
|
||||
transaction.job = jobId;
|
||||
return transactionService.createTransaction(job, transaction);
|
||||
});
|
||||
|
||||
const createdTransactions = await Promise.all(transactionPromises);
|
||||
|
||||
// Update the job's transaction references in one database call
|
||||
const field = type === 'expenditure' ? 'expenditures' : 'income';
|
||||
const currentIds = (job[field] || []).map(tr => tr.id);
|
||||
const newIds = createdTransactions.map(tr => tr.id);
|
||||
await jobService.updateJob(jobId, {
|
||||
[field]: [...currentIds, ...newIds]
|
||||
});
|
||||
|
||||
// Fetch fresh job data from the server
|
||||
const updatedJob = await jobService.getJob(jobId);
|
||||
if (!updatedJob) throw new Error(`Job with id ${jobId} not found after update`);
|
||||
|
||||
// Update local state with fresh data - create new array for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
} catch (error) {
|
||||
// Revert optimistic update on error - create new array for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? originalJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Job with id ${jobId} not found in local state`);
|
||||
}
|
||||
|
||||
async updateTransaction(jobId: string, transactionId: string, updates: Partial<IndTransactionRecord>): Promise<IndJob> {
|
||||
@@ -192,7 +266,9 @@ export class DataService {
|
||||
// Update local state with fresh data
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
@@ -215,7 +291,9 @@ export class DataService {
|
||||
// Update local state with fresh data
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
@@ -244,7 +322,9 @@ export class DataService {
|
||||
// Update local state with fresh data
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
@@ -285,7 +365,9 @@ export class DataService {
|
||||
// Update local state with fresh data
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
|
@@ -16,14 +16,14 @@ const expand = 'billOfMaterials,consumedMaterials,expenditures,income';
|
||||
|
||||
export async function getJobs(statuses?: string[]): Promise<IndJob[]> {
|
||||
console.log('Getting jobs', statuses ? `for statuses: ${statuses.join(', ')}` : '');
|
||||
|
||||
|
||||
let options: any = { expand };
|
||||
|
||||
|
||||
if (statuses && statuses.length > 0) {
|
||||
const statusFilters = statuses.map(status => `status = "${status}"`).join(' || ');
|
||||
options.filter = statusFilters;
|
||||
}
|
||||
|
||||
|
||||
const result = await pb.collection('ind_job').getFullList(10000, options);
|
||||
const jobs: IndJob[] = [];
|
||||
for (const job of result) {
|
||||
|
61
src/types/industry.ts
Normal file
61
src/types/industry.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type IsoDateString = string;
|
||||
export type RecordIdString = string;
|
||||
|
||||
export enum IndJobStatusOptions {
|
||||
"Planned" = "Planned",
|
||||
"Acquisition" = "Acquisition",
|
||||
"Running" = "Running",
|
||||
"Done" = "Done",
|
||||
"Selling" = "Selling",
|
||||
"Closed" = "Closed",
|
||||
"Tracked" = "Tracked",
|
||||
"Staging" = "Staging",
|
||||
"Inbound" = "Inbound",
|
||||
"Outbound" = "Outbound",
|
||||
"Delivered" = "Delivered",
|
||||
"Queued" = "Queued",
|
||||
}
|
||||
|
||||
export type IndBillitemRecord = {
|
||||
created?: IsoDateString;
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
updated?: IsoDateString;
|
||||
};
|
||||
|
||||
export type IndTransactionRecord = {
|
||||
buyer?: string;
|
||||
corporation?: string;
|
||||
created?: IsoDateString;
|
||||
date: IsoDateString;
|
||||
id: string;
|
||||
itemName: string;
|
||||
job?: RecordIdString;
|
||||
location?: string;
|
||||
quantity: number;
|
||||
totalPrice: number;
|
||||
unitPrice: number;
|
||||
updated?: IsoDateString;
|
||||
wallet?: string;
|
||||
};
|
||||
|
||||
export type IndJob = {
|
||||
billOfMaterials?: IndBillitemRecord[];
|
||||
consumedMaterials?: IndBillitemRecord[];
|
||||
created?: IsoDateString;
|
||||
expenditures?: IndTransactionRecord[];
|
||||
id: string;
|
||||
income?: IndTransactionRecord[];
|
||||
jobEnd?: IsoDateString;
|
||||
jobStart?: IsoDateString;
|
||||
outputItem: string;
|
||||
outputQuantity: number;
|
||||
produced?: number;
|
||||
saleEnd?: IsoDateString;
|
||||
saleStart?: IsoDateString;
|
||||
status: IndJobStatusOptions;
|
||||
updated?: IsoDateString;
|
||||
projectedCost?: number;
|
||||
projectedRevenue?: number;
|
||||
};
|
85
src/utils/currency.ts
Normal file
85
src/utils/currency.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export function formatISK(amount: number): string {
|
||||
return `${amount.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
})} ISK`;
|
||||
}
|
||||
|
||||
export function parseISK(value: string): number {
|
||||
// Remove ISK suffix and any spaces, then parse number with commas
|
||||
const cleaned = value.replace(/[ISK\s]/gi, '').replace(/,/g, '');
|
||||
const parsed = parseFloat(cleaned);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function parseTransactionLine(line: string): {
|
||||
date: string;
|
||||
quantity: number;
|
||||
itemName: string;
|
||||
unitPrice: number;
|
||||
totalPrice: number;
|
||||
buyer?: string;
|
||||
location?: string;
|
||||
corporation?: string;
|
||||
wallet?: string;
|
||||
} | null {
|
||||
// Parse EVE transaction format:
|
||||
// 2025.07.04 10:58 357 Isogen 699 ISK -249,543 ISK Shocker Killer Uitra VI - Moon 4 - State War Academy Primorium Master Wallet
|
||||
const parts = line.split('\t');
|
||||
|
||||
if (parts.length < 5) return null;
|
||||
|
||||
try {
|
||||
const date = parts[0]?.trim();
|
||||
const quantity = parseInt(parts[1]?.replace(/,/g, '') || '0');
|
||||
const itemName = parts[2]?.trim();
|
||||
const unitPrice = parseISK(parts[3] || '0');
|
||||
const totalPrice = Math.abs(parseISK(parts[4] || '0')); // Remove negative sign
|
||||
const buyer = parts[5]?.trim();
|
||||
const location = parts[6]?.trim();
|
||||
const corporation = parts[7]?.trim();
|
||||
const wallet = parts[8]?.trim();
|
||||
|
||||
if (!date || !itemName || quantity <= 0) return null;
|
||||
|
||||
return {
|
||||
date: new Date(date.replace(/\./g, '-')).toISOString(),
|
||||
quantity,
|
||||
itemName,
|
||||
unitPrice,
|
||||
totalPrice,
|
||||
buyer,
|
||||
location,
|
||||
corporation,
|
||||
wallet,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to parse transaction line:', line, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBillOfMaterials(text: string): { name: string; quantity: number }[] {
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const materials: { name: string; quantity: number }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0]?.trim();
|
||||
const quantity = parseInt(parts[1]?.replace(/,/g, '') || '0');
|
||||
|
||||
if (name && quantity > 0) {
|
||||
materials.push({ name, quantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
export function exportBillOfMaterials(materials: { name: string; quantity: number }[]): string {
|
||||
return materials
|
||||
.map(material => `${material.name}\t${material.quantity.toLocaleString()}`)
|
||||
.join('\n');
|
||||
}
|
62
src/utils/jobAttentionUtils.ts
Normal file
62
src/utils/jobAttentionUtils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
export function jobNeedsAttention(job: IndJob): boolean {
|
||||
// Acquisition jobs need attention when all materials are satisfied
|
||||
if (job.status === 'Acquisition') {
|
||||
if (!job.billOfMaterials || job.billOfMaterials.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a map of required materials from bill of materials
|
||||
const requiredMaterials = new Map<string, number>();
|
||||
job.billOfMaterials.forEach(item => {
|
||||
requiredMaterials.set(item.name, item.quantity);
|
||||
});
|
||||
|
||||
// Create a map of owned materials from expenditures
|
||||
const ownedMaterials = new Map<string, number>();
|
||||
job.expenditures?.forEach(transaction => {
|
||||
const currentOwned = ownedMaterials.get(transaction.itemName) || 0;
|
||||
ownedMaterials.set(transaction.itemName, currentOwned + transaction.quantity);
|
||||
});
|
||||
|
||||
// Check if all materials are satisfied
|
||||
let allMaterialsSatisfied = true;
|
||||
requiredMaterials.forEach((required, materialName) => {
|
||||
const owned = ownedMaterials.get(materialName) || 0;
|
||||
if (owned < required) {
|
||||
allMaterialsSatisfied = false;
|
||||
}
|
||||
});
|
||||
|
||||
return allMaterialsSatisfied;
|
||||
}
|
||||
|
||||
// Running jobs need attention when they have finished (start date + runtime > current time)
|
||||
if (job.status === 'Running') {
|
||||
if (!job.jobStart || !job.runtime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startTime = new Date(job.jobStart).getTime();
|
||||
const runtimeMs = job.runtime * 1000; // Convert seconds to milliseconds
|
||||
const finishTime = startTime + runtimeMs;
|
||||
const currentTime = Date.now();
|
||||
|
||||
return currentTime >= finishTime;
|
||||
}
|
||||
|
||||
// Selling jobs need attention when sold count reaches produced count
|
||||
if (job.status === 'Selling') {
|
||||
const produced = job.produced || 0;
|
||||
const sold = job.income?.reduce((sum, tx) => sum + tx.quantity, 0) || 0;
|
||||
return sold >= produced && produced > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getAttentionGlowClasses(): string {
|
||||
return 'ring-2 ring-yellow-400/50 shadow-lg shadow-yellow-400/20 animate-pulse';
|
||||
}
|
57
src/utils/jobFiltering.ts
Normal file
57
src/utils/jobFiltering.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { getStatusPriority } from '@/utils/jobStatusUtils';
|
||||
|
||||
export const filterJobs = (jobs: IndJob[], searchQuery: string) => {
|
||||
if (!searchQuery) return jobs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return jobs.filter(job =>
|
||||
job.outputItem.toLowerCase().includes(query)
|
||||
);
|
||||
};
|
||||
|
||||
export const sortJobs = (jobs: IndJob[]) => {
|
||||
return [...jobs].sort((a, b) => {
|
||||
const priorityA = getStatusPriority(a.status);
|
||||
const priorityB = getStatusPriority(b.status);
|
||||
if (priorityA === priorityB) {
|
||||
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
|
||||
}
|
||||
return priorityA - priorityB;
|
||||
});
|
||||
};
|
||||
|
||||
// Optimized categorizeJobs function - single pass through data
|
||||
export const categorizeJobs = (jobs: IndJob[], searchQuery: string) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const hasQuery = Boolean(searchQuery);
|
||||
|
||||
// Single pass: sort, filter, and categorize in one operation
|
||||
const sortedJobs = [...jobs].sort((a, b) => {
|
||||
const priorityA = getStatusPriority(a.status);
|
||||
const priorityB = getStatusPriority(b.status);
|
||||
if (priorityA === priorityB) {
|
||||
return new Date(b.created || '').getTime() - new Date(a.created || '').getTime();
|
||||
}
|
||||
return priorityA - priorityB;
|
||||
});
|
||||
|
||||
const regularJobs: IndJob[] = [];
|
||||
const trackedJobs: IndJob[] = [];
|
||||
|
||||
for (const job of sortedJobs) {
|
||||
// Apply search filter if needed
|
||||
if (hasQuery && !job.outputItem.toLowerCase().includes(query)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Categorize based on status
|
||||
if (job.status === 'Tracked') {
|
||||
trackedJobs.push(job);
|
||||
} else {
|
||||
regularJobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
return { regularJobs, trackedJobs };
|
||||
};
|
92
src/utils/jobStatusUtils.ts
Normal file
92
src/utils/jobStatusUtils.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
import { IndJobStatusOptions } from '@/lib/pbtypes';
|
||||
|
||||
export const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Planned': return 'bg-gray-600';
|
||||
case 'Acquisition': return 'bg-yellow-600';
|
||||
case 'Staging': return 'bg-amber-600';
|
||||
case 'Inbound': return 'bg-orange-600';
|
||||
case 'Queued': return 'bg-red-600';
|
||||
case 'Running': return 'bg-blue-600';
|
||||
case 'Done': return 'bg-purple-600';
|
||||
case 'Delivered': return 'bg-indigo-600';
|
||||
case 'Outbound': return 'bg-pink-600';
|
||||
case 'Selling': return 'bg-emerald-600';
|
||||
case 'Closed': return 'bg-green-600';
|
||||
case 'Tracked': return 'bg-cyan-600';
|
||||
default: return 'bg-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusBackgroundColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Planned': return `bg-gray-600/25`;
|
||||
case 'Acquisition': return `bg-yellow-600/25`;
|
||||
case 'Staging': return `bg-amber-600/25`;
|
||||
case 'Inbound': return `bg-orange-600/25`;
|
||||
case 'Queued': return `bg-red-600/25`;
|
||||
case 'Running': return `bg-blue-600/25`;
|
||||
case 'Done': return `bg-purple-600/25`;
|
||||
case 'Delivered': return `bg-indigo-600/25`;
|
||||
case 'Outbound': return `bg-pink-600/25`;
|
||||
case 'Selling': return `bg-emerald-600/25`;
|
||||
case 'Closed': return `bg-green-600/25`;
|
||||
case 'Tracked': return `bg-cyan-600/25`;
|
||||
default: return `bg-gray-600/25`;
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusBackgroundColorBright = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Planned': return `bg-gray-600/55`;
|
||||
case 'Acquisition': return `bg-yellow-600/55`;
|
||||
case 'Staging': return `bg-amber-600/55`;
|
||||
case 'Inbound': return `bg-orange-600/55`;
|
||||
case 'Queued': return `bg-red-600/55`;
|
||||
case 'Running': return `bg-blue-600/55`;
|
||||
case 'Done': return `bg-purple-600/55`;
|
||||
case 'Delivered': return `bg-indigo-600/55`;
|
||||
case 'Outbound': return `bg-pink-600/55`;
|
||||
case 'Selling': return `bg-emerald-600/55`;
|
||||
case 'Closed': return `bg-green-600/55`;
|
||||
case 'Tracked': return `bg-cyan-600/55`;
|
||||
default: return `bg-gray-600/55`;
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusPriority = (status: IndJobStatusOptions): number => {
|
||||
switch (status) {
|
||||
case 'Planned': return 1;
|
||||
case 'Acquisition': return 2;
|
||||
case 'Staging': return 3;
|
||||
case 'Inbound': return 4;
|
||||
case 'Queued': return 5;
|
||||
case 'Running': return 6;
|
||||
case 'Done': return 7;
|
||||
case 'Delivered': return 8;
|
||||
case 'Outbound': return 9;
|
||||
case 'Selling': return 10;
|
||||
case 'Closed': return 11;
|
||||
case 'Tracked': return 12;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Define the status sequence - using the actual enum values
|
||||
export const JOB_STATUSES = [
|
||||
'Planned', 'Acquisition', 'Staging', 'Inbound', 'Queued', 'Running', 'Done',
|
||||
'Delivered', 'Outbound', 'Selling', 'Closed', 'Tracked'
|
||||
] as const;
|
||||
|
||||
export const getNextStatus = (currentStatus: string): string | null => {
|
||||
const currentIndex = JOB_STATUSES.indexOf(currentStatus as any);
|
||||
if (currentIndex === -1 || currentIndex === JOB_STATUSES.length - 1) return null;
|
||||
return JOB_STATUSES[currentIndex + 1];
|
||||
};
|
||||
|
||||
export const getPreviousStatus = (currentStatus: string): string | null => {
|
||||
const currentIndex = JOB_STATUSES.indexOf(currentStatus as any);
|
||||
if (currentIndex <= 0) return null;
|
||||
return JOB_STATUSES[currentIndex - 1];
|
||||
};
|
14
src/utils/materialsExporter.ts
Normal file
14
src/utils/materialsExporter.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
import { IndBillitemRecord } from '@/lib/pbtypes';
|
||||
|
||||
export const exportBillOfMaterials = (billOfMaterials: IndBillitemRecord[]): string => {
|
||||
return billOfMaterials.map(item => `${item.name} ${item.quantity}`).join('\n');
|
||||
};
|
||||
|
||||
export const exportConsumedMaterials = (consumedMaterials: IndBillitemRecord[]): string => {
|
||||
return consumedMaterials.map(item => `${item.name}\t${item.quantity}`).join('\n');
|
||||
};
|
||||
|
||||
export const exportMissingMaterials = (missingMaterials: { name: string; quantity: number }[]): string => {
|
||||
return missingMaterials.map(item => `${item.name}\t${item.quantity}`).join('\n');
|
||||
};
|
38
src/utils/materialsParser.ts
Normal file
38
src/utils/materialsParser.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
import { IndBillitemRecordNoId } from '@/lib/pbtypes';
|
||||
|
||||
export const parseBillOfMaterials = (text: string): IndBillitemRecordNoId[] => {
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const materials: IndBillitemRecordNoId[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts.slice(0, -1).join(' ');
|
||||
const quantity = parseInt(parts[parts.length - 1]);
|
||||
if (name && !isNaN(quantity)) {
|
||||
materials.push({ name, quantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return materials;
|
||||
};
|
||||
|
||||
export const parseConsumedMaterials = (text: string): IndBillitemRecordNoId[] => {
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const materials: IndBillitemRecordNoId[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split('\t');
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const quantity = parseInt(parts[1]);
|
||||
if (name && !isNaN(quantity)) {
|
||||
materials.push({ name, quantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return materials;
|
||||
};
|
@@ -1,3 +1,4 @@
|
||||
import { IndTransactionRecordNoId } from "@/lib/pbtypes";
|
||||
|
||||
export const parseISKAmount = (iskString: string): number => {
|
||||
// Remove "ISK" and any extra whitespace
|
||||
@@ -26,53 +27,92 @@ export const formatISK = (amount: number): string => {
|
||||
return `${sign}${formatted} ISK`;
|
||||
};
|
||||
|
||||
export const parseTransactionLine = (line: string): {
|
||||
date: Date;
|
||||
quantity: number;
|
||||
itemName: string;
|
||||
unitPrice: number;
|
||||
totalAmount: number;
|
||||
buyer?: string;
|
||||
location?: string;
|
||||
corporation?: string;
|
||||
wallet?: string;
|
||||
} | null => {
|
||||
try {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 6) return null;
|
||||
export type PastedTransaction = IndTransactionRecordNoId & {
|
||||
assignedJobId?: string;
|
||||
isDuplicate?: boolean;
|
||||
}
|
||||
|
||||
let dateStr, quantityStr, itemName, unitPriceStr, totalAmountStr, buyer, location, corporation, wallet;
|
||||
if (parts.length === 8) {
|
||||
[dateStr, quantityStr, itemName, unitPriceStr, totalAmountStr, buyer, location, corporation, wallet] = parts;
|
||||
} else {
|
||||
[dateStr, quantityStr, itemName, unitPriceStr, totalAmountStr, buyer, location] = parts;
|
||||
export const parseTransactionLine = (line: string): PastedTransaction | null => {
|
||||
try {
|
||||
console.log('Parsing transaction line:', line);
|
||||
const parts = line.split('\t');
|
||||
console.log('Split parts:', parts, 'Length:', parts.length);
|
||||
|
||||
if (parts.length < 6) {
|
||||
console.log('Not enough parts, skipping line');
|
||||
return null;
|
||||
}
|
||||
|
||||
let dateStr, quantityStr, itemName, unitPriceStr, totalAmountStr, buyer, location, corporation, wallet;
|
||||
|
||||
// Handle both 7 and 8+ column formats
|
||||
if (parts.length >= 7) {
|
||||
[dateStr, quantityStr, itemName, unitPriceStr, totalAmountStr, buyer, location] = parts;
|
||||
if (parts.length >= 8) {
|
||||
corporation = parts[7];
|
||||
}
|
||||
if (parts.length >= 9) {
|
||||
wallet = parts[8];
|
||||
}
|
||||
} else {
|
||||
console.log('Unexpected number of columns:', parts.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('Extracted values:', { dateStr, quantityStr, itemName, unitPriceStr, totalAmountStr, buyer, location });
|
||||
|
||||
// Parse date (YYYY.MM.DD HH:mm format)
|
||||
const [datePart, timePart] = dateStr.split(' ');
|
||||
if (!datePart || !timePart) {
|
||||
console.log('Invalid date format:', dateStr);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [year, month, day] = datePart.split('.').map(Number);
|
||||
const [hour, minute] = timePart.split(':').map(Number);
|
||||
|
||||
if (isNaN(year) || isNaN(month) || isNaN(day) || isNaN(hour) || isNaN(minute)) {
|
||||
console.log('Invalid date components:', { year, month, day, hour, minute });
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(year, month - 1, day, hour, minute);
|
||||
|
||||
// Parse quantity (remove commas)
|
||||
const quantity = parseInt(quantityStr.replace(/,/g, ''));
|
||||
if (isNaN(quantity)) {
|
||||
console.log('Invalid quantity:', quantityStr);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse prices
|
||||
const unitPrice = parseISKAmount(unitPriceStr);
|
||||
const totalAmount = parseISKAmount(totalAmountStr);
|
||||
let unitPrice = parseISKAmount(unitPriceStr);
|
||||
let totalPrice = parseISKAmount(totalAmountStr);
|
||||
|
||||
console.log('Parsed prices:', { unitPrice, totalPrice });
|
||||
|
||||
if (isNaN(unitPrice) || isNaN(totalPrice)) {
|
||||
console.log('Invalid price values:', { unitPrice, totalPrice });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
date,
|
||||
// Keep the original sign for expenditures (negative values)
|
||||
// The batch expenditure form will handle the conversion
|
||||
|
||||
const result = {
|
||||
date: date.toISOString(),
|
||||
quantity,
|
||||
itemName,
|
||||
itemName: itemName.trim(),
|
||||
unitPrice,
|
||||
totalAmount,
|
||||
buyer,
|
||||
location,
|
||||
corporation,
|
||||
wallet
|
||||
totalPrice,
|
||||
buyer: buyer?.trim() || '',
|
||||
location: location?.trim() || '',
|
||||
corporation: corporation?.trim(),
|
||||
wallet: wallet?.trim()
|
||||
};
|
||||
|
||||
console.log('Successfully parsed transaction:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error parsing transaction line:', line, error);
|
||||
return null;
|
||||
|
35
src/utils/timeUtils.ts
Normal file
35
src/utils/timeUtils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return 'Ready!';
|
||||
|
||||
const units = [
|
||||
{ name: 'w', value: 604800 }, // weeks
|
||||
{ name: 'd', value: 86400 }, // days
|
||||
{ name: 'h', value: 3600 }, // hours
|
||||
{ name: 'm', value: 60 }, // minutes
|
||||
{ name: 's', value: 1 } // seconds
|
||||
];
|
||||
|
||||
const parts: string[] = [];
|
||||
let remaining = Math.floor(seconds);
|
||||
|
||||
for (const unit of units) {
|
||||
if (remaining >= unit.value) {
|
||||
const count = Math.floor(remaining / unit.value);
|
||||
parts.push(`${count}${unit.name}`);
|
||||
remaining %= unit.value;
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : '0s';
|
||||
};
|
||||
|
||||
export const calculateRemainingTime = (jobStart: string | null, runtime: number): number => {
|
||||
if (!jobStart || !runtime) return 0;
|
||||
|
||||
const startTime = new Date(jobStart).getTime();
|
||||
const currentTime = Date.now();
|
||||
const elapsedSeconds = (currentTime - startTime) / 1000;
|
||||
|
||||
return Math.max(0, runtime - elapsedSeconds);
|
||||
};
|
67
updateRemote.sh
Normal file
67
updateRemote.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Function to check if work tree is clean
|
||||
check_work_tree_clean() {
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "Error: Work tree is not clean. Please commit or stash your changes first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to process a single branch
|
||||
process_branch() {
|
||||
local branch_name=$1
|
||||
echo "Processing branch: $branch_name"
|
||||
|
||||
# Check out the branch
|
||||
if ! git checkout "$branch_name"; then
|
||||
echo "Error: Failed to checkout branch $branch_name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check out main -- src
|
||||
if ! git checkout main -- src; then
|
||||
echo "Error: Failed to checkout main -- src"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add all changes
|
||||
if ! git add .; then
|
||||
echo "Error: Failed to add changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Commit changes
|
||||
if ! git commit -m "Update"; then
|
||||
echo "Error: Failed to commit changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Push to remote
|
||||
if ! git push "$branch_name" "$branch_name:main"; then
|
||||
echo "Error: Failed to push to remote"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Successfully processed branch: $branch_name"
|
||||
}
|
||||
|
||||
# Main script
|
||||
echo "Starting update process..."
|
||||
|
||||
# Check if work tree is clean
|
||||
check_work_tree_clean
|
||||
echo "Work tree is clean, proceeding..."
|
||||
|
||||
# Process branches kosmodiskclassic1 through kosmodiskclassic5
|
||||
for i in {1..5}; do
|
||||
branch_name="kosmodiskclassic$i"
|
||||
process_branch "$branch_name"
|
||||
done
|
||||
for i in {1..5}; do
|
||||
branch_name="kosmodisk.classic$i"
|
||||
process_branch "$branch_name"
|
||||
done
|
||||
|
||||
git checkout main
|
||||
echo "All branches processed successfully!"
|
2
wailsjs/runtime/runtime.d.ts
vendored
2
wailsjs/runtime/runtime.d.ts
vendored
@@ -134,7 +134,7 @@ export function WindowIsFullscreen(): Promise<boolean>;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): Promise<Size>;
|
||||
export function WindowSetSize(width: number, height: number): void;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
|
Reference in New Issue
Block a user