Compare commits

...

88 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
ec8f2cbc75 Fix performance issues
Continue to optimize performance by addressing remaining bottlenecks. Further refactor components and calculations to reduce re-renders and improve responsiveness.
2025-07-28 21:17:27 +02:00
gpt-engineer-app[bot]
cf5f666651 Fix performance issues
Address performance problems on the home page and when navigating from job details.
2025-07-28 21:17:03 +02:00
gpt-engineer-app[bot]
b1e08815b3 Optimize frontend computations
Address performance issues on the home page by optimizing frontend calculations. The data loading is efficient, indicating that the bottleneck is likely in the client-side processing of the 1MB dataset.
2025-07-28 21:17:03 +02:00
gpt-engineer-app[bot]
7eb957f66a Optimize home page performance
Address significant performance issues on the home page, where operations like moving jobs or adding transactions take several seconds despite fast database requests. Focus on optimizing client-side computations and rendering to improve responsiveness.
2025-07-28 21:05:04 +02:00
gpt-engineer-app[bot]
57ae872cc6 Fix: React Hooks order in Index page
The `Index` page component was violating the Rules of Hooks by conditionally rendering hooks. This commit refactors the component to ensure hooks are called in a consistent order across renders, resolving the "Rendered more hooks than during the previous render" error. Additionally, it addresses performance concerns by optimizing data fetching and state management.
2025-07-28 21:04:12 +02:00
65050794f0 Add pocketbase admin token 2025-07-28 21:04:12 +02:00
gpt-engineer-app[bot]
e164cd8fbe Optimize home page performance
Address significant performance issues on the home page by optimizing data processing and calculations. The focus is on reducing computational overhead, as database operations appear to be efficient.
2025-07-28 21:04:07 +02:00
8fcaaf2332 Put buttons on batch forms above the content instead of below 2025-07-13 15:42:54 +02:00
0c4854326b Update 2025-07-13 15:42:54 +02:00
gpt-engineer-app[bot]
68ed074c37 Add "queued" job status
Added a new "queued" status for jobs, indicating they are waiting to begin production, occurring before the "running" status.
2025-07-13 15:42:54 +02:00
3791ce992c Add queued status 2025-07-13 15:42:54 +02:00
gpt-engineer-app[bot]
e024439cb0 Adjust runtime calculation for parallel jobs
Update runtime calculation to reflect the impact of parallel job execution, dividing the total runtime by the number of parallel runs.
2025-07-13 15:42:54 +02:00
gpt-engineer-app[bot]
b1925331ed Display and edit parallel jobs on card
Display the parallel jobs value on the job card and enable editing by clicking on it.
2025-07-13 15:42:53 +02:00
gpt-engineer-app[bot]
51467925f3 Enable parallel job execution.
Add UI element to specify parallel job count and utilize the `parallel` field in `indjob` to divide runtime accordingly.
2025-07-13 15:42:53 +02:00
b24b0810f4 Add "parallel" parameter to indjob 2025-07-12 12:46:25 +02:00
3c5cebedfe Always show runtime 2025-07-12 12:41:48 +02:00
gpt-engineer-app[bot]
7353c7e243 Fix: Display missing prices and runtime
Show "Break-even price" and "target-price" if available. Do not render runtime if it is 0.
2025-07-10 01:32:29 +02:00
gpt-engineer-app[bot]
b2c5684577 Add flashing to category headers.
Category headers now flash if any jobs within require attention.  Loading of jobs for 'Acquisition', 'Running', and 'Selling' categories is now prioritized.
2025-07-10 01:26:28 +02:00
a569de9c03 Remove tumor 2025-07-10 01:23:03 +02:00
929f7d170b Fuck with the colors a bit 2025-07-10 01:23:03 +02:00
40c91f9f2b Remove job status dropdown
Don't need it anymore
2025-07-10 01:15:45 +02:00
gpt-engineer-app[bot]
85f55da57b Fix status button colors and runtime display
Fixed status button colors. Added remaining runtime display for running jobs below "sale start" and "sale end" in job details.
2025-07-10 01:15:45 +02:00
gpt-engineer-app[bot]
f697f38e4f feat: Implement runtime parsing and reduce button alpha
Adds runtime parsing from job imports and adds a runtime field to the job creation dialogue. Reduces the alpha on status buttons.
2025-07-10 00:57:28 +02:00
gpt-engineer-app[bot]
c5b4a41b19 Refactor: Style status navigation buttons
Make status navigation buttons wider, center text, and color them based on status.
2025-07-10 00:57:28 +02:00
gpt-engineer-app[bot]
5e2caefaaf Refactor: Move status navigation buttons
Relocate the status navigation buttons to below the header, between the header and details sections of the job card. This change aims to improve the layout and address the issue of long job names. Also, ensure no duplicate code.
2025-07-10 00:57:28 +02:00
d4d17ae987 Update types with new statuses 2025-07-10 00:57:28 +02:00
gpt-engineer-app[bot]
fbda209db4 Add job status navigation buttons
Adds forward and backward buttons to job cards to change job status. Implements new statuses and highlights jobs needing attention.
2025-07-10 00:57:28 +02:00
gpt-engineer-app[bot]
d05d0180b8 Add job card glow effect
Highlight job cards based on completion criteria for "Acquisition" and "Selling" jobs.
2025-07-10 00:57:28 +02:00
8b2c85df33 Update update remote 2025-07-10 00:27:42 +02:00
gpt-engineer-app[bot]
b29416495c Refactor BatchTransactionForm
Refactors `BatchTransactionForm.tsx` into smaller, focused components and a custom hook to improve maintainability, mirroring the structure of the refactored `BatchExpenditureForm`.
2025-07-09 21:42:01 +02:00
gpt-engineer-app[bot]
d645dbde2f Refactor: Split BatchExpenditureForm into smaller components
Refactors the BatchExpenditureForm component into smaller, more manageable files to improve code organization and readability.
2025-07-09 21:42:01 +02:00
gpt-engineer-app[bot]
73ccee5dd3 Fix batch expenditure form and add autofocus
Fixed the batch expenditure form to correctly recognize transactions. Added autofocus to the input forms in both batch income and expenditure forms.
2025-07-09 21:42:01 +02:00
gpt-engineer-app[bot]
fb130799a9 Add missing materials export button
Implemented a button to export only missing materials based on BOM and expenditures, using `useMaterialsCalculations` to determine missing quantities. Updated `MaterialsActions` and `MaterialsImportExport` components.
2025-07-09 21:42:01 +02:00
gpt-engineer-app[bot]
5af53723cc Refactor Index page
Refactor `src/pages/Index.tsx` into smaller components.
2025-07-09 21:18:51 +02:00
gpt-engineer-app[bot]
81e9a98315 Refactor: Split Index.tsx into smaller components
This commit refactors the `Index.tsx` file into smaller, more manageable components to improve code organization and readability.
2025-07-09 21:18:51 +02:00
gpt-engineer-app[bot]
15280ecdab Refactor MaterialsImportExport component
Refactor the MaterialsImportExport component into smaller, more manageable files.
2025-07-09 21:18:51 +02:00
gpt-engineer-app[bot]
e33368dcb7 Add batch expenditure assignment
- Implemented batch assignment for expenditures, similar to income.
- Added a button to export missing materials.
- Updated logic to consider jobs in "acquisition" status.
- Added logic to skip jobs with satisfied BOM.
2025-07-09 21:18:44 +02:00
93037b581c Make sure our transactions are always positive
Amount cannot be negative
2025-07-09 21:06:23 +02:00
02e91a3951 Ignore some shit 2025-07-09 16:55:50 +02:00
a241b74599 Update remote script 2025-07-09 16:53:28 +02:00
gpt-engineer-app[bot]
5825370f23 Add automatic date assignment on status change
Update job dates based on status changes.
2025-07-09 16:42:37 +02:00
e00907e575 Load the god damn fucking credentials you robot retard 2025-07-09 16:39:42 +02:00
gpt-engineer-app[bot]
8b431eaeca Fix jarring job loading and scrolling
Improve job loading to ensure total revenue is accurate and prevent page refresh/scroll to top when expanding job categories.
2025-07-09 15:10:05 +02:00
gpt-engineer-app[bot]
6ce39c89d0 Fix: Job dump parsing issue
Corrected job dump parsing to include the first line of the BOM.
2025-07-09 15:10:05 +02:00
gpt-engineer-app[bot]
dc5b91d104 Add bill of materials after job creation
Add functionality to add bill of materials to a job after it has been created.
2025-07-09 15:10:05 +02:00
gpt-engineer-app[bot]
260c1c0af3 Feat: Implement job dump parsing
The job dump input should update the output item fields.
The dump is delimited by newlines.
2025-07-09 15:10:05 +02:00
gpt-engineer-app[bot]
c9d9cd99ee Remove date fields and add job dump import
Removed date fields from the job form and added a text area for importing job dumps.
2025-07-09 15:10:05 +02:00
gpt-engineer-app[bot]
dfa886eec7 Fix: Implement missing per-day lines in charts
The per-day lines were missing from the charts. This commit ensures that all charts display both cumulative and per-day data as requested.
2025-07-09 03:42:07 +02:00
gpt-engineer-app[bot]
7fb0cfb41c Fix chart display inconsistencies
Ensure consistent chart styles across different views, adding cumulative revenue and profit lines with area shading.
2025-07-09 03:42:07 +02:00
gpt-engineer-app[bot]
ebb5e4931f Refactor: Improve transaction chart visualizations
The AI has updated the transaction charts to match the total revenue chart's style, implementing specific display rules for different chart types (job, cost, revenue, profit) including cumulative values, shaded areas, and line representations.
2025-07-09 03:42:07 +02:00
34f9127778 Bigger chart icon please 2025-07-09 03:26:53 +02:00
gpt-engineer-app[bot]
0c69b59677 feat: Add chart icons to total revenue/profit
Adds chart icons to the total revenue and profit displays on the main page.
Implements cumulative lines on all charts.
Addresses chart scoping issues.
2025-07-09 03:26:50 +02:00
86a9fc4382 Move the fucking chart button left of the import button
WAS IT SO HARD!?
2025-07-09 03:26:47 +02:00
gpt-engineer-app[bot]
6f7f777eab Fix chart display and add global chart views
- Fixed chart legend cutoff issue.
- Improved chart time-based granularity.
- Prevented chart details from auto-opening on close.
- Added chart icon next to "Sold" and to "Total Revenue" and "Total Profit" sections.
2025-07-09 03:26:44 +02:00
36fe2b114f Change BOM import to download
To be same as upload
2025-07-09 03:26:38 +02:00
gpt-engineer-app[bot]
99aa53652b Fix chart icon display issues
Fixes icon placement, click propagation, and missing total revenue/profit graphs. Addresses alignment and layout issues.
2025-07-09 01:02:25 +00:00
gpt-engineer-app[bot]
1db029e573 feat: Add interactive charts for job metrics
Adds line/area charts for costs, revenue, and profit, both per job and overall. Implements modal popups for graph display and adds graph icons for easy access.
2025-07-09 00:57:40 +00:00
767288ea86 Fix pasting transactions on job details 2025-07-09 01:12:58 +02:00
3820522f32 Properly implement transaction deduplication
Using my superior HUMAN brain
Idiot ram stick
2025-07-08 21:38:03 +02:00
gpt-engineer-app[bot]
74dbaab169 Improve transaction deduplication logic
Limit deduplication to transactions of the relevant jobs.
2025-07-08 19:20:54 +00:00
gpt-engineer-app[bot]
b527ecebee Fix transaction deduplication
Address the issue where duplicate transactions are still being created after the previous fix.
2025-07-08 19:15:53 +00:00
gpt-engineer-app[bot]
58ed99a8ae Fix: Improve transaction deduplication logic
Ensures deduplication occurs after combining pasted transactions with existing ones to prevent duplicate entries.
-edited src/components/BatchTransactionForm.tsx
2025-07-08 19:10:37 +00:00
7644ea5c6b Make shit EDITABLE DAMN IT 2025-07-08 19:20:53 +02:00
fe4eb80ed5 Justify-center BABYYYYYYYYYY 2025-07-08 13:20:27 +02:00
335bbc3bab Code format 2025-07-08 13:18:04 +02:00
1dc07159c1 Enhance PriceDisplay component with adjusted pricing calculations
- Added calculations for adjusted target and break-even prices based on remaining revenue and uncovered costs.
- Implemented clipboard copy functionality for adjusted prices.
- Updated layout to display adjusted prices alongside original calculations for better clarity.
2025-07-08 13:17:16 +02:00
78f4f1e527 Refactor PriceDisplay component layout
- Adjusted grid layout for price display to improve readability.
- Consolidated break-even price display into a more compact format.
- Enhanced styling for better visual alignment of price information.
2025-07-08 13:14:27 +02:00
gpt-engineer-app[bot]
d810b86474 Refactor: Improve price display and formatting
- Round prices to 4 significant digits.
- Adjust grid layout for price display.
- Move tax information to header.
2025-07-08 13:11:34 +02:00
gpt-engineer-app[bot]
dd8b4c8e94 Refactor job card header layout
Adjust job card header width to fit content, aligning values.
2025-07-08 13:11:34 +02:00
gpt-engineer-app[bot]
ef74c46550 Fix Job Card layout and tax display
Refactor Job Card layout to a grid, and adjust tax display.
2025-07-08 13:11:34 +02:00
gpt-engineer-app[bot]
9f50189cd1 Fix: Close batch assign modal on outside click
feat: Add break even price to job metrics, rename min price
2025-07-08 13:11:34 +02:00
965ac51c59 Undo clipboard reading 2025-07-08 12:50:55 +02:00
gpt-engineer-app[bot]
135ce5d8fa Fix UI and functionality issues
Fixes tax config button size, recalculates min prices on tax change, saves tax on blur, and implements automatic pasting to batch assign.
2025-07-08 12:50:55 +02:00
gpt-engineer-app[bot]
50cb89eff5 feat: Add minimum price per unit display
- Added a display for the minimum price per unit required to meet revenue expectations.
- Replaced the "BOM: 4 items hover to view" with the new display.
- Implemented clipboard copy functionality for the minimum price.
- Added a sales tax configuration option.
- Modified the "batch assign" button to read data from the clipboard on click.
2025-07-08 12:50:55 +02:00
gpt-engineer-app[bot]
6e7e4e4f73 Fix: Improve revenue performance indicator
The revenue performance indicator now correctly calculates and displays the performance based on price per unit, comparing expected and actual revenue per item sold. It also handles cases where produced or sold quantities are zero.
2025-07-08 12:39:17 +02:00
gpt-engineer-app[bot]
7a61a3d62a Fix: JobCardMetrics layout and performance indicator
- Moved progress percentages below ISK values in JobCardMetrics.
- Corrected performance calculation in JobCardMetrics to accurately reflect progress.
2025-07-08 12:39:17 +02:00
gpt-engineer-app[bot]
8b2a44e1ea Refactor JobCardMetrics display
Consistently style the revenue progress and performance indicator, either with or without a backdrop/icon.
2025-07-08 12:39:17 +02:00
gpt-engineer-app[bot]
460049a1a2 Refactor performance indicator display
Reduced verbosity and improved the layout of the performance indicator in `JobCardMetrics.tsx` for a more compact and accurate display.
2025-07-08 12:39:17 +02:00
gpt-engineer-app[bot]
037328f0c9 feat: Add selling job progress metric
Adds a metric to the JobCardMetrics component to display the progress of selling jobs, comparing actual revenue to projected revenue based on the number of items sold.
2025-07-08 12:39:17 +02:00
bcf249e9c4 Fix sorting shit in recap 2025-07-07 21:06:28 +02:00
f3ab31ce76 Code format 2025-07-07 20:10:09 +02:00
e30cac00ea Enable clicking on sort on individual job popovers 2025-07-07 20:09:54 +02:00
2009273959 Make jobID copy-able 2025-07-07 20:09:54 +02:00
gpt-engineer-app[bot]
127dd3cfda feat: Add transaction popover to job metrics
Adds popover functionality to display transactions on click of cost, revenue, and profit metrics in JobCardMetrics (Index and JobDetails).
2025-07-07 19:58:23 +02:00
2ba0f735fd Update 2025-07-07 19:52:05 +02:00
ad7ada88c2 Fix up the popover a bit 2025-07-07 19:51:37 +02:00
gpt-engineer-app[bot]
ec8430189a Refactor: Extract common logic and reduce file size
Refactors `Index.tsx` and `JobCardHeader.tsx` to reduce bloat and extract common logic, such as job status coloring, into reusable components or utility functions. This improves code maintainability and readability.
2025-07-07 19:50:28 +02:00
gpt-engineer-app[bot]
cb32ccaba9 feat: Implement revenue/profit recap and fixes
- Added a hover-over recap for total revenue and profit, displaying contributing jobs in a popup.
- Fixed the issue where the lower parts of letters in job names were cut off.
- Implemented job ID copy-to-clipboard functionality on click.
2025-07-07 19:40:52 +02:00
77 changed files with 5904 additions and 1548 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
build/bin
dist
node_modules
src/lib/pocketbaseAdmin.ts

View File

@@ -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)

View File

@@ -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
View File

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

View File

@@ -1 +0,0 @@
08d93e7b725ac442f35af92973131ea7

View 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;

View 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;

View 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>
);
}

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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;

View File

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

View File

@@ -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;

View File

@@ -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)}
/>
</>
);
};

View File

@@ -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;

View File

@@ -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:&#10;&#10;Discovery Survey Probe I 800&#10;468000&#10;158,670,288&#10;237,484,800&#10;&#10;Tritanium 8888800&#10;Pyerite 711200&#10;..."
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'}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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>
);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

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

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
};
};

View 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
};
};

View 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
View 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
View 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
};
}

View 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
};
}

View File

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

View 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]);
};

View 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
]);
};

View 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
};
};

View 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
};
}

View 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;
};

View File

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

View File

@@ -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];
}
});

View 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 };

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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];
}

View File

@@ -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
View 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
View 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');
}

View 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
View 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 };
};

View 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];
};

View 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');
};

View 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;
};

View File

@@ -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
View 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
View 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!"

View File

@@ -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.