37 Commits

Author SHA1 Message Date
matt 763aa4f74b Tweak sidebar and header 2025-06-22 21:21:14 -04:00
matt 520ff5bd74 Lazy loading for smaller build chunks/faster initial load 2025-06-22 21:07:17 -04:00
matt 8496bbc4ee Merge dashboard app in 2025-06-22 19:13:35 -04:00
matt 38f6688f10 Misc product fixes 2025-06-22 15:52:16 -04:00
matt fcfe7e2fab Add groups to sidebar 2025-06-20 14:55:45 -04:00
matt 2e3e81a02b Opus corrections/fixes/additions 2025-06-19 15:49:31 -04:00
matt 8606a90e34 Optimize imports, fix up tracking records and time overall 2025-06-19 11:15:04 -04:00
matt a97819f4a6 Clean up old historical data calcs/scripts, optimize calculations to not update every row every time 2025-06-18 15:13:31 -04:00
matt dd82c624d8 Fix issues with data management settings page 2025-06-18 11:20:34 -04:00
matt 7999e1e64a FIx time zone calcs 2025-06-15 09:24:40 -04:00
matt 12a0f540b3 Chat fixes and layout tweaks 2025-06-15 00:55:49 -04:00
matt e793cb0cc5 Build out chat more 2025-06-14 14:27:50 -04:00
matt b2330dee22 Add chat page and chat server 2025-06-14 13:36:31 -04:00
matt 00501704df Switch dev port 2025-06-14 10:43:48 -04:00
matt 4cb41a7e4c Fix PO import not removing products from edited POs 2025-04-14 14:31:03 -04:00
matt d05d27494d Adjust PO accordion styles, add in product and PO/receiving links 2025-04-14 14:20:30 -04:00
matt 4ed734e5c0 Add PO details accordion to purchase orders page 2025-04-14 00:58:55 -04:00
matt 1e3be5d4cb Refactor purchase orders page into individual components 2025-04-14 00:29:37 -04:00
matt 8dd852dd6a Fix filtering/sorting/pagination for purchase orders 2025-04-13 23:51:09 -04:00
matt eeff5817ea More layout/header tweaks for purchase orders 2025-04-13 22:19:14 -04:00
matt 1b19feb172 Tweak layout of purchase orders page and redo header cards 2025-04-13 17:16:08 -04:00
matt 80ff8124ec Update calculate scripts and routes for PO table split 2025-04-12 17:07:43 -04:00
matt 8508bfac93 Add receivings table, split PO import 2025-04-12 14:20:59 -04:00
matt ac14179bd2 PO-related fixes 2025-04-12 10:54:42 -04:00
matt 00249f7c33 Clean up routes 2025-04-08 21:26:00 -04:00
matt f271f3aae4 Get frontend dashboard/analytics mostly loading data again 2025-04-08 00:02:43 -04:00
matt 43f76e4ac0 Fix specific import calculations 2025-04-07 22:07:21 -04:00
matt 92ff80fba2 Import and calculate tweaks and fixes 2025-04-06 17:12:36 -04:00
matt a4c1a19d2e Try to synchronize time zones across import 2025-04-05 16:20:43 -04:00
matt c9b656d34b Tweaks and fixes for products table 2025-04-05 09:52:36 -04:00
matt d081a60662 Change calculate metrics script to only record one entry in database per run 2025-04-04 11:33:50 -04:00
matt 4021fe487d Create pages and routes for new settings tables, start improving product details 2025-04-03 22:12:53 -04:00
matt 4552fa4862 Move product status calculation to database, fix up products table, more categories tweaks 2025-04-03 17:12:10 -04:00
matt 2601a04211 Category calculation fixes 2025-04-02 15:42:20 -04:00
matt 6051b849d6 Consolidate old/new vendor and category routes, enhance new brands route, update frontend accordingly for all three pages, improve hierarchy on categories page, fix some calculations 2025-04-02 14:28:18 -04:00
matt dbd0232285 Update/add frontend pages for categories, brands, vendors new routes, update products page to use new route 2025-04-01 14:34:57 -04:00
matt 1b9f01d101 Add routes for brands, categories, vendors new implementation 2025-04-01 12:03:12 -04:00
180 changed files with 37005 additions and 8944 deletions
+7 -1
View File
@@ -67,4 +67,10 @@ inventory-server/scripts/.fuse_hidden00000fa20000000a
.VSCodeCounter/
.VSCodeCounter/*
.VSCodeCounter/**/*
.VSCodeCounter/**/*
*/chat/db-convert/db/*
*/chat/db-convert/mongo_converter_env/*
# Ignore compiled Vite config to avoid duplication
vite.config.js
+271
View File
@@ -0,0 +1,271 @@
**Analysis of Potential Issues**
1. **Obsolete Functionality:**
* **`config.js` Legacy Endpoints:** The endpoints `GET /config/`, `PUT /config/stock-thresholds/:id`, `PUT /config/lead-time-thresholds/:id`, `PUT /config/sales-velocity/:id`, `PUT /config/abc-classification/:id`, `PUT /config/safety-stock/:id`, and `PUT /config/turnover/:id` appear **highly likely to be obsolete**. They reference older, single-row config tables (`stock_thresholds`, etc.) while newer endpoints (`/config/global`, `/config/products`, `/config/vendors`) manage settings in more structured tables (`settings_global`, `settings_product`, `settings_vendor`). Unless specifically required for backward compatibility, these legacy endpoints should be removed to avoid confusion and potential data conflicts.
* **`analytics.js` Forecast Endpoint (`GET /analytics/forecast`):** This endpoint uses **MySQL syntax** (`DATEDIFF`, `DATE_FORMAT`, `JSON_OBJECT`, `?` placeholders) but seems intended to run within the analytics module which otherwise uses PostgreSQL (`req.app.locals.pool`, `date_trunc`, `::text`, `$1` placeholders). This endpoint is likely **obsolete or misplaced** and will not function correctly against the PostgreSQL database.
* **`csv.js` Redundant Actions:**
* `POST /csv/update` seems redundant with `POST /csv/full-update`. The latter uses the `runScript` helper and dedicated state (`activeFullUpdate`), appearing more robust. `/csv/update` might be older or incomplete.
* `POST /csv/reset` seems redundant with `POST /csv/full-reset`. Similar reasoning applies; `/csv/full-reset` appears preferred.
* **`products.js` Import Endpoint (`POST /products/import`):** This is **dangerous duplication**. The `/csv` module handles imports (`/csv/import`, `/csv/import-from-prod`) with locking (`activeImport`) to prevent concurrent operations. This endpoint lacks such locking and could corrupt data if run simultaneously with other CSV/reset operations. It should likely be removed.
* **`products.js` Metrics Endpoint (`GET /products/:id/metrics`):** This is redundant. The `/metrics/:pid` endpoint provides the same, possibly more comprehensive, data directly from the `product_metrics` table. Clients should use `/metrics/:pid` instead.
2. **Overlap or Inappropriate Duplication of Effort:**
* **AI Prompt Getters:** `GET /ai-prompts/type/general` and `GET /ai-prompts/type/system` could potentially be handled by adding a query parameter filter to `GET /ai-prompts/` (e.g., `GET /ai-prompts?prompt_type=general`). However, dedicated endpoints for single, specific items can sometimes be simpler. This is more of a design choice than a major issue.
* **Vendor Performance/Metrics:** There are multiple ways to get vendor performance data:
* `GET /analytics/vendors` (uses `vendor_metrics`)
* `GET /dashboard/vendor/performance` (uses `purchase_orders`)
* `GET /purchase-orders/vendor-metrics` (uses `purchase_orders`)
* `GET /vendors-aggregate/` (uses `vendor_metrics`, augmented with `purchase_orders`)
This suggests significant overlap. The `/vendors-aggregate` endpoint seems the most comprehensive, combining pre-aggregated data with some real-time info. The others, especially `/dashboard/vendor/performance` and `/purchase-orders/vendor-metrics` which calculate directly from `purchase_orders`, might be redundant or less performant.
* **Product Listing:**
* `GET /products/` lists products joining `products`, `product_metrics`, and `categories`.
* `GET /metrics/` lists products primarily from `product_metrics`.
They offer similar filtering/sorting. If `product_metrics` contains all necessary display fields, `GET /products/` might be partly redundant for simple listing views, although it does provide aggregated category names. Evaluate if both full list endpoints are necessary.
* **Image Uploads/Management:** Image handling is split:
* `products-import.js`: Uploads temporary images for product import to `/uploads/products/`, schedules deletion.
* `reusable-images.js`: Uploads persistent images to `/uploads/reusable/`, stores metadata in DB.
* `products-import.js` has `/check-file` and `/list-uploads` that can see *both* directories, while `reusable-images.js` has a `/check-file` that only sees its own. This separation could be confusing. Clarify the purpose and lifecycle of images in each directory.
* **Background Task Management (`csv.js`):** The use of `activeImport` for multiple unrelated tasks (import, reset, metrics calc) prevents concurrency, which might be too restrictive. The cancellation logic (`/cancel`) only targets `full-update`/`full-reset`, not tasks locked by `activeImport`. This needs unification.
* **Analytics/Dashboard Base Table Queries:** Several endpoints in `analytics.js` (`/pricing`, `/categories`) and `dashboard.js` (`/best-sellers`, `/sales/metrics`, `/trending/products`, `/key-metrics`, `/inventory-health`, `/sales-overview`) query base tables (`orders`, `products`, `purchase_orders`) directly, while many others leverage pre-aggregated `_metrics` tables. This inconsistency can lead to performance differences and suggests potential for optimization by using aggregates where possible.
3. **Obvious Mistakes / Data Issues:**
* **AI Prompt Fetching:** `GET /ai-prompts/company/:companyId`, `/type/general`, `/type/system` return `result.rows[0]`. This assumes uniqueness. If the underlying DB constraints (`unique_company_prompt`, etc.) fail or aren't present, this could silently hide data if multiple rows match. The use of unique constraint handling in POST/PUT suggests this is likely intended and safe *if* DB constraints are solid.
* **Mixed Databases & SSH Tunnels:** The heavy reliance in `ai_validation.js` and `products-import.js` on connecting to a production MySQL DB via SSH tunnel while also using a local PostgreSQL DB adds significant architectural complexity.
* **Inefficiency:** In `ai_validation.js` (`generateDebugResponse`), an SSH tunnel and MySQL connection (`promptTunnel`, `promptConnection`) are established but seem unused when fetching prompts (which correctly come from the PG pool `res.app.locals.pool`). This is wasted effort.
* **Improvement:** The `getDbConnection` function in `products-import.js` implements caching/pooling for the SSH/MySQL connection this is much better and should ideally be used consistently wherever the production DB is accessed (e.g., in `ai_validation.js`).
* **`products.js` Brand Filtering:** `GET /products/brands` filters brands based on having associated purchase orders with a cost >= 500. This seems arbitrary for a general list of brands and might return incomplete results depending on the use case.
* **Type Handling:** Ensure `parseValue` handles all required types and edge cases correctly, especially for filtering complex queries in `*-aggregate` and `metrics` routes. Explicit type casting in SQL (`::numeric`, `::text`, etc.) is generally good practice in PostgreSQL.
* **Dummy Data:** Several `dashboard.js` endpoints return hardcoded dummy data on errors or when no data is found. While this prevents UI crashes, it can mask real issues. Ensure logging is robust when fallbacks are used.
**Summary of Endpoints**
Here's a summary of the available endpoints, grouped by their likely file/module:
**1. AI Prompts (`ai_prompts.js`)**
* `GET /`: Get all AI prompts.
* `GET /:id`: Get a specific AI prompt by its ID.
* `GET /company/:companyId`: Get the AI prompt for a specific company (expects one). **(Deprecated)**
* `GET /type/general`: Get the general AI prompt (expects one). **(Deprecated)**
* `GET /type/system`: Get the system AI prompt (expects one). **(Deprecated)**
* `GET /by-type`: Get AI prompt by type (general, system, company_specific) with optional company parameter. **(New Consolidated Endpoint)**
* `POST /`: Create a new AI prompt.
* `PUT /:id`: Update an existing AI prompt.
* `DELETE /:id`: Delete an AI prompt.
**2. AI Validation (`ai_validation.js`)**
* `POST /debug`: Generate and view the structure of prompts and taxonomy data (for debugging, doesn't call OpenAI). Connects to Prod MySQL (taxonomy) and Local PG (prompts, performance).
* `POST /validate`: Validate product data using OpenAI. Connects to Prod MySQL (taxonomy) and Local PG (prompts, performance).
* `GET /test-taxonomy`: Test endpoint to query sample taxonomy data from Prod MySQL.
**3. Analytics (`analytics.js`)**
* `GET /stats`: Get overall business statistics from metrics tables.
* `GET /profit`: Get profit analysis data (by category, over time, top products) from metrics tables.
* `GET /vendors`: Get vendor performance analysis from `vendor_metrics`.
* `GET /stock`: Get stock analysis data (turnover, levels, critical items) from metrics tables.
* `GET /pricing`: Get pricing analysis (price points, elasticity, recommendations) - **uses `orders` table**.
* `GET /categories`: Get category performance analysis (revenue, profit, growth, distribution, trends) - **uses `orders` and `products` tables**.
* `GET /forecast`: (**Likely Obsolete/Broken**) Attempts to get forecast data using MySQL syntax.
**4. Brands Aggregate (`brands-aggregate.js`)**
* `GET /filter-options`: Get distinct brand names and statuses for UI filters (from `brand_metrics`).
* `GET /stats`: Get overall statistics related to brands (from `brand_metrics`).
* `GET /`: List brands with aggregated metrics, supporting filtering, sorting, pagination (from `brand_metrics`).
**5. Categories Aggregate (`categories-aggregate.js`)**
* `GET /filter-options`: Get distinct category types, statuses, and counts for UI filters (from `category_metrics` & `categories`).
* `GET /stats`: Get overall statistics related to categories (from `category_metrics` & `categories`).
* `GET /`: List categories with aggregated metrics, supporting filtering, sorting (incl. hierarchy), pagination (from `category_metrics` & `categories`).
**6. Configuration (`config.js`)**
* **(New)** `GET /global`: Get all global settings.
* **(New)** `PUT /global`: Update global settings.
* **(New)** `GET /products`: List product-specific settings with pagination/search.
* **(New)** `PUT /products/:pid`: Update/Create product-specific settings.
* **(New)** `POST /products/:pid/reset`: Reset product settings to defaults.
* **(New)** `GET /vendors`: List vendor-specific settings with pagination/search.
* **(New)** `PUT /vendors/:vendor`: Update/Create vendor-specific settings.
* **(New)** `POST /vendors/:vendor/reset`: Reset vendor settings to defaults.
* **(Legacy/Obsolete)** `GET /`: Get all config from old single-row tables.
* **(Legacy/Obsolete)** `PUT /stock-thresholds/:id`: Update old stock thresholds.
* **(Legacy/Obsolete)** `PUT /lead-time-thresholds/:id`: Update old lead time thresholds.
* **(Legacy/Obsolete)** `PUT /sales-velocity/:id`: Update old sales velocity config.
* **(Legacy/Obsolete)** `PUT /abc-classification/:id`: Update old ABC config.
* **(Legacy/Obsolete)** `PUT /safety-stock/:id`: Update old safety stock config.
* **(Legacy/Obsolete)** `PUT /turnover/:id`: Update old turnover config.
**7. CSV Operations & Background Tasks (`csv.js`)**
* `GET /:type/progress`: SSE endpoint for full update/reset progress.
* `GET /test`: Simple test endpoint.
* `GET /status`: Check status of the generic background task lock (`activeImport`).
* `GET /calculate-metrics/status`: Check status of metrics calculation.
* `GET /history/import`: Get recent import history.
* `GET /history/calculate`: Get recent metrics calculation history.
* `GET /status/modules`: Get last calculation time per module.
* `GET /status/tables`: Get last sync time per table.
* `GET /status/table-counts`: Get record counts for key tables.
* `POST /update`: (**Potentially Obsolete**) Trigger `update-csv.js` script.
* `POST /import`: Trigger `import-csv.js` script.
* `POST /cancel`: Cancel `/full-update` or `/full-reset` task.
* `POST /reset`: (**Potentially Obsolete**) Trigger `reset-db.js` script.
* `POST /reset-metrics`: Trigger `reset-metrics.js` script.
* `POST /calculate-metrics`: Trigger `calculate-metrics.js` script.
* `POST /import-from-prod`: Trigger `import-from-prod.js` script.
* `POST /full-update`: Trigger `full-update.js` script (preferred update).
* `POST /full-reset`: Trigger `full-reset.js` script (preferred reset).
**8. Dashboard (`dashboard.js`)**
* `GET /stock/metrics`: Get dashboard stock summary metrics & brand breakdown.
* `GET /purchase/metrics`: Get dashboard purchase order summary metrics & vendor breakdown.
* `GET /replenishment/metrics`: Get dashboard replenishment summary & top variants.
* `GET /forecast/metrics`: Get dashboard forecast summary, daily, and category breakdown.
* `GET /overstock/metrics`: Get dashboard overstock summary & category breakdown.
* `GET /overstock/products`: Get list of top overstocked products.
* `GET /best-sellers`: Get dashboard best-selling products, brands, categories - **uses `orders`, `products`**.
* `GET /sales/metrics`: Get dashboard sales summary for a period - **uses `orders`**.
* `GET /low-stock/products`: Get list of top low stock/critical products.
* `GET /trending/products`: Get list of trending products - **uses `orders`, `products`**.
* `GET /vendor/performance`: Get dashboard vendor performance details - **uses `purchase_orders`**.
* `GET /key-metrics`: Get dashboard summary KPIs - **uses multiple base tables**.
* `GET /inventory-health`: Get dashboard inventory health overview - **uses `products`, `product_metrics`**.
* `GET /replenish/products`: Get list of products needing replenishment (overlaps `/low-stock/products`).
* `GET /sales-overview`: Get daily sales totals for chart - **uses `orders`**.
**9. Product Import Utilities (`products-import.js`)**
* `POST /upload-image`: Upload temporary product image, schedule deletion.
* `DELETE /delete-image`: Delete temporary product image.
* `GET /field-options`: Get dropdown options for product fields from Prod MySQL (cached).
* `GET /product-lines/:companyId`: Get product lines for a company from Prod MySQL (cached).
* `GET /sublines/:lineId`: Get sublines for a line from Prod MySQL (cached).
* `GET /check-file/:filename`: Check existence/permissions of uploaded file (temp or reusable).
* `GET /list-uploads`: List files in upload directories.
* `GET /search-products`: Search products in Prod MySQL DB.
* `GET /check-upc-and-generate-sku`: Check UPC existence and generate SKU suggestion based on Prod MySQL data.
* `GET /product-categories/:pid`: Get assigned categories for a product from Prod MySQL.
**10. Product Metrics (`product-metrics.js`)**
* `GET /filter-options`: Get distinct filter values (vendor, brand, abcClass) from `product_metrics`.
* `GET /`: List detailed product metrics with filtering, sorting, pagination (primary data access).
* `GET /:pid`: Get full metrics record for a single product.
**11. Orders (`orders.js`)**
* `GET /`: List orders with summary info, filtering, sorting, pagination, and stats.
* `GET /:orderNumber`: Get details for a single order, including items.
**12. Products (`products.js`)**
* `GET /brands`: Get distinct brands (filtered by PO value).
* `GET /`: List products with core data + metrics, filtering, sorting, pagination.
* `GET /trending`: Get trending products based on `product_metrics`.
* `GET /:id`: Get details for a single product (core data + metrics).
* `POST /import`: (**Likely Obsolete/Dangerous**) Import products from CSV.
* `PUT /:id`: Update core product data.
* `GET /:id/metrics`: (**Redundant**) Get metrics for a single product.
* `GET /:id/time-series`: Get sales/PO history for a single product.
**13. Purchase Orders (`purchase-orders.js`)**
* `GET /`: List purchase orders with summary info, filtering, sorting, pagination, and summary stats.
* `GET /vendor-metrics`: Calculate vendor performance metrics from `purchase_orders`.
* `GET /cost-analysis`: Calculate cost analysis by category from `purchase_orders`.
* `GET /receiving-status`: Get summary counts based on PO receiving status.
* `GET /order-vs-received`: List product ordered vs. received quantities.
**14. Reusable Images (`reusable-images.js`)**
* `GET /`: List all reusable images.
* `GET /by-company/:companyId`: List global and company-specific images.
* `GET /global`: List only global images.
* `GET /:id`: Get a single reusable image record.
* `POST /upload`: Upload a new reusable image and create DB record.
* `PUT /:id`: Update reusable image metadata (name, global, company).
* `DELETE /:id`: Delete reusable image record and file.
* `GET /check-file/:filename`: Check existence/permissions of a reusable image file.
**15. Templates (`templates.js`)**
* `GET /`: List all product data templates.
* `GET /:company/:productType`: Get a specific template.
* `POST /`: Create a new template.
* `PUT /:id`: Update an existing template.
* `DELETE /:id`: Delete a template.
**16. Vendors Aggregate (`vendors-aggregate.js`)**
* `GET /filter-options`: Get distinct vendor names and statuses for UI filters (from `vendor_metrics`).
* `GET /stats`: Get overall statistics related to vendors (from `vendor_metrics` & `purchase_orders`).
* `GET /`: List vendors with aggregated metrics, supporting filtering, sorting, pagination (from `vendor_metrics` & `purchase_orders`).
**Recommendations:**
1. **Address Obsolete Endpoints:** Prioritize removing or confirming the necessity of the endpoints marked as obsolete/redundant (legacy config, `/analytics/forecast`, `/csv/update`, `/csv/reset`, `/products/import`, `/products/:id/metrics`).
2. **Consolidate Overlapping Functionality:** Review the multiple vendor performance and product listing endpoints. Decide on the primary method (e.g., using aggregate tables via `/vendors-aggregate` and `/metrics`) and refactor or remove the others. Clarify the image upload strategies.
3. **Standardize Data Access:** Decide whether `dashboard` and `analytics` endpoints should primarily use aggregate tables (like `/metrics`, `/brands-aggregate`, etc.) or if direct access to base tables is sometimes necessary. Aim for consistency and document the reasoning. Optimize queries hitting base tables if they must remain.
4. **Improve Background Task Management:** Refactor `csv.js` to use a unified locking mechanism (maybe separate locks per task type?) and a consistent cancellation strategy for all spawned/managed processes. Clarify the purpose of `update` vs `full-update` and `reset` vs `full-reset`.
5. **Optimize DB Connections:** Ensure the `getDbConnection` pooling/caching helper from `products-import.js` is used *consistently* across all modules interacting with the production MySQL database (especially `ai_validation.js`). Remove unnecessary tunnel creations.
6. **Review Data Integrity:** Double-check the assumptions made (e.g., uniqueness of AI prompts) and ensure database constraints enforce them. Review the `GET /products/brands` filtering logic.
## Changes Made
1. **Removed Obsolete Legacy Endpoints in `config.js`**:
- Removed `GET /config/` endpoint
- Removed `PUT /config/stock-thresholds/:id` endpoint
- Removed `PUT /config/lead-time-thresholds/:id` endpoint
- Removed `PUT /config/sales-velocity/:id` endpoint
- Removed `PUT /config/abc-classification/:id` endpoint
- Removed `PUT /config/safety-stock/:id` endpoint
- Removed `PUT /config/turnover/:id` endpoint
These endpoints were obsolete as they referenced older, single-row config tables that have been replaced by newer endpoints using the structured tables `settings_global`, `settings_product`, and `settings_vendor`.
2. **Removed MySQL Syntax `/forecast` Endpoint in `analytics.js`**:
- Removed `GET /analytics/forecast` endpoint that was using MySQL-specific syntax incompatible with the PostgreSQL database used elsewhere in the application.
3. **Renamed and Removed Redundant Endpoints**:
- Renamed `csv.js` to `data-management.js` while maintaining the same `/csv/*` endpoint paths for consistency
- Removed deprecated `/csv/update` endpoint (now fully replaced by `/csv/full-update`)
- Removed deprecated `/csv/reset` endpoint (now fully replaced by `/csv/full-reset`)
- Removed deprecated `/products/import` endpoint (now handled by `/csv/import`)
- Removed deprecated `/products/:id/metrics` endpoint (now handled by `/metrics/:pid`)
4. **Fixed Data Integrity Issues**:
- Improved `GET /products/brands` endpoint by removing the arbitrary filtering logic that was only showing brands with purchase orders that had a total cost of at least $500
- The updated endpoint now returns all distinct brands from visible products, providing more complete data
5. **Optimized Database Connections**:
- Created a new `dbConnection.js` utility file that encapsulates the optimized database connection management logic
- Improved the `ai-validation.js` file to use this shared connection management, eliminating unnecessary repeated tunnel creation
- Added proper connection pooling with timeout-based connection reuse, reducing the overhead of repeatedly creating SSH tunnels
- Added query result caching for frequently accessed data to improve performance
These changes improve maintainability by removing duplicate code, enhance consistency by standardizing on the newer endpoint patterns, and optimize performance by reducing redundant database connections.
## Additional Improvements
1. **Further Database Connection Optimizations**:
- Extended the use of the optimized database connection utility to additional endpoints in `ai-validation.js`
- Updated the `/validate` endpoint and `/test-taxonomy` endpoint to use `getDbConnection`
- Ensured consistent connection management across all routes that access the production database
2. **AI Prompts Data Integrity Verification**:
- Confirmed proper uniqueness constraints are in place in the database schema for AI prompts
- The schema includes:
- `unique_company_prompt` constraint ensuring only one prompt per company
- `idx_unique_general_prompt` index ensuring only one general prompt in the system
- `idx_unique_system_prompt` index ensuring only one system prompt in the system
- Endpoint handlers properly handle uniqueness constraint violations with appropriate 409 Conflict responses
- Validation ensures company-specific prompts have company IDs, while general/system prompts do not
3. **AI Prompts Endpoint Consolidation**:
- Added a new consolidated `/by-type` endpoint that handles all types of prompts (general, system, company_specific)
- Marked the existing separate endpoints as deprecated with console warnings
- Maintained backward compatibility while providing a cleaner API moving forward
## Completed Items
✅ Removed obsolete legacy endpoints in `config.js`
✅ Removed MySQL syntax `/forecast` endpoint in `analytics.js`
✅ Fixed `GET /products/brands` endpoint filtering logic
✅ Created reusable database connection utility (`dbConnection.js`)
✅ Optimized database connections in `ai-validation.js`
✅ Verified data integrity in AI prompts handling
✅ Consolidated AI prompts endpoints with a unified `/by-type` endpoint
## Remaining Items
- Consider adding additional error handling and logging for database connections
- Perform load testing on the optimized database connections to ensure they handle high traffic properly
+23
View File
@@ -0,0 +1,23 @@
This portion of the application is going to be a read only chat archive. It will pull data from a rocketchat export converted to postgresql. This is a separate database than the rest of the inventory application uses, but it will still use users and permissions from the inventory database. Both databases are on the same postgres instance.
For now, let's add a select to the top of the page that allows me to "view as" any of the users in the rocketchat database. We'll connect this to the authorization in the main application later.
The db connection info is stored in the .env file in the inventory-server root. It contains these variables
DB_HOST=localhost
DB_USER=rocketchat_user
DB_PASSWORD=password
DB_NAME=rocketchat_converted
DB_PORT=5432
Not all of the information in this database is relevant as it's a direct export from another app with more features. You can use the query tool to examine the structure and data available.
Server-side files should use similar conventions and the same technologies as the inventory-server (inventor-server root) and auth-server (inventory-server/auth). I will provide my current pm2 ecosystem file upon request for you to add the configuration for the new "chat-server". I use Caddy on the server and can provide my caddyfile to assist with configuring the api routes. All configuration and routes for the chat-server should go in the inventory-server/chat folder or subfolders you create.
The folder you see as inventory-server is actually a direct mount of the /var/www/html/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself.
The "Chat" page should be added to the main application sidebar and a similar page to the others should be created in inventory/src/pages. All other frontend pages should go in inventory/src/components/chat.
The application uses shadcn components and those should be used for all ui elements where possible (located in inventory/src/components/ui). The UI should match existing pages and components.
+112
View File
@@ -0,0 +1,112 @@
Okay, I understand completely now. The core issue is that the previous approaches tried too hard to reconcile every receipt back to a specific PO line within the `purchase_orders` table structure, which doesn't reflect the reality where receipts can be independent events. Your downstream scripts, especially `daily_snapshots` and `product_metrics`, rely on having a complete picture of *all* receivings.
Let's pivot to a model that respects both distinct data streams: **Orders (Intent)** and **Receivings (Actuals)**.
**Proposed Solution: Separate `purchase_orders` and `receivings` Tables**
This is the cleanest way to model the reality you've described.
1. **`purchase_orders` Table:**
* **Purpose:** Tracks the status and details of purchase *orders* placed. Represents the *intent* to receive goods.
* **Key Columns:** `po_id`, `pid`, `ordered` (quantity ordered), `po_cost_price`, `date` (order/created date), `expected_date`, `status` (PO lifecycle: 'ordered', 'canceled', 'done'), `vendor`, `notes`, etc.
* **Crucially:** This table *does not* need a `received` column or a `receiving_history` column derived from complex allocations. It focuses solely on the PO itself.
2. **`receivings` Table (New or Refined):**
* **Purpose:** Tracks every single line item received, regardless of whether it was linked to a PO during the receiving process. Represents the *actual* goods that arrived.
* **Key Columns:**
* `receiving_id` (Identifier for the overall receiving document/batch)
* `pid` (Product ID received)
* `received_qty` (Quantity received for this specific line)
* `cost_each` (Actual cost paid for this item on this receiving)
* `received_date` (Actual date the item was received)
* `received_by` (Employee ID/Name)
* `source_po_id` (The `po_id` entered on the receiving screen, *nullable*. Stores the original link attempt, even if it was wrong or missing)
* `source_receiving_status` (The status from the source `receivings` table: 'partial_received', 'full_received', 'paid', 'canceled')
**How the Import Script Changes:**
1. **Fetch POs:** Fetch data from `po` and `po_products`.
2. **Populate `purchase_orders`:**
* Insert/Update rows into `purchase_orders` based directly on the fetched PO data.
* Set `po_id`, `pid`, `ordered`, `po_cost_price`, `date` (`COALESCE(date_ordered, date_created)`), `expected_date`.
* Set `status` by mapping the source `po.status` code directly ('ordered', 'canceled', 'done', etc.).
* **No complex allocation needed here.**
3. **Fetch Receivings:** Fetch data from `receivings` and `receivings_products`.
4. **Populate `receivings`:**
* For *every* line item fetched from `receivings_products`:
* Perform necessary data validation (dates, numbers).
* Insert a new row into `receivings` with all the relevant details (`receiving_id`, `pid`, `received_qty`, `cost_each`, `received_date`, `received_by`, `source_po_id`, `source_receiving_status`).
* Use `ON CONFLICT (receiving_id, pid)` (or similar unique key based on your source data) `DO UPDATE SET ...` for incremental updates if necessary, or simply delete/re-insert based on `receiving_id` for simplicity if performance allows.
**Impact on Downstream Scripts (and how to adapt):**
* **Initial Query (Active POs):**
* `SELECT ... FROM purchase_orders po WHERE po.status NOT IN ('canceled', 'done', 'paid_equivalent_status?') AND po.date >= ...`
* `active_pos`: `COUNT(DISTINCT po.po_id)` based on the filtered POs.
* `overdue_pos`: Add `AND po.expected_date < CURRENT_DATE`.
* `total_units`: `SUM(po.ordered)`. Represents total units *ordered* on active POs.
* `total_cost`: `SUM(po.ordered * po.po_cost_price)`. Cost of units *ordered*.
* `total_retail`: `SUM(po.ordered * pm.current_price)`. Retail value of units *ordered*.
* **Result:** This query now cleanly reports on the status of *orders* placed, which seems closer to its original intent. The filter `po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')` is replaced by `po.status NOT IN ('canceled', 'done', 'paid_equivalent?')`. The 90% received check is removed as `received` is not reliably tracked *on the PO* anymore.
* **`daily_product_snapshots`:**
* **`SalesData` CTE:** No change needed.
* **`ReceivingData` CTE:** **Must be changed.** Query the **`receivings`** table instead of `purchase_orders`.
```sql
ReceivingData AS (
SELECT
rl.pid,
COUNT(DISTINCT rl.receiving_id) as receiving_doc_count,
SUM(rl.received_qty) AS units_received,
SUM(rl.received_qty * rl.cost_each) AS cost_received
FROM public.receivings rl
WHERE rl.received_date::date = _date
-- Optional: Filter out canceled receivings if needed
-- AND rl.source_receiving_status <> 'canceled'
GROUP BY rl.pid
),
```
* **Result:** This now accurately reflects *all* units received on a given day from the definitive source.
* **`update_product_metrics`:**
* **`CurrentInfo` CTE:** No change needed (pulls from `products`).
* **`OnOrderInfo` CTE:** Needs re-evaluation. How do you want to define "On Order"?
* **Option A (Strict PO View):** `SUM(po.ordered)` from `purchase_orders po WHERE po.status NOT IN ('canceled', 'done', 'paid_equivalent?')`. This is quantity on *open orders*, ignoring fulfillment state. Simple, but might overestimate if items arrived unlinked.
* **Option B (Approximate Fulfillment):** `SUM(po.ordered)` from open POs MINUS `SUM(rl.received_qty)` from `receivings rl` where `rl.source_po_id = po.po_id` (summing only directly linked receivings). Better, but still misses fulfillment via unlinked receivings.
* **Option C (Heuristic):** `SUM(po.ordered)` from open POs MINUS `SUM(rl.received_qty)` from `receivings rl` where `rl.pid = po.pid` and `rl.received_date >= po.date`. This *tries* to account for unlinked receivings but is imprecise.
* **Recommendation:** Start with **Option A** for simplicity, clearly labeling it "Quantity on Open POs". You might need a separate process or metric for a more nuanced view of expected vs. actual pipeline.
```sql
-- Example for Option A
OnOrderInfo AS (
SELECT
pid,
SUM(ordered) AS on_order_qty, -- Total qty on open POs
SUM(ordered * po_cost_price) AS on_order_cost -- Cost of qty on open POs
FROM public.purchase_orders
WHERE status NOT IN ('canceled', 'done', 'paid_equivalent?') -- Define your open statuses
GROUP BY pid
),
```
* **`HistoricalDates` CTE:**
* `date_first_sold`, `max_order_date`: No change (queries `orders`).
* `date_first_received_calc`, `date_last_received_calc`: **Must be changed.** Query `MIN(rl.received_date)` and `MAX(rl.received_date)` from the **`receivings`** table grouped by `pid`.
* **`SnapshotAggregates` CTE:**
* `received_qty_30d`, `received_cost_30d`: These are calculated from `daily_product_snapshots`, which are now correctly sourced from `receivings`, so this part is fine.
* **Forecasting Calculations:** Will use the chosen definition of `on_order_qty`. Be aware of the implications of Option A (potentially inflated if unlinked receivings fulfill orders).
* **Result:** Metrics are calculated based on distinct order data and complete receiving data. The definition of "on order" needs careful consideration.
**Summary of this Approach:**
* **Pros:**
* Accurately models distinct order and receiving events.
* Provides a definitive source (`receivings`) for all received inventory.
* Simplifies the `purchase_orders` table and its import logic.
* Avoids complex/potentially inaccurate allocation logic for unlinked receivings within the main tables.
* Avoids synthetic records.
* Fixes downstream reporting (`daily_snapshots` receiving data).
* **Cons:**
* Requires creating/managing the `receivings` table.
* Requires modifying downstream queries (`ReceivingData`, `OnOrderInfo`, `HistoricalDates`).
* Calculating a precise "net quantity still expected to arrive" (true on-order minus all relevant fulfillment) becomes more complex and may require specific business rules or heuristics outside the basic table structure if Option A for `OnOrderInfo` isn't sufficient.
This two-table approach (`purchase_orders` + `receivings`) seems the most robust and accurate way to handle your requirement for complete receiving records independent of potentially flawed PO linking. It directly addresses the shortcomings of the previous attempts.
+1 -1
View File
@@ -35,7 +35,7 @@ global.pool = pool;
app.use(express.json());
app.use(morgan('combined'));
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:5174', 'https://inventory.kent.pw'],
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw'],
credentials: true
}));
@@ -0,0 +1,881 @@
#!/usr/bin/env python3
"""
MongoDB to PostgreSQL Converter for Rocket.Chat
Converts MongoDB BSON export files to PostgreSQL database
Usage:
python3 mongo_to_postgres_converter.py \
--mongo-path db/database/62df06d44234d20001289144 \
--pg-database rocketchat_converted \
--pg-user rocketchat_user \
--pg-password your_password \
--debug
"""
import json
import os
import re
import subprocess
import sys
import struct
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List, Optional
import argparse
import traceback
# Auto-install dependencies if needed
try:
import bson
import psycopg2
except ImportError:
print("Installing required packages...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "pymongo", "psycopg2-binary"])
import bson
import psycopg2
class MongoToPostgresConverter:
def __init__(self, mongo_db_path: str, postgres_config: Dict[str, str], debug_mode: bool = False, debug_collections: List[str] = None):
self.mongo_db_path = Path(mongo_db_path)
self.postgres_config = postgres_config
self.debug_mode = debug_mode
self.debug_collections = debug_collections or []
self.collections = {}
self.schema_info = {}
self.error_log = {}
def log_debug(self, message: str, collection: str = None):
"""Log debug messages if debug mode is enabled and collection is in debug list"""
if self.debug_mode and (not self.debug_collections or collection in self.debug_collections):
print(f"DEBUG: {message}")
def log_error(self, collection: str, error_type: str, details: str):
"""Log detailed error information"""
if collection not in self.error_log:
self.error_log[collection] = []
self.error_log[collection].append({
'type': error_type,
'details': details,
'timestamp': datetime.now().isoformat()
})
def sample_documents(self, collection_name: str, max_samples: int = 3) -> List[Dict]:
"""Sample documents from a collection for debugging"""
if not self.debug_mode or (self.debug_collections and collection_name not in self.debug_collections):
return []
print(f"\n🔍 Sampling documents from {collection_name}:")
bson_file = self.collections[collection_name]['bson_file']
if bson_file.stat().st_size == 0:
print(" Collection is empty")
return []
samples = []
try:
with open(bson_file, 'rb') as f:
sample_count = 0
while sample_count < max_samples:
try:
doc_size = int.from_bytes(f.read(4), byteorder='little')
if doc_size <= 0:
break
f.seek(-4, 1)
doc_bytes = f.read(doc_size)
if len(doc_bytes) != doc_size:
break
doc = bson.decode(doc_bytes)
samples.append(doc)
sample_count += 1
print(f" Sample {sample_count} - Keys: {list(doc.keys())}")
# Show a few key fields with their types and truncated values
for key, value in list(doc.items())[:3]:
value_preview = str(value)[:50] + "..." if len(str(value)) > 50 else str(value)
print(f" {key}: {type(value).__name__} = {value_preview}")
if len(doc) > 3:
print(f" ... and {len(doc) - 3} more fields")
print()
except (bson.InvalidBSON, struct.error, OSError) as e:
self.log_error(collection_name, 'document_parsing', str(e))
break
except Exception as e:
self.log_error(collection_name, 'file_reading', str(e))
print(f" Error reading collection: {e}")
return samples
def discover_collections(self):
"""Discover all BSON files and their metadata"""
print("Discovering MongoDB collections...")
for bson_file in self.mongo_db_path.glob("*.bson"):
collection_name = bson_file.stem
metadata_file = bson_file.with_suffix(".metadata.json")
# Read metadata if available
metadata = {}
if metadata_file.exists():
try:
with open(metadata_file, 'r', encoding='utf-8') as f:
metadata = json.load(f)
except (UnicodeDecodeError, json.JSONDecodeError) as e:
print(f"Warning: Could not read metadata for {collection_name}: {e}")
metadata = {}
# Get file size and document count estimate
file_size = bson_file.stat().st_size
doc_count = self._estimate_document_count(bson_file)
self.collections[collection_name] = {
'bson_file': bson_file,
'metadata': metadata,
'file_size': file_size,
'estimated_docs': doc_count
}
print(f"Found {len(self.collections)} collections")
for name, info in self.collections.items():
print(f" - {name}: {info['file_size']/1024/1024:.1f}MB (~{info['estimated_docs']} docs)")
def _estimate_document_count(self, bson_file: Path) -> int:
"""Estimate document count by reading first few documents"""
if bson_file.stat().st_size == 0:
return 0
try:
with open(bson_file, 'rb') as f:
docs_sampled = 0
bytes_sampled = 0
max_sample_size = min(1024 * 1024, bson_file.stat().st_size) # 1MB or file size
while bytes_sampled < max_sample_size:
try:
doc_size = int.from_bytes(f.read(4), byteorder='little')
if doc_size <= 0 or doc_size > 16 * 1024 * 1024: # MongoDB doc size limit
break
f.seek(-4, 1) # Go back
doc_bytes = f.read(doc_size)
if len(doc_bytes) != doc_size:
break
bson.decode(doc_bytes) # Validate it's a valid BSON document
docs_sampled += 1
bytes_sampled += doc_size
except (bson.InvalidBSON, struct.error, OSError):
break
if docs_sampled > 0 and bytes_sampled > 0:
avg_doc_size = bytes_sampled / docs_sampled
return int(bson_file.stat().st_size / avg_doc_size)
except Exception:
pass
return 0
def analyze_schema(self, collection_name: str, sample_size: int = 100) -> Dict[str, Any]:
"""Analyze collection schema by sampling documents"""
print(f"Analyzing schema for {collection_name}...")
bson_file = self.collections[collection_name]['bson_file']
if bson_file.stat().st_size == 0:
return {}
schema = {}
docs_analyzed = 0
try:
with open(bson_file, 'rb') as f:
while docs_analyzed < sample_size:
try:
doc_size = int.from_bytes(f.read(4), byteorder='little')
if doc_size <= 0:
break
f.seek(-4, 1)
doc_bytes = f.read(doc_size)
if len(doc_bytes) != doc_size:
break
doc = bson.decode(doc_bytes)
self._analyze_document_schema(doc, schema)
docs_analyzed += 1
except (bson.InvalidBSON, struct.error, OSError):
break
except Exception as e:
print(f"Error analyzing {collection_name}: {e}")
self.schema_info[collection_name] = schema
return schema
def _analyze_document_schema(self, doc: Dict[str, Any], schema: Dict[str, Any], prefix: str = ""):
"""Recursively analyze document structure"""
for key, value in doc.items():
full_key = f"{prefix}.{key}" if prefix else key
if full_key not in schema:
schema[full_key] = {
'types': set(),
'null_count': 0,
'total_count': 0,
'is_array': False,
'nested_schema': {}
}
schema[full_key]['total_count'] += 1
if value is None:
schema[full_key]['null_count'] += 1
schema[full_key]['types'].add('null')
elif isinstance(value, dict):
schema[full_key]['types'].add('object')
if 'nested_schema' not in schema[full_key]:
schema[full_key]['nested_schema'] = {}
self._analyze_document_schema(value, schema[full_key]['nested_schema'])
elif isinstance(value, list):
schema[full_key]['types'].add('array')
schema[full_key]['is_array'] = True
if value and isinstance(value[0], dict):
if 'array_item_schema' not in schema[full_key]:
schema[full_key]['array_item_schema'] = {}
for item in value[:5]: # Sample first 5 items
if isinstance(item, dict):
self._analyze_document_schema(item, schema[full_key]['array_item_schema'])
else:
schema[full_key]['types'].add(type(value).__name__)
def generate_postgres_schema(self) -> Dict[str, str]:
"""Generate PostgreSQL CREATE TABLE statements"""
print("Generating PostgreSQL schema...")
table_definitions = {}
for collection_name, schema in self.schema_info.items():
if not schema: # Empty collection
continue
table_name = self._sanitize_table_name(collection_name)
columns = []
# Always add an id column (PostgreSQL doesn't use _id like MongoDB)
columns.append("id SERIAL PRIMARY KEY")
for field_name, field_info in schema.items():
if field_name == '_id':
columns.append("mongo_id TEXT") # Always allow NULL for mongo_id
continue
col_name = self._sanitize_column_name(field_name)
# Handle conflicts with PostgreSQL auto-generated columns
if col_name in ['id', 'mongo_id', 'created_at', 'updated_at']:
col_name = f"field_{col_name}"
col_type = self._determine_postgres_type(field_info)
# Make all fields nullable by default to avoid constraint violations
columns.append(f"{col_name} {col_type}")
# Add metadata columns
columns.extend([
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP",
"updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
])
column_definitions = ',\n '.join(columns)
table_sql = f"""
CREATE TABLE IF NOT EXISTS {table_name} (
{column_definitions}
);
-- Create indexes based on MongoDB indexes
"""
# Get list of actual columns that will exist in the table
existing_columns = set(['id', 'mongo_id', 'created_at', 'updated_at'])
for field_name in schema.keys():
if field_name != '_id':
col_name = self._sanitize_column_name(field_name)
# Handle conflicts with PostgreSQL auto-generated columns
if col_name in ['id', 'mongo_id', 'created_at', 'updated_at']:
col_name = f"field_{col_name}"
existing_columns.add(col_name)
# Add indexes from MongoDB metadata
metadata = self.collections[collection_name].get('metadata', {})
indexes = metadata.get('indexes', [])
for index in indexes:
if index['name'] != '_id_': # Skip the default _id index
# Sanitize index name - remove special characters
sanitized_index_name = re.sub(r'[^a-zA-Z0-9_]', '_', index['name'])
index_name = f"idx_{table_name}_{sanitized_index_name}"
index_keys = list(index['key'].keys())
if index_keys:
sanitized_keys = []
for key in index_keys:
if key != '_id':
sanitized_key = self._sanitize_column_name(key)
# Handle conflicts with PostgreSQL auto-generated columns
if sanitized_key in ['id', 'mongo_id', 'created_at', 'updated_at']:
sanitized_key = f"field_{sanitized_key}"
# Only add if the column actually exists in our table
if sanitized_key in existing_columns:
sanitized_keys.append(sanitized_key)
if sanitized_keys:
table_sql += f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name} ({', '.join(sanitized_keys)});\n"
table_definitions[collection_name] = table_sql
return table_definitions
def _sanitize_table_name(self, name: str) -> str:
"""Convert MongoDB collection name to PostgreSQL table name"""
# Remove rocketchat_ prefix if present
if name.startswith('rocketchat_'):
name = name[11:]
# Replace special characters with underscores
name = re.sub(r'[^a-zA-Z0-9_]', '_', name)
# Ensure it starts with a letter
if name and name[0].isdigit():
name = 'table_' + name
return name.lower()
def _sanitize_column_name(self, name: str) -> str:
"""Convert MongoDB field name to PostgreSQL column name"""
# Handle nested field names (convert dots to underscores)
name = name.replace('.', '_')
# Replace special characters with underscores
name = re.sub(r'[^a-zA-Z0-9_]', '_', name)
# Ensure it starts with a letter or underscore
if name and name[0].isdigit():
name = 'col_' + name
# Handle PostgreSQL reserved words
reserved = {
'user', 'order', 'group', 'table', 'index', 'key', 'value', 'date', 'time', 'timestamp',
'default', 'select', 'from', 'where', 'insert', 'update', 'delete', 'create', 'drop',
'alter', 'grant', 'revoke', 'commit', 'rollback', 'begin', 'end', 'case', 'when',
'then', 'else', 'if', 'null', 'not', 'and', 'or', 'in', 'exists', 'between',
'like', 'limit', 'offset', 'union', 'join', 'inner', 'outer', 'left', 'right',
'full', 'cross', 'natural', 'on', 'using', 'distinct', 'all', 'any', 'some',
'desc', 'asc', 'primary', 'foreign', 'references', 'constraint', 'unique',
'check', 'cascade', 'restrict', 'action', 'match', 'partial', 'full'
}
if name.lower() in reserved:
name = name + '_col'
return name.lower()
def _determine_postgres_type(self, field_info: Dict[str, Any]) -> str:
"""Determine PostgreSQL column type from MongoDB field analysis with improved logic"""
types = field_info['types']
# Convert set to list for easier checking
type_list = list(types)
# If there's only one type (excluding null), use specific typing
non_null_types = [t for t in type_list if t != 'null']
if len(non_null_types) == 1:
single_type = non_null_types[0]
if single_type == 'bool':
return 'BOOLEAN'
elif single_type == 'int':
return 'INTEGER'
elif single_type == 'float':
return 'NUMERIC'
elif single_type == 'str':
return 'TEXT'
elif single_type == 'datetime':
return 'TIMESTAMP'
elif single_type == 'ObjectId':
return 'TEXT'
# Handle mixed types more conservatively
if 'array' in types or field_info.get('is_array', False):
return 'JSONB' # Arrays always go to JSONB
elif 'object' in types:
return 'JSONB' # Objects always go to JSONB
elif len(non_null_types) > 1:
# Multiple non-null types - check for common combinations
if set(non_null_types) <= {'int', 'float'}:
return 'NUMERIC' # Can handle both int and float
elif set(non_null_types) <= {'bool', 'str'}:
return 'TEXT' # Convert everything to text
elif set(non_null_types) <= {'str', 'ObjectId'}:
return 'TEXT' # Both are string-like
else:
return 'JSONB' # Complex mixed types go to JSONB
elif 'ObjectId' in types:
return 'TEXT'
elif 'datetime' in types:
return 'TIMESTAMP'
elif 'bool' in types:
return 'BOOLEAN'
elif 'int' in types:
return 'INTEGER'
elif 'float' in types:
return 'NUMERIC'
elif 'str' in types:
return 'TEXT'
else:
return 'TEXT' # Default fallback
def create_postgres_database(self, table_definitions: Dict[str, str]):
"""Create PostgreSQL database and tables"""
print("Creating PostgreSQL database schema...")
try:
# Connect to PostgreSQL
conn = psycopg2.connect(**self.postgres_config)
conn.autocommit = True
cursor = conn.cursor()
# Create tables
for collection_name, table_sql in table_definitions.items():
print(f"Creating table for {collection_name}...")
cursor.execute(table_sql)
cursor.close()
conn.close()
print("Database schema created successfully!")
except Exception as e:
print(f"Error creating database schema: {e}")
raise
def convert_and_insert_data(self, batch_size: int = 1000):
"""Convert BSON data and insert into PostgreSQL"""
print("Converting and inserting data...")
try:
conn = psycopg2.connect(**self.postgres_config)
conn.autocommit = False
for collection_name in self.collections:
print(f"Processing {collection_name}...")
self._convert_collection(conn, collection_name, batch_size)
conn.close()
print("Data conversion completed successfully!")
except Exception as e:
print(f"Error converting data: {e}")
raise
def _convert_collection(self, conn, collection_name: str, batch_size: int):
"""Convert a single collection"""
bson_file = self.collections[collection_name]['bson_file']
if bson_file.stat().st_size == 0:
print(f" Skipping empty collection {collection_name}")
return
table_name = self._sanitize_table_name(collection_name)
cursor = conn.cursor()
batch = []
total_inserted = 0
errors = 0
try:
with open(bson_file, 'rb') as f:
while True:
try:
doc_size = int.from_bytes(f.read(4), byteorder='little')
if doc_size <= 0:
break
f.seek(-4, 1)
doc_bytes = f.read(doc_size)
if len(doc_bytes) != doc_size:
break
doc = bson.decode(doc_bytes)
batch.append(doc)
if len(batch) >= batch_size:
inserted, batch_errors = self._insert_batch(cursor, table_name, batch, collection_name)
total_inserted += inserted
errors += batch_errors
batch = []
conn.commit()
if total_inserted % 5000 == 0: # Less frequent progress updates
print(f" Inserted {total_inserted} documents...")
except (bson.InvalidBSON, struct.error, OSError):
break
# Insert remaining documents
if batch:
inserted, batch_errors = self._insert_batch(cursor, table_name, batch, collection_name)
total_inserted += inserted
errors += batch_errors
conn.commit()
if errors > 0:
print(f" Completed {collection_name}: {total_inserted} documents inserted ({errors} errors)")
else:
print(f" Completed {collection_name}: {total_inserted} documents inserted")
except Exception as e:
print(f" Error processing {collection_name}: {e}")
conn.rollback()
finally:
cursor.close()
def _insert_batch(self, cursor, table_name: str, documents: List[Dict], collection_name: str):
"""Insert a batch of documents with proper transaction handling"""
if not documents:
return 0, 0
# Get schema info for this collection
schema = self.schema_info.get(collection_name, {})
# Build column list
columns = ['mongo_id']
for field_name in schema.keys():
if field_name != '_id':
col_name = self._sanitize_column_name(field_name)
# Handle conflicts with PostgreSQL auto-generated columns
if col_name in ['id', 'mongo_id', 'created_at', 'updated_at']:
col_name = f"field_{col_name}"
columns.append(col_name)
# Build INSERT statement
placeholders = ', '.join(['%s'] * len(columns))
sql = f"INSERT INTO {table_name} ({', '.join(columns)}) VALUES ({placeholders})"
self.log_debug(f"SQL: {sql}", collection_name)
# Convert documents to tuples
rows = []
errors = 0
for doc_idx, doc in enumerate(documents):
try:
row = []
# Add mongo_id
row.append(str(doc.get('_id', '')))
# Add other fields
for field_name in schema.keys():
if field_name != '_id':
try:
value = self._get_nested_value(doc, field_name)
converted_value = self._convert_value_for_postgres(value, field_name, schema)
row.append(converted_value)
except Exception as e:
self.log_error(collection_name, 'field_conversion',
f"Field '{field_name}' in doc {doc_idx}: {str(e)}")
# Only show debug for collections we're focusing on
if collection_name in self.debug_collections:
print(f" ⚠️ Error converting field '{field_name}': {e}")
row.append(None) # Use NULL for problematic fields
rows.append(tuple(row))
except Exception as e:
self.log_error(collection_name, 'document_conversion', f"Document {doc_idx}: {str(e)}")
errors += 1
continue
# Execute batch insert
if rows:
try:
cursor.executemany(sql, rows)
return len(rows), errors
except Exception as batch_error:
self.log_error(collection_name, 'batch_insert', str(batch_error))
# Only show detailed debugging for targeted collections
if collection_name in self.debug_collections:
print(f" 🔴 Batch insert failed for {collection_name}: {batch_error}")
print(" Trying individual inserts with rollback handling...")
# Rollback the failed transaction
cursor.connection.rollback()
# Try inserting one by one in individual transactions
success_count = 0
for row_idx, row in enumerate(rows):
try:
cursor.execute(sql, row)
cursor.connection.commit() # Commit each successful insert
success_count += 1
except Exception as row_error:
cursor.connection.rollback() # Rollback failed insert
self.log_error(collection_name, 'row_insert', f"Row {row_idx}: {str(row_error)}")
# Show detailed error only for the first few failures and only for targeted collections
if collection_name in self.debug_collections and errors < 3:
print(f" Row {row_idx} failed: {row_error}")
print(f" Row data: {len(row)} values, expected {len(columns)} columns")
errors += 1
continue
return success_count, errors
return 0, errors
def _get_nested_value(self, doc: Dict, field_path: str):
"""Get value from nested document using dot notation"""
keys = field_path.split('.')
value = doc
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return None
return value
def _convert_value_for_postgres(self, value, field_name: str = None, schema: Dict = None):
"""Convert MongoDB value to PostgreSQL compatible value with schema-aware conversion"""
if value is None:
return None
# Get the expected PostgreSQL type for this field if available
expected_type = None
if schema and field_name and field_name in schema:
field_info = schema[field_name]
expected_type = self._determine_postgres_type(field_info)
# Handle conversion based on expected type
if expected_type == 'BOOLEAN':
if isinstance(value, bool):
return value
elif isinstance(value, str):
return value.lower() in ('true', '1', 'yes', 'on')
elif isinstance(value, (int, float)):
return bool(value)
else:
return None
elif expected_type == 'INTEGER':
if isinstance(value, int):
return value
elif isinstance(value, float):
return int(value)
elif isinstance(value, str) and value.isdigit():
return int(value)
elif isinstance(value, bool):
return int(value)
else:
return None
elif expected_type == 'NUMERIC':
if isinstance(value, (int, float)):
return value
elif isinstance(value, str):
try:
return float(value)
except ValueError:
return None
elif isinstance(value, bool):
return float(value)
else:
return None
elif expected_type == 'TEXT':
if isinstance(value, str):
return value
elif value is not None:
str_value = str(value)
# Handle very long strings
if len(str_value) > 65535:
return str_value[:65535]
return str_value
else:
return None
elif expected_type == 'TIMESTAMP':
if hasattr(value, 'isoformat'):
return value.isoformat()
elif isinstance(value, str):
return value
else:
return str(value) if value is not None else None
elif expected_type == 'JSONB':
if isinstance(value, (dict, list)):
return json.dumps(value, default=self._json_serializer)
elif isinstance(value, str):
# Check if it's already valid JSON
try:
json.loads(value)
return value
except (json.JSONDecodeError, TypeError):
# Not valid JSON, wrap it
return json.dumps(value)
else:
return json.dumps(value, default=self._json_serializer)
# Fallback to original logic if no expected type or type not recognized
if isinstance(value, bool):
return value
elif isinstance(value, (int, float)):
return value
elif isinstance(value, str):
return value
elif isinstance(value, (dict, list)):
return json.dumps(value, default=self._json_serializer)
elif hasattr(value, 'isoformat'): # datetime
return value.isoformat()
elif hasattr(value, '__str__'):
str_value = str(value)
if len(str_value) > 65535:
return str_value[:65535]
return str_value
else:
return str(value)
def _json_serializer(self, obj):
"""Custom JSON serializer for complex objects with better error handling"""
try:
if hasattr(obj, 'isoformat'): # datetime
return obj.isoformat()
elif hasattr(obj, '__str__'):
return str(obj)
else:
return None
except Exception as e:
self.log_debug(f"JSON serialization error: {e}")
return str(obj)
def run_conversion(self, sample_size: int = 100, batch_size: int = 1000):
"""Run the full conversion process with focused debugging"""
print("Starting MongoDB to PostgreSQL conversion...")
print("This will convert your Rocket.Chat database from MongoDB to PostgreSQL")
if self.debug_mode:
if self.debug_collections:
print(f"🐛 DEBUG MODE: Focusing on collections: {', '.join(self.debug_collections)}")
else:
print("🐛 DEBUG MODE: All collections")
print("=" * 70)
# Step 1: Discover collections
self.discover_collections()
# Step 2: Analyze schemas
print("\nAnalyzing collection schemas...")
for collection_name in self.collections:
self.analyze_schema(collection_name, sample_size)
# Sample problematic collections if debugging
if self.debug_mode and self.debug_collections:
for coll in self.debug_collections:
if coll in self.collections:
self.sample_documents(coll, 2)
# Step 3: Generate PostgreSQL schema
table_definitions = self.generate_postgres_schema()
# Step 4: Create database schema
self.create_postgres_database(table_definitions)
# Step 5: Convert and insert data
self.convert_and_insert_data(batch_size)
# Step 6: Show error summary
self._print_error_summary()
print("=" * 70)
print("✅ Conversion completed!")
print(f" Database: {self.postgres_config['database']}")
print(f" Tables created: {len(table_definitions)}")
def _print_error_summary(self):
"""Print a focused summary of errors"""
if not self.error_log:
print("\n✅ No errors encountered during conversion!")
return
print("\n⚠️ ERROR SUMMARY:")
print("=" * 50)
# Sort by error count descending
sorted_collections = sorted(self.error_log.items(),
key=lambda x: len(x[1]), reverse=True)
for collection, errors in sorted_collections:
error_types = {}
for error in errors:
error_type = error['type']
if error_type not in error_types:
error_types[error_type] = []
error_types[error_type].append(error['details'])
print(f"\n🔴 {collection} ({len(errors)} total errors):")
for error_type, details_list in error_types.items():
print(f" {error_type}: {len(details_list)} errors")
# Show sample errors for critical collections
if collection in ['rocketchat_settings', 'rocketchat_room'] and len(details_list) > 0:
print(f" Sample: {details_list[0][:100]}...")
def main():
parser = argparse.ArgumentParser(
description='Convert MongoDB BSON export to PostgreSQL',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic usage
python3 mongo_to_postgres_converter.py \\
--mongo-path db/database/62df06d44234d20001289144 \\
--pg-database rocketchat_converted \\
--pg-user rocketchat_user \\
--pg-password mypassword
# Debug specific failing collections
python3 mongo_to_postgres_converter.py \\
--mongo-path db/database/62df06d44234d20001289144 \\
--pg-database rocketchat_converted \\
--pg-user rocketchat_user \\
--pg-password mypassword \\
--debug-collections rocketchat_settings rocketchat_room
Before running this script:
1. Run: sudo -u postgres psql -f reset_database.sql
2. Update the password in reset_database.sql
"""
)
parser.add_argument('--mongo-path', required=True, help='Path to MongoDB export directory')
parser.add_argument('--pg-host', default='localhost', help='PostgreSQL host (default: localhost)')
parser.add_argument('--pg-port', default='5432', help='PostgreSQL port (default: 5432)')
parser.add_argument('--pg-database', required=True, help='PostgreSQL database name')
parser.add_argument('--pg-user', required=True, help='PostgreSQL username')
parser.add_argument('--pg-password', required=True, help='PostgreSQL password')
parser.add_argument('--sample-size', type=int, default=100, help='Number of documents to sample for schema analysis (default: 100)')
parser.add_argument('--batch-size', type=int, default=1000, help='Batch size for data insertion (default: 1000)')
parser.add_argument('--debug', action='store_true', help='Enable debug mode with detailed error logging')
parser.add_argument('--debug-collections', nargs='*', help='Specific collections to debug (e.g., rocketchat_settings rocketchat_room)')
args = parser.parse_args()
postgres_config = {
'host': args.pg_host,
'port': args.pg_port,
'database': args.pg_database,
'user': args.pg_user,
'password': args.pg_password
}
# Enable debug mode if debug collections are specified
debug_mode = args.debug or (args.debug_collections is not None)
converter = MongoToPostgresConverter(args.mongo_path, postgres_config, debug_mode, args.debug_collections)
converter.run_conversion(args.sample_size, args.batch_size)
if __name__ == '__main__':
main()
@@ -0,0 +1,41 @@
-- PostgreSQL Database Reset Script for Rocket.Chat Import
-- Run as: sudo -u postgres psql -f reset_database.sql
-- Terminate all connections to the database (force disconnect users)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'rocketchat_converted' AND pid <> pg_backend_pid();
-- Drop the database if it exists
DROP DATABASE IF EXISTS rocketchat_converted;
-- Create fresh database
CREATE DATABASE rocketchat_converted;
-- Create user (if not exists)
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'rocketchat_user') THEN
CREATE USER rocketchat_user WITH PASSWORD 'HKjLgt23gWuPXzEAn3rW';
END IF;
END $$;
-- Grant database privileges
GRANT CONNECT ON DATABASE rocketchat_converted TO rocketchat_user;
GRANT CREATE ON DATABASE rocketchat_converted TO rocketchat_user;
-- Connect to the new database
\c rocketchat_converted;
-- Grant schema privileges
GRANT CREATE ON SCHEMA public TO rocketchat_user;
GRANT USAGE ON SCHEMA public TO rocketchat_user;
-- Grant privileges on all future tables and sequences
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rocketchat_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO rocketchat_user;
-- Display success message
\echo 'Database reset completed successfully!'
\echo 'You can now run the converter with:'
\echo 'python3 mongo_to_postgres_converter.py --mongo-path db/database/62df06d44234d20001289144 --pg-database rocketchat_converted --pg-user rocketchat_user --pg-password your_password'
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Quick test script to verify the converter fixes work for problematic collections
"""
from mongo_to_postgres_converter import MongoToPostgresConverter
def test_problematic_collections():
print("🧪 Testing converter fixes for problematic collections...")
postgres_config = {
'host': 'localhost',
'port': '5432',
'database': 'rocketchat_test',
'user': 'rocketchat_user',
'password': 'password123'
}
converter = MongoToPostgresConverter(
'db/database/62df06d44234d20001289144',
postgres_config,
debug_mode=True,
debug_collections=['rocketchat_settings', 'rocketchat_room']
)
# Test just discovery and schema analysis
print("\n1. Testing collection discovery...")
converter.discover_collections()
print("\n2. Testing schema analysis...")
if 'rocketchat_settings' in converter.collections:
settings_schema = converter.analyze_schema('rocketchat_settings', 10)
print(f"Settings schema fields: {len(settings_schema)}")
# Check specific problematic fields
if 'packageValue' in settings_schema:
packagevalue_info = settings_schema['packageValue']
pg_type = converter._determine_postgres_type(packagevalue_info)
print(f"packageValue types: {packagevalue_info['types']} -> PostgreSQL: {pg_type}")
if 'rocketchat_room' in converter.collections:
room_schema = converter.analyze_schema('rocketchat_room', 10)
print(f"Room schema fields: {len(room_schema)}")
# Check specific problematic fields
if 'sysMes' in room_schema:
sysmes_info = room_schema['sysMes']
pg_type = converter._determine_postgres_type(sysmes_info)
print(f"sysMes types: {sysmes_info['types']} -> PostgreSQL: {pg_type}")
print("\n✅ Test completed - check the type mappings above!")
if __name__ == '__main__':
test_problematic_collections()
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "chat-server",
"version": "1.0.0",
"description": "Chat archive server for Rocket.Chat data",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"pg": "^8.11.0",
"dotenv": "^16.0.3",
"morgan": "^1.10.0"
},
"devDependencies": {
"nodemon": "^2.0.22"
}
}
+649
View File
@@ -0,0 +1,649 @@
const express = require('express');
const path = require('path');
const router = express.Router();
// Serve uploaded files with proper mapping from database paths to actual file locations
router.get('/files/uploads/*', async (req, res) => {
try {
// Extract the path from the URL (everything after /files/uploads/)
const requestPath = req.params[0];
// The URL path will be like: ufs/AmazonS3:Uploads/274Mf9CyHNG72oF86/filename.jpg
// We need to extract the mongo_id (274Mf9CyHNG72oF86) from this path
const pathParts = requestPath.split('/');
let mongoId = null;
// Find the mongo_id in the path structure
for (let i = 0; i < pathParts.length; i++) {
if (pathParts[i].includes('AmazonS3:Uploads') && i + 1 < pathParts.length) {
mongoId = pathParts[i + 1];
break;
}
// Sometimes the mongo_id might be the last part of ufs/AmazonS3:Uploads/mongoId
if (pathParts[i] === 'AmazonS3:Uploads' && i + 1 < pathParts.length) {
mongoId = pathParts[i + 1];
break;
}
}
if (!mongoId) {
// Try to get mongo_id from database by matching the full path
const result = await global.pool.query(`
SELECT mongo_id, name, type
FROM uploads
WHERE path = $1 OR url = $1
LIMIT 1
`, [`/ufs/AmazonS3:Uploads/${requestPath}`, `/ufs/AmazonS3:Uploads/${requestPath}`]);
if (result.rows.length > 0) {
mongoId = result.rows[0].mongo_id;
}
}
if (!mongoId) {
return res.status(404).json({ error: 'File not found' });
}
// The actual file is stored with just the mongo_id as filename
const filePath = path.join(__dirname, 'db-convert/db/files/uploads', mongoId);
// Get file info from database for proper content-type
const fileInfo = await global.pool.query(`
SELECT name, type
FROM uploads
WHERE mongo_id = $1
LIMIT 1
`, [mongoId]);
if (fileInfo.rows.length === 0) {
return res.status(404).json({ error: 'File metadata not found' });
}
const { name, type } = fileInfo.rows[0];
// Set proper content type
if (type) {
res.set('Content-Type', type);
}
// Set content disposition with original filename
if (name) {
res.set('Content-Disposition', `inline; filename="${name}"`);
}
// Send the file
res.sendFile(filePath, (err) => {
if (err) {
console.error('Error serving file:', err);
if (!res.headersSent) {
res.status(404).json({ error: 'File not found on disk' });
}
}
});
} catch (error) {
console.error('Error serving upload:', error);
res.status(500).json({ error: 'Server error' });
}
});
// Also serve files directly by mongo_id for simpler access
router.get('/files/by-id/:mongoId', async (req, res) => {
try {
const { mongoId } = req.params;
// Get file info from database
const fileInfo = await global.pool.query(`
SELECT name, type
FROM uploads
WHERE mongo_id = $1
LIMIT 1
`, [mongoId]);
if (fileInfo.rows.length === 0) {
return res.status(404).json({ error: 'File not found' });
}
const { name, type } = fileInfo.rows[0];
const filePath = path.join(__dirname, 'db-convert/db/files/uploads', mongoId);
// Set proper content type and filename
if (type) {
res.set('Content-Type', type);
}
if (name) {
res.set('Content-Disposition', `inline; filename="${name}"`);
}
// Send the file
res.sendFile(filePath, (err) => {
if (err) {
console.error('Error serving file:', err);
if (!res.headersSent) {
res.status(404).json({ error: 'File not found on disk' });
}
}
});
} catch (error) {
console.error('Error serving upload by ID:', error);
res.status(500).json({ error: 'Server error' });
}
});
// Serve user avatars by mongo_id
router.get('/avatar/:mongoId', async (req, res) => {
try {
const { mongoId } = req.params;
console.log(`[Avatar Debug] Looking up avatar for user mongo_id: ${mongoId}`);
// First try to find avatar by user's avataretag
const userResult = await global.pool.query(`
SELECT avataretag, username FROM users WHERE mongo_id = $1
`, [mongoId]);
let avatarPath = null;
if (userResult.rows.length > 0) {
const username = userResult.rows[0].username;
const avataretag = userResult.rows[0].avataretag;
// Try method 1: Look up by avataretag -> etag (for users with avataretag set)
if (avataretag) {
console.log(`[Avatar Debug] Found user ${username} with avataretag: ${avataretag}`);
const avatarResult = await global.pool.query(`
SELECT url, path FROM avatars WHERE etag = $1
`, [avataretag]);
if (avatarResult.rows.length > 0) {
const dbPath = avatarResult.rows[0].path || avatarResult.rows[0].url;
console.log(`[Avatar Debug] Found avatar record with path: ${dbPath}`);
if (dbPath) {
const pathParts = dbPath.split('/');
for (let i = 0; i < pathParts.length; i++) {
if (pathParts[i].includes('AmazonS3:Avatars') && i + 1 < pathParts.length) {
const avatarMongoId = pathParts[i + 1];
avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', avatarMongoId);
console.log(`[Avatar Debug] Extracted avatar mongo_id: ${avatarMongoId}, full path: ${avatarPath}`);
break;
}
}
}
} else {
console.log(`[Avatar Debug] No avatar record found for etag: ${avataretag}`);
}
}
// Try method 2: Look up by userid directly (for users without avataretag)
if (!avatarPath) {
console.log(`[Avatar Debug] Trying direct userid lookup for user ${username} (${mongoId})`);
const avatarResult = await global.pool.query(`
SELECT url, path FROM avatars WHERE userid = $1
`, [mongoId]);
if (avatarResult.rows.length > 0) {
const dbPath = avatarResult.rows[0].path || avatarResult.rows[0].url;
console.log(`[Avatar Debug] Found avatar record by userid with path: ${dbPath}`);
if (dbPath) {
const pathParts = dbPath.split('/');
for (let i = 0; i < pathParts.length; i++) {
if (pathParts[i].includes('AmazonS3:Avatars') && i + 1 < pathParts.length) {
const avatarMongoId = pathParts[i + 1];
avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', avatarMongoId);
console.log(`[Avatar Debug] Extracted avatar mongo_id: ${avatarMongoId}, full path: ${avatarPath}`);
break;
}
}
}
} else {
console.log(`[Avatar Debug] No avatar record found for userid: ${mongoId}`);
}
}
} else {
console.log(`[Avatar Debug] No user found for mongo_id: ${mongoId}`);
}
// Fallback: try direct lookup by user mongo_id
if (!avatarPath) {
avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', mongoId);
console.log(`[Avatar Debug] Using fallback path: ${avatarPath}`);
}
// Set proper content type for images
res.set('Content-Type', 'image/jpeg'); // Most avatars are likely JPEG
// Send the file
res.sendFile(avatarPath, (err) => {
if (err) {
// If avatar doesn't exist, send a default 404 or generate initials
console.log(`[Avatar Debug] Avatar file not found at path: ${avatarPath}, error:`, err.message);
if (!res.headersSent) {
res.status(404).json({ error: 'Avatar not found' });
}
} else {
console.log(`[Avatar Debug] Successfully served avatar from: ${avatarPath}`);
}
});
} catch (error) {
console.error('Error serving avatar:', error);
res.status(500).json({ error: 'Server error' });
}
});
// Serve avatars statically as fallback
router.use('/files/avatars', express.static(path.join(__dirname, 'db-convert/db/files/avatars')));
// Get all users for the "view as" dropdown (active and inactive)
router.get('/users', async (req, res) => {
try {
const result = await global.pool.query(`
SELECT id, username, name, type, active, status, lastlogin,
statustext, utcoffset, statusconnection, mongo_id, avataretag
FROM users
WHERE type = 'user'
ORDER BY
active DESC, -- Active users first
CASE
WHEN status = 'online' THEN 1
WHEN status = 'away' THEN 2
WHEN status = 'busy' THEN 3
ELSE 4
END,
name ASC
`);
res.json({
status: 'success',
users: result.rows
});
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({
status: 'error',
error: 'Failed to fetch users',
details: error.message
});
}
});
// Get rooms for a specific user with enhanced room names for direct messages
router.get('/users/:userId/rooms', async (req, res) => {
const { userId } = req.params;
try {
// Get the current user's mongo_id for filtering
const userResult = await global.pool.query(`
SELECT mongo_id, username FROM users WHERE id = $1
`, [userId]);
if (userResult.rows.length === 0) {
return res.status(404).json({
status: 'error',
error: 'User not found'
});
}
const currentUserMongoId = userResult.rows[0].mongo_id;
const currentUsername = userResult.rows[0].username;
// Get rooms where the user is a member with proper naming from subscription table
// Include archived and closed rooms but sort them at the bottom
const result = await global.pool.query(`
SELECT DISTINCT
r.id,
r.mongo_id as room_mongo_id,
r.name,
r.fname,
r.t as type,
r.msgs,
r.lm as last_message_date,
r.usernames,
r.uids,
r.userscount,
r.description,
r.teamid,
r.archived,
s.open,
-- Use the subscription's name for direct messages (excludes current user)
-- For channels/groups, use room's fname or name
CASE
WHEN r.t = 'd' THEN COALESCE(s.fname, s.name, 'Unknown User')
ELSE COALESCE(r.fname, r.name, 'Unnamed Room')
END as display_name
FROM room r
JOIN subscription s ON s.rid = r.mongo_id
WHERE s.u->>'_id' = $1
ORDER BY
s.open DESC NULLS LAST, -- Open rooms first
r.archived NULLS FIRST, -- Non-archived first (nulls treated as false)
r.lm DESC NULLS LAST
LIMIT 50
`, [currentUserMongoId]);
// Enhance rooms with participant information for direct messages
const enhancedRooms = await Promise.all(result.rows.map(async (room) => {
if (room.type === 'd' && room.uids) {
// Get participant info (excluding current user) for direct messages
const participantResult = await global.pool.query(`
SELECT u.username, u.name, u.mongo_id, u.avataretag
FROM users u
WHERE u.mongo_id = ANY($1::text[])
AND u.mongo_id != $2
`, [room.uids, currentUserMongoId]);
room.participants = participantResult.rows;
}
return room;
}));
res.json({
status: 'success',
rooms: enhancedRooms
});
} catch (error) {
console.error('Error fetching user rooms:', error);
res.status(500).json({
status: 'error',
error: 'Failed to fetch user rooms',
details: error.message
});
}
});
// Get room details including participants
router.get('/rooms/:roomId', async (req, res) => {
const { roomId } = req.params;
const { userId } = req.query; // Accept current user ID as query parameter
try {
const result = await global.pool.query(`
SELECT r.id, r.name, r.fname, r.t as type, r.msgs, r.description,
r.lm as last_message_date, r.usernames, r.uids, r.userscount, r.teamid
FROM room r
WHERE r.id = $1
`, [roomId]);
if (result.rows.length === 0) {
return res.status(404).json({
status: 'error',
error: 'Room not found'
});
}
const room = result.rows[0];
// For direct messages, get the proper display name based on current user
if (room.type === 'd' && room.uids && userId) {
// Get current user's mongo_id
const userResult = await global.pool.query(`
SELECT mongo_id FROM users WHERE id = $1
`, [userId]);
if (userResult.rows.length > 0) {
const currentUserMongoId = userResult.rows[0].mongo_id;
// Get display name from subscription table for this user
// Use room mongo_id to match with subscription.rid
const roomMongoResult = await global.pool.query(`
SELECT mongo_id FROM room WHERE id = $1
`, [roomId]);
if (roomMongoResult.rows.length > 0) {
const roomMongoId = roomMongoResult.rows[0].mongo_id;
const subscriptionResult = await global.pool.query(`
SELECT fname, name FROM subscription
WHERE rid = $1 AND u->>'_id' = $2
`, [roomMongoId, currentUserMongoId]);
if (subscriptionResult.rows.length > 0) {
const sub = subscriptionResult.rows[0];
room.display_name = sub.fname || sub.name || 'Unknown User';
}
}
}
// Get all participants for additional info
const participantResult = await global.pool.query(`
SELECT username, name
FROM users
WHERE mongo_id = ANY($1::text[])
`, [room.uids]);
room.participants = participantResult.rows;
} else {
// For channels/groups, use room's fname or name
room.display_name = room.fname || room.name || 'Unnamed Room';
}
res.json({
status: 'success',
room: room
});
} catch (error) {
console.error('Error fetching room details:', error);
res.status(500).json({
status: 'error',
error: 'Failed to fetch room details',
details: error.message
});
}
});
// Get messages for a specific room (fast, without attachments)
router.get('/rooms/:roomId/messages', async (req, res) => {
const { roomId } = req.params;
const { limit = 50, offset = 0, before } = req.query;
try {
// Fast query - just get messages without expensive attachment joins
let query = `
SELECT m.id, m.msg, m.ts, m.u, m._updatedat, m.urls, m.mentions, m.md
FROM message m
JOIN room r ON m.rid = r.mongo_id
WHERE r.id = $1
`;
const params = [roomId];
if (before) {
query += ` AND m.ts < $${params.length + 1}`;
params.push(before);
}
query += ` ORDER BY m.ts DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
params.push(limit, offset);
const result = await global.pool.query(query, params);
// Add empty attachments array for now - attachments will be loaded separately if needed
const messages = result.rows.map(msg => ({
...msg,
attachments: []
}));
res.json({
status: 'success',
messages: messages.reverse() // Reverse to show oldest first
});
} catch (error) {
console.error('Error fetching messages:', error);
res.status(500).json({
status: 'error',
error: 'Failed to fetch messages',
details: error.message
});
}
});
// Get attachments for specific messages (called separately for performance)
router.post('/messages/attachments', async (req, res) => {
const { messageIds } = req.body;
if (!messageIds || !Array.isArray(messageIds) || messageIds.length === 0) {
return res.json({ status: 'success', attachments: {} });
}
try {
// Get room mongo_id from first message to limit search scope
const roomQuery = await global.pool.query(`
SELECT r.mongo_id as room_mongo_id
FROM message m
JOIN room r ON m.rid = r.mongo_id
WHERE m.id = $1
LIMIT 1
`, [messageIds[0]]);
if (roomQuery.rows.length === 0) {
return res.json({ status: 'success', attachments: {} });
}
const roomMongoId = roomQuery.rows[0].room_mongo_id;
// Get messages and their upload timestamps
const messagesQuery = await global.pool.query(`
SELECT m.id, m.ts, m.u->>'_id' as user_id
FROM message m
WHERE m.id = ANY($1::int[])
`, [messageIds]);
if (messagesQuery.rows.length === 0) {
return res.json({ status: 'success', attachments: {} });
}
// Build a map of user_id -> array of message timestamps for efficient lookup
const userTimeMap = {};
const messageMap = {};
messagesQuery.rows.forEach(msg => {
if (!userTimeMap[msg.user_id]) {
userTimeMap[msg.user_id] = [];
}
userTimeMap[msg.user_id].push(msg.ts);
messageMap[msg.id] = { ts: msg.ts, user_id: msg.user_id };
});
// Get attachments for this room and these users
const uploadsQuery = await global.pool.query(`
SELECT mongo_id, name, size, type, url, path, typegroup, identify,
userid, uploadedat
FROM uploads
WHERE rid = $1
AND userid = ANY($2::text[])
ORDER BY uploadedat
`, [roomMongoId, Object.keys(userTimeMap)]);
// Match attachments to messages based on timestamp proximity (within 5 minutes)
const attachmentsByMessage = {};
uploadsQuery.rows.forEach(upload => {
const uploadTime = new Date(upload.uploadedat).getTime();
// Find the closest message from this user within 5 minutes
let closestMessageId = null;
let closestTimeDiff = Infinity;
Object.entries(messageMap).forEach(([msgId, msgData]) => {
if (msgData.user_id === upload.userid) {
const msgTime = new Date(msgData.ts).getTime();
const timeDiff = Math.abs(uploadTime - msgTime);
if (timeDiff < 300000 && timeDiff < closestTimeDiff) { // 5 minutes = 300000ms
closestMessageId = msgId;
closestTimeDiff = timeDiff;
}
}
});
if (closestMessageId) {
if (!attachmentsByMessage[closestMessageId]) {
attachmentsByMessage[closestMessageId] = [];
}
attachmentsByMessage[closestMessageId].push({
id: upload.id,
mongo_id: upload.mongo_id,
name: upload.name,
size: upload.size,
type: upload.type,
url: upload.url,
path: upload.path,
typegroup: upload.typegroup,
identify: upload.identify
});
}
});
res.json({
status: 'success',
attachments: attachmentsByMessage
});
} catch (error) {
console.error('Error fetching message attachments:', error);
res.status(500).json({
status: 'error',
error: 'Failed to fetch attachments',
details: error.message
});
}
});
// Search messages in accessible rooms for a user
router.get('/users/:userId/search', async (req, res) => {
const { userId } = req.params;
const { q, limit = 20 } = req.query;
if (!q || q.length < 2) {
return res.status(400).json({
status: 'error',
error: 'Search query must be at least 2 characters'
});
}
try {
const userResult = await global.pool.query(`
SELECT mongo_id FROM users WHERE id = $1
`, [userId]);
if (userResult.rows.length === 0) {
return res.status(404).json({
status: 'error',
error: 'User not found'
});
}
const currentUserMongoId = userResult.rows[0].mongo_id;
const result = await global.pool.query(`
SELECT m.id, m.msg, m.ts, m.u, r.id as room_id, r.name as room_name, r.fname as room_fname, r.t as room_type
FROM message m
JOIN room r ON m.rid = r.mongo_id
JOIN subscription s ON s.rid = r.mongo_id AND s.u->>'_id' = $1
WHERE m.msg ILIKE $2
AND r.archived IS NOT TRUE
ORDER BY m.ts DESC
LIMIT $3
`, [currentUserMongoId, `%${q}%`, limit]);
res.json({
status: 'success',
results: result.rows
});
} catch (error) {
console.error('Error searching messages:', error);
res.status(500).json({
status: 'error',
error: 'Failed to search messages',
details: error.message
});
}
});
module.exports = router;
+83
View File
@@ -0,0 +1,83 @@
require('dotenv').config({ path: '../.env' });
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const morgan = require('morgan');
const chatRoutes = require('./routes');
// Log startup configuration
console.log('Starting chat server with config:', {
host: process.env.CHAT_DB_HOST,
user: process.env.CHAT_DB_USER,
database: process.env.CHAT_DB_NAME || 'rocketchat_converted',
port: process.env.CHAT_DB_PORT,
chat_port: process.env.CHAT_PORT || 3014
});
const app = express();
const port = process.env.CHAT_PORT || 3014;
// Database configuration for rocketchat_converted database
const pool = new Pool({
host: process.env.CHAT_DB_HOST,
user: process.env.CHAT_DB_USER,
password: process.env.CHAT_DB_PASSWORD,
database: process.env.CHAT_DB_NAME || 'rocketchat_converted',
port: process.env.CHAT_DB_PORT,
});
// Make pool available globally
global.pool = pool;
// Middleware
app.use(express.json());
app.use(morgan('combined'));
app.use(cors({
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw'],
credentials: true
}));
// Test database connection endpoint
app.get('/test-db', async (req, res) => {
try {
const result = await pool.query('SELECT COUNT(*) as user_count FROM users WHERE active = true');
const messageResult = await pool.query('SELECT COUNT(*) as message_count FROM message');
const roomResult = await pool.query('SELECT COUNT(*) as room_count FROM room');
res.json({
status: 'success',
database: 'rocketchat_converted',
stats: {
active_users: parseInt(result.rows[0].user_count),
total_messages: parseInt(messageResult.rows[0].message_count),
total_rooms: parseInt(roomResult.rows[0].room_count)
}
});
} catch (error) {
console.error('Database test error:', error);
res.status(500).json({
status: 'error',
error: 'Database connection failed',
details: error.message
});
}
});
// Mount all routes from routes.js
app.use('/', chatRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something broke!' });
});
// Start server
app.listen(port, () => {
console.log(`Chat server running on port ${port}`);
});
+17 -2
View File
@@ -150,7 +150,7 @@ CREATE TABLE IF NOT EXISTS calculate_history (
);
CREATE TABLE IF NOT EXISTS calculate_status (
module_name module_name PRIMARY KEY,
module_name text PRIMARY KEY,
last_calculation_timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -169,6 +169,9 @@ CREATE TABLE IF NOT EXISTS import_history (
duration_minutes DECIMAL(10,2) GENERATED ALWAYS AS (duration_seconds::decimal / 60.0) STORED,
records_added INTEGER DEFAULT 0,
records_updated INTEGER DEFAULT 0,
records_deleted INTEGER DEFAULT 0,
records_skipped INTEGER DEFAULT 0,
total_processed INTEGER DEFAULT 0,
is_incremental BOOLEAN DEFAULT FALSE,
status calculation_status DEFAULT 'running',
error_message TEXT,
@@ -178,4 +181,16 @@ CREATE TABLE IF NOT EXISTS import_history (
-- Create all indexes after tables are fully created
CREATE INDEX IF NOT EXISTS idx_last_calc ON calculate_status(last_calculation_timestamp);
CREATE INDEX IF NOT EXISTS idx_last_sync ON sync_status(last_sync_timestamp);
CREATE INDEX IF NOT EXISTS idx_table_time ON import_history(table_name, start_time);
CREATE INDEX IF NOT EXISTS idx_table_time ON import_history(table_name, start_time);
CREATE INDEX IF NOT EXISTS idx_import_history_status ON import_history(status);
CREATE INDEX IF NOT EXISTS idx_calculate_history_status ON calculate_history(status);
-- Add comments for documentation
COMMENT ON TABLE import_history IS 'Tracks history of data import operations with detailed statistics';
COMMENT ON COLUMN import_history.records_deleted IS 'Number of records deleted during this import';
COMMENT ON COLUMN import_history.records_skipped IS 'Number of records skipped (e.g., unchanged, invalid)';
COMMENT ON COLUMN import_history.total_processed IS 'Total number of records examined/processed, including skipped';
COMMENT ON TABLE calculate_history IS 'Tracks history of metrics calculation runs with performance data';
COMMENT ON COLUMN calculate_history.duration_seconds IS 'Total duration of the calculation in seconds';
COMMENT ON COLUMN calculate_history.additional_info IS 'JSON object containing step timings, row counts, and other detailed metrics';
+77 -4
View File
@@ -53,6 +53,28 @@ CREATE TABLE public.product_metrics (
image_url VARCHAR, -- (e.g., products.image_175)
is_visible BOOLEAN,
is_replenishable BOOLEAN,
-- Additional product fields
barcode VARCHAR,
harmonized_tariff_code VARCHAR,
vendor_reference VARCHAR,
notions_reference VARCHAR,
line VARCHAR,
subline VARCHAR,
artist VARCHAR,
moq INT,
rating NUMERIC(10, 2),
reviews INT,
weight NUMERIC(14, 4),
length NUMERIC(14, 4),
width NUMERIC(14, 4),
height NUMERIC(14, 4),
country_of_origin VARCHAR,
location VARCHAR,
baskets INT,
notifies INT,
preorder_count INT,
notions_inv_count INT,
-- Current Status (Refreshed Hourly)
current_price NUMERIC(10, 2),
@@ -94,6 +116,7 @@ CREATE TABLE public.product_metrics (
-- Lifetime Metrics (Recalculated Hourly/Daily from daily_product_snapshots)
lifetime_sales INT,
lifetime_revenue NUMERIC(16, 4),
lifetime_revenue_quality VARCHAR(10), -- 'exact', 'partial', 'estimated'
-- First Period Metrics (Calculated Once/Periodically from daily_product_snapshots)
first_7_days_sales INT, first_7_days_revenue NUMERIC(14, 4),
@@ -151,6 +174,32 @@ CREATE TABLE public.product_metrics (
-- Yesterday's Metrics (Refreshed Hourly from daily_product_snapshots)
yesterday_sales INT,
-- Product Status (Calculated from metrics)
status VARCHAR, -- Stores status values like: Critical, Reorder Soon, Healthy, Overstock, At Risk, New
-- Growth Metrics (P3)
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth current 30d vs prev 30d
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth current 30d vs prev 30d
sales_growth_yoy NUMERIC(10, 2), -- Year-over-year sales growth %
revenue_growth_yoy NUMERIC(10, 2), -- Year-over-year revenue growth %
-- Demand Variability Metrics (P3)
sales_variance_30d NUMERIC(10, 2), -- Variance of daily sales
sales_std_dev_30d NUMERIC(10, 2), -- Standard deviation of daily sales
sales_cv_30d NUMERIC(10, 2), -- Coefficient of variation
demand_pattern VARCHAR(20), -- 'stable', 'variable', 'sporadic', 'lumpy'
-- Service Level & Fill Rate (P5)
fill_rate_30d NUMERIC(8, 2), -- % of demand fulfilled from stock
stockout_incidents_30d INT, -- Days with stockouts
service_level_30d NUMERIC(8, 2), -- % of days without stockouts
lost_sales_incidents_30d INT, -- Days with potential lost sales
-- Seasonality (P5)
seasonality_index NUMERIC(10, 2), -- Current vs average (100 = average)
seasonal_pattern VARCHAR(20), -- 'none', 'weekly', 'monthly', 'quarterly', 'yearly'
peak_season VARCHAR(20), -- e.g., 'Q4', 'summer', 'holiday'
CONSTRAINT fk_product_metrics_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE
);
@@ -163,6 +212,7 @@ CREATE INDEX idx_product_metrics_revenue_30d ON public.product_metrics(revenue_3
CREATE INDEX idx_product_metrics_sales_30d ON public.product_metrics(sales_30d DESC NULLS LAST); -- Example sorting index
CREATE INDEX idx_product_metrics_current_stock ON public.product_metrics(current_stock);
CREATE INDEX idx_product_metrics_sells_out_in_days ON public.product_metrics(sells_out_in_days ASC NULLS LAST); -- Example sorting index
CREATE INDEX idx_product_metrics_status ON public.product_metrics(status); -- Index for status filtering
-- Add new vendor, category, and brand metrics tables
-- Drop tables in reverse order if they exist
@@ -178,6 +228,7 @@ CREATE TABLE public.category_metrics (
parent_id INT8, -- Denormalized for convenience
last_calculated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- ROLLED-UP METRICS (includes this category + all descendants)
-- Counts & Basic Info
product_count INT NOT NULL DEFAULT 0, -- Total products linked
active_product_count INT NOT NULL DEFAULT 0, -- Visible products linked
@@ -195,10 +246,28 @@ CREATE TABLE public.category_metrics (
sales_365d INT NOT NULL DEFAULT 0, revenue_365d NUMERIC(16, 4) NOT NULL DEFAULT 0.00,
lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00,
-- Calculated KPIs (Based on 30d aggregates)
-- DIRECT METRICS (only products directly in this category)
direct_product_count INT NOT NULL DEFAULT 0, -- Products directly in this category
direct_active_product_count INT NOT NULL DEFAULT 0, -- Visible products directly in this category
direct_replenishable_product_count INT NOT NULL DEFAULT 0,-- Replenishable products directly in this category
-- Direct Current Stock Value
direct_current_stock_units INT NOT NULL DEFAULT 0,
direct_stock_cost NUMERIC(16, 4) NOT NULL DEFAULT 0.00,
direct_stock_retail NUMERIC(16, 4) NOT NULL DEFAULT 0.00,
-- Direct Rolling Period Aggregates
direct_sales_7d INT NOT NULL DEFAULT 0, direct_revenue_7d NUMERIC(16, 4) NOT NULL DEFAULT 0.00,
direct_sales_30d INT NOT NULL DEFAULT 0, direct_revenue_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00,
direct_profit_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00, direct_cogs_30d NUMERIC(16, 4) NOT NULL DEFAULT 0.00,
direct_sales_365d INT NOT NULL DEFAULT 0, direct_revenue_365d NUMERIC(16, 4) NOT NULL DEFAULT 0.00,
direct_lifetime_sales INT NOT NULL DEFAULT 0, direct_lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00,
-- Calculated KPIs (Based on 30d aggregates) - Apply to rolled-up metrics
avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100
stock_turn_30d NUMERIC(10, 3), -- sales_units / avg_stock_units (Needs avg stock calc)
-- growth_rate_30d NUMERIC(7, 3), -- (current 30d rev - prev 30d rev) / prev 30d rev
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
CONSTRAINT fk_category_metrics_cat_id FOREIGN KEY (category_id) REFERENCES public.categories(cat_id) ON DELETE CASCADE ON UPDATE CASCADE
);
@@ -236,7 +305,9 @@ CREATE TABLE public.vendor_metrics (
lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00,
-- Calculated KPIs (Based on 30d aggregates)
avg_margin_30d NUMERIC(7, 3) -- (profit / revenue) * 100
avg_margin_30d NUMERIC(14, 4), -- (profit / revenue) * 100
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
-- Add more KPIs if needed (e.g., avg product value, sell-through rate for vendor)
);
CREATE INDEX idx_vendor_metrics_active_count ON public.vendor_metrics(active_product_count);
@@ -265,7 +336,9 @@ CREATE TABLE public.brand_metrics (
lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00,
-- Calculated KPIs (Based on 30d aggregates)
avg_margin_30d NUMERIC(7, 3) -- (profit / revenue) * 100
avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
-- Add more KPIs if needed (e.g., avg product value, sell-through rate for brand)
);
CREATE INDEX idx_brand_metrics_active_count ON public.brand_metrics(active_product_count);
+98 -61
View File
@@ -7,7 +7,7 @@ BEGIN
-- Check which table is being updated and use the appropriate column
IF TG_TABLE_NAME = 'categories' THEN
NEW.updated_at = CURRENT_TIMESTAMP;
ELSIF TG_TABLE_NAME IN ('products', 'orders', 'purchase_orders') THEN
ELSIF TG_TABLE_NAME IN ('products', 'orders', 'purchase_orders', 'receivings') THEN
NEW.updated = CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
@@ -159,27 +159,24 @@ CREATE INDEX idx_orders_pid_date ON orders(pid, date);
CREATE INDEX idx_orders_updated ON orders(updated);
-- Create purchase_orders table with its indexes
-- This table now focuses solely on purchase order intent, not receivings
CREATE TABLE purchase_orders (
id BIGSERIAL PRIMARY KEY,
po_id TEXT NOT NULL,
vendor TEXT NOT NULL,
date DATE NOT NULL,
date TIMESTAMP WITH TIME ZONE NOT NULL,
expected_date DATE,
pid BIGINT NOT NULL,
sku TEXT NOT NULL,
name TEXT NOT NULL,
cost_price NUMERIC(14, 4) NOT NULL,
po_cost_price NUMERIC(14, 4) NOT NULL,
status TEXT DEFAULT 'created',
receiving_status TEXT DEFAULT 'created',
notes TEXT,
long_note TEXT,
ordered INTEGER NOT NULL,
received INTEGER DEFAULT 0,
received_date DATE,
last_received_date DATE,
received_by TEXT,
receiving_history JSONB,
supplier_id INTEGER,
date_created TIMESTAMP WITH TIME ZONE,
date_ordered TIMESTAMP WITH TIME ZONE,
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE,
UNIQUE (po_id, pid)
@@ -192,76 +189,116 @@ CREATE TRIGGER update_purchase_orders_updated
EXECUTE FUNCTION update_updated_column();
COMMENT ON COLUMN purchase_orders.name IS 'Product name from products.description';
COMMENT ON COLUMN purchase_orders.po_cost_price IS 'Original cost from PO, before receiving adjustments';
COMMENT ON COLUMN purchase_orders.po_cost_price IS 'Original cost from PO';
COMMENT ON COLUMN purchase_orders.status IS 'canceled, created, electronically_ready_send, ordered, preordered, electronically_sent, receiving_started, done';
COMMENT ON COLUMN purchase_orders.receiving_status IS 'canceled, created, partial_received, full_received, paid';
COMMENT ON COLUMN purchase_orders.receiving_history IS 'Array of receiving records with qty, date, cost, receiving_id, and alt_po flag';
CREATE INDEX idx_po_id ON purchase_orders(po_id);
CREATE INDEX idx_po_sku ON purchase_orders(sku);
CREATE INDEX idx_po_vendor ON purchase_orders(vendor);
CREATE INDEX idx_po_status ON purchase_orders(status);
CREATE INDEX idx_po_receiving_status ON purchase_orders(receiving_status);
CREATE INDEX idx_po_expected_date ON purchase_orders(expected_date);
CREATE INDEX idx_po_last_received_date ON purchase_orders(last_received_date);
CREATE INDEX idx_po_pid_status ON purchase_orders(pid, status);
CREATE INDEX idx_po_pid_date ON purchase_orders(pid, date);
CREATE INDEX idx_po_updated ON purchase_orders(updated);
CREATE INDEX idx_po_supplier_id ON purchase_orders(supplier_id);
-- Create receivings table to track actual receipt of goods
CREATE TABLE receivings (
id BIGSERIAL PRIMARY KEY,
receiving_id TEXT NOT NULL,
pid BIGINT NOT NULL,
sku TEXT NOT NULL,
name TEXT NOT NULL,
vendor TEXT,
qty_each INTEGER NOT NULL,
qty_each_orig INTEGER,
cost_each NUMERIC(14, 5) NOT NULL,
cost_each_orig NUMERIC(14, 5),
received_by INTEGER,
received_by_name TEXT,
received_date TIMESTAMP WITH TIME ZONE NOT NULL,
receiving_created_date TIMESTAMP WITH TIME ZONE,
supplier_id INTEGER,
status TEXT DEFAULT 'created',
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE,
UNIQUE (receiving_id, pid)
);
-- Create trigger for receivings
CREATE TRIGGER update_receivings_updated
BEFORE UPDATE ON receivings
FOR EACH ROW
EXECUTE FUNCTION update_updated_column();
COMMENT ON COLUMN receivings.status IS 'canceled, created, partial_received, full_received, paid';
COMMENT ON COLUMN receivings.qty_each_orig IS 'Original quantity from the source system';
COMMENT ON COLUMN receivings.cost_each_orig IS 'Original cost from the source system';
COMMENT ON COLUMN receivings.vendor IS 'Vendor name, same as in purchase_orders';
CREATE INDEX idx_receivings_id ON receivings(receiving_id);
CREATE INDEX idx_receivings_pid ON receivings(pid);
CREATE INDEX idx_receivings_sku ON receivings(sku);
CREATE INDEX idx_receivings_status ON receivings(status);
CREATE INDEX idx_receivings_received_date ON receivings(received_date);
CREATE INDEX idx_receivings_supplier_id ON receivings(supplier_id);
CREATE INDEX idx_receivings_vendor ON receivings(vendor);
CREATE INDEX idx_receivings_updated ON receivings(updated);
SET session_replication_role = 'origin'; -- Re-enable foreign key checks
-- Create views for common calculations
-- product_sales_trends view moved to metrics-schema.sql
-- Historical data tables imported from production
CREATE TABLE imported_product_current_prices (
price_id BIGSERIAL PRIMARY KEY,
pid BIGINT NOT NULL,
qty_buy SMALLINT NOT NULL,
is_min_qty_buy BOOLEAN NOT NULL,
price_each NUMERIC(10,3) NOT NULL,
qty_limit SMALLINT NOT NULL,
no_promo BOOLEAN NOT NULL,
checkout_offer BOOLEAN NOT NULL,
active BOOLEAN NOT NULL,
date_active TIMESTAMP WITH TIME ZONE,
date_deactive TIMESTAMP WITH TIME ZONE,
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- -- Historical data tables imported from production
-- CREATE TABLE imported_product_current_prices (
-- price_id BIGSERIAL PRIMARY KEY,
-- pid BIGINT NOT NULL,
-- qty_buy SMALLINT NOT NULL,
-- is_min_qty_buy BOOLEAN NOT NULL,
-- price_each NUMERIC(10,3) NOT NULL,
-- qty_limit SMALLINT NOT NULL,
-- no_promo BOOLEAN NOT NULL,
-- checkout_offer BOOLEAN NOT NULL,
-- active BOOLEAN NOT NULL,
-- date_active TIMESTAMP WITH TIME ZONE,
-- date_deactive TIMESTAMP WITH TIME ZONE,
-- updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
-- );
CREATE INDEX idx_imported_product_current_prices_pid ON imported_product_current_prices(pid, active, qty_buy);
CREATE INDEX idx_imported_product_current_prices_checkout ON imported_product_current_prices(checkout_offer, active);
CREATE INDEX idx_imported_product_current_prices_deactive ON imported_product_current_prices(date_deactive, active);
CREATE INDEX idx_imported_product_current_prices_active ON imported_product_current_prices(date_active, active);
-- CREATE INDEX idx_imported_product_current_prices_pid ON imported_product_current_prices(pid, active, qty_buy);
-- CREATE INDEX idx_imported_product_current_prices_checkout ON imported_product_current_prices(checkout_offer, active);
-- CREATE INDEX idx_imported_product_current_prices_deactive ON imported_product_current_prices(date_deactive, active);
-- CREATE INDEX idx_imported_product_current_prices_active ON imported_product_current_prices(date_active, active);
CREATE TABLE imported_daily_inventory (
date DATE NOT NULL,
pid BIGINT NOT NULL,
amountsold SMALLINT NOT NULL DEFAULT 0,
times_sold SMALLINT NOT NULL DEFAULT 0,
qtyreceived SMALLINT NOT NULL DEFAULT 0,
price NUMERIC(7,2) NOT NULL DEFAULT 0,
costeach NUMERIC(7,2) NOT NULL DEFAULT 0,
stamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (date, pid)
);
-- CREATE TABLE imported_daily_inventory (
-- date DATE NOT NULL,
-- pid BIGINT NOT NULL,
-- amountsold SMALLINT NOT NULL DEFAULT 0,
-- times_sold SMALLINT NOT NULL DEFAULT 0,
-- qtyreceived SMALLINT NOT NULL DEFAULT 0,
-- price NUMERIC(7,2) NOT NULL DEFAULT 0,
-- costeach NUMERIC(7,2) NOT NULL DEFAULT 0,
-- stamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (date, pid)
-- );
CREATE INDEX idx_imported_daily_inventory_pid ON imported_daily_inventory(pid);
-- CREATE INDEX idx_imported_daily_inventory_pid ON imported_daily_inventory(pid);
CREATE TABLE imported_product_stat_history (
pid BIGINT NOT NULL,
date DATE NOT NULL,
score NUMERIC(10,2) NOT NULL,
score2 NUMERIC(10,2) NOT NULL,
qty_in_baskets SMALLINT NOT NULL,
qty_sold SMALLINT NOT NULL,
notifies_set SMALLINT NOT NULL,
visibility_score NUMERIC(10,2) NOT NULL,
health_score VARCHAR(5) NOT NULL,
sold_view_score NUMERIC(6,3) NOT NULL,
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (pid, date)
);
-- CREATE TABLE imported_product_stat_history (
-- pid BIGINT NOT NULL,
-- date DATE NOT NULL,
-- score NUMERIC(10,2) NOT NULL,
-- score2 NUMERIC(10,2) NOT NULL,
-- qty_in_baskets SMALLINT NOT NULL,
-- qty_sold SMALLINT NOT NULL,
-- notifies_set SMALLINT NOT NULL,
-- visibility_score NUMERIC(10,2) NOT NULL,
-- health_score VARCHAR(5) NOT NULL,
-- sold_view_score NUMERIC(6,3) NOT NULL,
-- updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (pid, date)
-- );
CREATE INDEX idx_imported_product_stat_history_date ON imported_product_stat_history(date);
-- CREATE INDEX idx_imported_product_stat_history_date ON imported_product_stat_history(date);
@@ -1,7 +1,7 @@
const path = require('path');
const fs = require('fs');
const progress = require('../utils/progress'); // Assuming progress utils are here
const { getConnection, closePool } = require('../utils/db'); // Assuming db utils are here
const progress = require('../scripts/metrics-new/utils/progress'); // Assuming progress utils are here
const { getConnection, closePool } = require('../scripts/metrics-new/utils/db'); // Assuming db utils are here
const os = require('os'); // For detecting number of CPU cores
// --- Configuration ---
@@ -1,4 +1,4 @@
const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('../metrics-new/utils/progress');
const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('../scripts/metrics-new/utils/progress');
const fs = require('fs');
const path = require('path');
const { pipeline } = require('stream');
@@ -24,7 +24,7 @@ process.on('unhandledRejection', (reason, promise) => {
});
// Load progress module
const progress = require('../utils/progress');
const progress = require('../scripts/metrics-new/utils/progress');
// Store progress functions in global scope to ensure availability
global.formatElapsedTime = progress.formatElapsedTime;
@@ -36,7 +36,7 @@ global.getProgress = progress.getProgress;
global.logError = progress.logError;
// Load database module
const { getConnection, closePool } = require('../utils/db');
const { getConnection, closePool } = require('../scripts/metrics-new/utils/db');
// Add cancel handler
let isCancelled = false;
@@ -91,6 +91,287 @@ function cancelCalculation() {
process.on('SIGTERM', cancelCalculation);
process.on('SIGINT', cancelCalculation);
const calculateInitialMetrics = (client, onProgress) => {
return client.query(`
-- Truncate the existing metrics tables to ensure clean data
TRUNCATE TABLE public.daily_product_snapshots;
TRUNCATE TABLE public.product_metrics;
-- First let's create daily snapshots for all products with order activity
WITH SalesData AS (
SELECT
p.pid,
p.sku,
o.date::date AS order_date,
-- Count orders to ensure we only include products with real activity
COUNT(o.id) as order_count,
-- Aggregate Sales (Quantity > 0, Status not Canceled/Returned)
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
-- Aggregate Returns (Quantity < 0 or Status = Returned)
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned,
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue
FROM public.products p
LEFT JOIN public.orders o ON p.pid = o.pid
GROUP BY p.pid, p.sku, o.date::date
HAVING COUNT(o.id) > 0 -- Only include products with actual orders
),
ReceivingData AS (
SELECT
r.pid,
r.received_date::date AS receiving_date,
-- Count receiving documents to ensure we only include products with real activity
COUNT(DISTINCT r.receiving_id) as receiving_count,
-- Calculate received quantity for this day
SUM(r.received_quantity) AS units_received,
-- Calculate received cost for this day
SUM(r.received_quantity * r.unit_cost) AS cost_received
FROM public.receivings r
GROUP BY r.pid, r.received_date::date
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.received_quantity) > 0
),
-- Get current stock quantities
StockData AS (
SELECT
p.pid,
p.stock_quantity,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price
FROM public.products p
),
-- Combine sales and receiving dates to get all activity dates
DatePidCombos AS (
SELECT DISTINCT pid, order_date AS activity_date FROM SalesData
UNION
SELECT DISTINCT pid, receiving_date FROM ReceivingData
),
-- Insert daily snapshots for all product-date combinations
SnapshotInsert AS (
INSERT INTO public.daily_product_snapshots (
snapshot_date,
pid,
sku,
eod_stock_quantity,
eod_stock_cost,
eod_stock_retail,
eod_stock_gross,
stockout_flag,
units_sold,
units_returned,
gross_revenue,
discounts,
returns_revenue,
net_revenue,
cogs,
gross_regular_revenue,
profit,
units_received,
cost_received,
calculation_timestamp
)
SELECT
d.activity_date AS snapshot_date,
d.pid,
p.sku,
-- Use current stock as approximation, since historical stock data is not available
s.stock_quantity AS eod_stock_quantity,
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
s.stock_quantity * s.current_price AS eod_stock_retail,
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
(s.stock_quantity <= 0) AS stockout_flag,
-- Sales metrics
COALESCE(sd.units_sold, 0),
COALESCE(sd.units_returned, 0),
COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
-- Receiving metrics
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),
now() -- calculation timestamp
FROM DatePidCombos d
JOIN public.products p ON d.pid = p.pid
LEFT JOIN SalesData sd ON d.pid = sd.pid AND d.activity_date = sd.order_date
LEFT JOIN ReceivingData rd ON d.pid = rd.pid AND d.activity_date = rd.receiving_date
LEFT JOIN StockData s ON d.pid = s.pid
RETURNING pid, snapshot_date
),
-- Now build the aggregated product metrics from the daily snapshots
MetricsInsert AS (
INSERT INTO public.product_metrics (
pid,
sku,
current_stock_quantity,
current_stock_cost,
current_stock_retail,
current_stock_msrp,
is_out_of_stock,
total_units_sold,
total_units_returned,
return_rate,
gross_revenue,
total_discounts,
total_returns,
net_revenue,
total_cogs,
total_gross_revenue,
total_profit,
profit_margin,
avg_daily_units,
reorder_point,
reorder_alert,
days_of_supply,
sales_velocity,
sales_velocity_score,
rank_by_revenue,
rank_by_quantity,
rank_by_profit,
total_received_quantity,
total_received_cost,
last_sold_date,
last_received_date,
days_since_last_sale,
days_since_last_received,
calculation_timestamp
)
SELECT
p.pid,
p.sku,
p.stock_quantity AS current_stock_quantity,
p.stock_quantity * COALESCE(p.landing_cost_price, p.cost_price, 0) AS current_stock_cost,
p.stock_quantity * COALESCE(p.price, 0) AS current_stock_retail,
p.stock_quantity * COALESCE(p.regular_price, 0) AS current_stock_msrp,
(p.stock_quantity <= 0) AS is_out_of_stock,
-- Aggregate metrics
COALESCE(SUM(ds.units_sold), 0) AS total_units_sold,
COALESCE(SUM(ds.units_returned), 0) AS total_units_returned,
CASE
WHEN COALESCE(SUM(ds.units_sold), 0) > 0
THEN COALESCE(SUM(ds.units_returned), 0)::float / NULLIF(COALESCE(SUM(ds.units_sold), 0), 0)
ELSE 0
END AS return_rate,
COALESCE(SUM(ds.gross_revenue), 0) AS gross_revenue,
COALESCE(SUM(ds.discounts), 0) AS total_discounts,
COALESCE(SUM(ds.returns_revenue), 0) AS total_returns,
COALESCE(SUM(ds.net_revenue), 0) AS net_revenue,
COALESCE(SUM(ds.cogs), 0) AS total_cogs,
COALESCE(SUM(ds.gross_regular_revenue), 0) AS total_gross_revenue,
COALESCE(SUM(ds.profit), 0) AS total_profit,
CASE
WHEN COALESCE(SUM(ds.net_revenue), 0) > 0
THEN COALESCE(SUM(ds.profit), 0) / NULLIF(COALESCE(SUM(ds.net_revenue), 0), 0)
ELSE 0
END AS profit_margin,
-- Calculate average daily units
COALESCE(AVG(ds.units_sold), 0) AS avg_daily_units,
-- Calculate reorder point (simplified, can be enhanced with lead time and safety stock)
CEILING(COALESCE(AVG(ds.units_sold) * 14, 0)) AS reorder_point,
(p.stock_quantity <= CEILING(COALESCE(AVG(ds.units_sold) * 14, 0))) AS reorder_alert,
-- Days of supply based on average daily sales
CASE
WHEN COALESCE(AVG(ds.units_sold), 0) > 0
THEN p.stock_quantity / NULLIF(COALESCE(AVG(ds.units_sold), 0), 0)
ELSE NULL
END AS days_of_supply,
-- Sales velocity (average units sold per day over last 30 days)
(SELECT COALESCE(AVG(recent.units_sold), 0)
FROM public.daily_product_snapshots recent
WHERE recent.pid = p.pid
AND recent.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
) AS sales_velocity,
-- Placeholder for sales velocity score (can be calculated based on velocity)
0 AS sales_velocity_score,
-- Will be updated later by ranking procedure
0 AS rank_by_revenue,
0 AS rank_by_quantity,
0 AS rank_by_profit,
-- Receiving data
COALESCE(SUM(ds.units_received), 0) AS total_received_quantity,
COALESCE(SUM(ds.cost_received), 0) AS total_received_cost,
-- Date metrics
(SELECT MAX(sd.snapshot_date)
FROM public.daily_product_snapshots sd
WHERE sd.pid = p.pid AND sd.units_sold > 0
) AS last_sold_date,
(SELECT MAX(rd.snapshot_date)
FROM public.daily_product_snapshots rd
WHERE rd.pid = p.pid AND rd.units_received > 0
) AS last_received_date,
-- Calculate days since last sale/received
CASE
WHEN (SELECT MAX(sd.snapshot_date)
FROM public.daily_product_snapshots sd
WHERE sd.pid = p.pid AND sd.units_sold > 0) IS NOT NULL
THEN (CURRENT_DATE - (SELECT MAX(sd.snapshot_date)
FROM public.daily_product_snapshots sd
WHERE sd.pid = p.pid AND sd.units_sold > 0))::integer
ELSE NULL
END AS days_since_last_sale,
CASE
WHEN (SELECT MAX(rd.snapshot_date)
FROM public.daily_product_snapshots rd
WHERE rd.pid = p.pid AND rd.units_received > 0) IS NOT NULL
THEN (CURRENT_DATE - (SELECT MAX(rd.snapshot_date)
FROM public.daily_product_snapshots rd
WHERE rd.pid = p.pid AND rd.units_received > 0))::integer
ELSE NULL
END AS days_since_last_received,
now() -- calculation timestamp
FROM public.products p
LEFT JOIN public.daily_product_snapshots ds ON p.pid = ds.pid
GROUP BY p.pid, p.sku, p.stock_quantity, p.landing_cost_price, p.cost_price, p.price, p.regular_price
)
-- Update the calculate_status table
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
VALUES
('daily_snapshots', now()),
('product_metrics', now())
ON CONFLICT (module_name) DO UPDATE
SET last_calculation_timestamp = now();
-- Finally, update the ranks for products
UPDATE public.product_metrics pm SET
rank_by_revenue = rev_ranks.rank
FROM (
SELECT pid, RANK() OVER (ORDER BY net_revenue DESC) AS rank
FROM public.product_metrics
WHERE net_revenue > 0
) rev_ranks
WHERE pm.pid = rev_ranks.pid;
UPDATE public.product_metrics pm SET
rank_by_quantity = qty_ranks.rank
FROM (
SELECT pid, RANK() OVER (ORDER BY total_units_sold DESC) AS rank
FROM public.product_metrics
WHERE total_units_sold > 0
) qty_ranks
WHERE pm.pid = qty_ranks.pid;
UPDATE public.product_metrics pm SET
rank_by_profit = profit_ranks.rank
FROM (
SELECT pid, RANK() OVER (ORDER BY total_profit DESC) AS rank
FROM public.product_metrics
WHERE total_profit > 0
) profit_ranks
WHERE pm.pid = profit_ranks.pid;
-- Return count of products with metrics
SELECT COUNT(*) AS product_count FROM public.product_metrics
`);
};
async function populateInitialMetrics() {
let connection;
const startTime = Date.now();
+267 -98
View File
@@ -156,6 +156,7 @@ let currentStep = ''; // Track which step is running for cancellation message
let overallStartTime = null;
let mainTimeoutHandle = null;
let stepTimeoutHandle = null;
let combinedHistoryId = null; // ID for the combined history record
async function cancelCalculation(reason = 'cancelled by user') {
if (isCancelled) return; // Prevent multiple cancellations
@@ -181,6 +182,22 @@ async function cancelCalculation(reason = 'cancelled by user') {
AND pid <> pg_backend_pid(); -- Don't cancel self
`);
console.log(`Sent ${result.rowCount} cancellation signal(s).`);
// Update the combined history record to show cancellation
if (combinedHistoryId) {
const totalDuration = Math.round((Date.now() - overallStartTime) / 1000);
await conn.query(`
UPDATE calculate_history
SET
status = 'cancelled'::calculation_status,
end_time = NOW(),
duration_seconds = $1::integer,
error_message = $2::text
WHERE id = $3::integer;
`, [totalDuration, `Calculation ${reason} during step: ${currentStep}`, combinedHistoryId]);
console.log(`Updated combined history record ${combinedHistoryId} with cancellation status`);
}
conn.release();
} catch (err) {
console.error('Error during database query cancellation:', err.message);
@@ -340,7 +357,7 @@ async function syncSettingsProductTable() {
* @param {string} config.historyType - Type identifier for calculate_history.
* @param {string} config.statusModule - Module name for calculate_status.
* @param {object} progress - Progress utility functions.
* @returns {Promise<{success: boolean, message: string, duration: number}>}
* @returns {Promise<{success: boolean, message: string, duration: number, rowsAffected: number}>}
*/
async function executeSqlStep(config, progress) {
if (isCancelled) throw new Error(`Calculation skipped step ${config.name} due to prior cancellation.`);
@@ -349,7 +366,7 @@ async function executeSqlStep(config, progress) {
console.log(`\n--- Starting Step: ${config.name} ---`);
const stepStartTime = Date.now();
let connection = null;
let calculateHistoryId = null;
let rowsAffected = 0; // Track rows affected by this step
// Set timeout for this specific step
if (stepTimeoutHandle) clearTimeout(stepTimeoutHandle); // Clear previous step's timeout
@@ -383,10 +400,7 @@ async function executeSqlStep(config, progress) {
connection = await getConnection();
console.log("Database connection acquired.");
// 3. Clean up Previous Runs & Create History Record (within a transaction)
await connection.query('BEGIN');
// Ensure calculate_status table exists
// 3. Ensure calculate_status table exists
await connection.query(`
CREATE TABLE IF NOT EXISTS calculate_status (
module_name TEXT PRIMARY KEY,
@@ -394,41 +408,6 @@ async function executeSqlStep(config, progress) {
);
`);
// Ensure calculate_history table exists (basic structure)
await connection.query(`
CREATE TABLE IF NOT EXISTS calculate_history (
id SERIAL PRIMARY KEY,
start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP WITH TIME ZONE,
duration_seconds INTEGER,
status TEXT, -- 'running', 'completed', 'failed', 'cancelled'
error_message TEXT,
additional_info JSONB
);
`);
// Mark previous runs of this type as cancelled
await connection.query(`
UPDATE calculate_history
SET
status = 'cancelled',
end_time = NOW(),
duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER,
error_message = 'Previous calculation was not completed properly or was superseded.'
WHERE status = 'running' AND additional_info->>'type' = $1::text;
`, [config.historyType]);
// Create history record for this run
const historyResult = await connection.query(`
INSERT INTO calculate_history (status, additional_info)
VALUES ('running', jsonb_build_object('type', $1::text, 'sql_file', $2::text))
RETURNING id;
`, [config.historyType, config.sqlFile]);
calculateHistoryId = historyResult.rows[0].id;
await connection.query('COMMIT');
console.log(`Created history record ID: ${calculateHistoryId}`);
// 4. Initial Progress Update
progress.outputProgress({
status: 'running',
@@ -436,7 +415,10 @@ async function executeSqlStep(config, progress) {
current: 0, total: 100,
elapsed: progress.formatElapsedTime(stepStartTime),
remaining: 'Calculating...', rate: 0, percentage: '0',
timing: { start_time: new Date(stepStartTime).toISOString() }
timing: {
start_time: new Date(stepStartTime).toISOString(),
step_start_ms: stepStartTime
}
});
// 5. Execute the Main SQL Query
@@ -445,15 +427,35 @@ async function executeSqlStep(config, progress) {
operation: `Executing SQL: ${config.name}`,
current: 25, total: 100,
elapsed: progress.formatElapsedTime(stepStartTime),
remaining: 'Executing...', rate: 0, percentage: '25',
timing: { start_time: new Date(stepStartTime).toISOString() }
remaining: 'Executing query...', rate: 0, percentage: '25',
timing: {
start_time: new Date(stepStartTime).toISOString(),
step_start_ms: stepStartTime
}
});
console.log(`Executing SQL for ${config.name}...`);
try {
// Try executing exactly as individual scripts do
console.log('Executing SQL with simple query method...');
await connection.query(sqlQuery);
const result = await connection.query(sqlQuery);
// Try to extract row count from result
if (result && result.rowCount !== undefined) {
rowsAffected = result.rowCount;
} else if (Array.isArray(result) && result[0] && result[0].rowCount !== undefined) {
rowsAffected = result[0].rowCount;
}
// Check if the query returned a result set with row count info
if (result && result.rows && result.rows.length > 0 && result.rows[0].rows_processed) {
rowsAffected = parseInt(result.rows[0].rows_processed) || rowsAffected;
console.log(`SQL returned metrics: ${JSON.stringify(result.rows[0])}`);
} else if (Array.isArray(result) && result[0] && result[0].rows && result[0].rows[0] && result[0].rows[0].rows_processed) {
rowsAffected = parseInt(result[0].rows[0].rows_processed) || rowsAffected;
console.log(`SQL returned metrics: ${JSON.stringify(result[0].rows[0])}`);
}
console.log(`SQL affected ${rowsAffected} rows`);
} catch (sqlError) {
if (sqlError.message.includes('could not determine data type of parameter')) {
console.log('Simple query failed with parameter type error, trying alternative method...');
@@ -486,9 +488,7 @@ async function executeSqlStep(config, progress) {
console.log(`SQL execution finished for ${config.name}.`);
// 6. Update Status & History (within a transaction)
await connection.query('BEGIN');
// 6. Update Status table only
await connection.query(`
INSERT INTO calculate_status (module_name, last_calculation_timestamp)
VALUES ($1::text, NOW())
@@ -497,16 +497,6 @@ async function executeSqlStep(config, progress) {
`, [config.statusModule]);
const stepDuration = Math.round((Date.now() - stepStartTime) / 1000);
await connection.query(`
UPDATE calculate_history
SET
end_time = NOW(),
duration_seconds = $1::integer,
status = 'completed'
WHERE id = $2::integer;
`, [stepDuration, calculateHistoryId]);
await connection.query('COMMIT');
// 7. Final Progress Update for Step
progress.outputProgress({
@@ -526,7 +516,8 @@ async function executeSqlStep(config, progress) {
return {
success: true,
message: `${config.name} completed successfully`,
duration: stepDuration
duration: stepDuration,
rowsAffected: rowsAffected
};
} catch (error) {
@@ -540,33 +531,8 @@ async function executeSqlStep(config, progress) {
console.error(error); // Log the full error
console.error(`------------------------------------`);
// Update history with error/cancellation status
if (connection && calculateHistoryId) {
try {
// Use a separate transaction for error logging
await connection.query('ROLLBACK'); // Rollback any partial transaction from try block
await connection.query('BEGIN');
await connection.query(`
UPDATE calculate_history
SET
end_time = NOW(),
duration_seconds = $1::integer,
status = $2::text,
error_message = $3::text
WHERE id = $4::integer;
`, [errorDuration, finalStatus, errorMessage.substring(0, 1000), calculateHistoryId]); // Limit error message size
await connection.query('COMMIT');
console.log(`Updated history record ID ${calculateHistoryId} with status: ${finalStatus}`);
} catch (historyError) {
console.error("FATAL: Failed to update history record on error:", historyError);
// Cannot rollback here if already rolled back or commit failed
}
} else {
console.warn("Could not update history record on error (no connection or history ID).");
}
// Update progress file with error/cancellation
progress.outputProgress({
progress.outputProgress({
status: finalStatus,
operation: `Error in ${config.name}: ${errorMessage.split('\n')[0]}`, // Show first line of error
current: 50, total: 100, // Indicate partial completion
@@ -656,9 +622,91 @@ async function runAllCalculations() {
}
];
// Build a list of steps we will actually run
const stepsToRun = steps.filter(step => step.run);
const stepNames = stepsToRun.map(step => step.name);
const sqlFiles = stepsToRun.map(step => step.sqlFile);
let overallSuccess = true;
let connection = null;
try {
// Create a single history record before starting all calculations
try {
connection = await getConnection();
// Ensure calculate_history table exists (basic structure)
await connection.query(`
CREATE TABLE IF NOT EXISTS calculate_history (
id SERIAL PRIMARY KEY,
start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP WITH TIME ZONE,
duration_seconds INTEGER,
status TEXT, -- Will be altered to enum if needed below
error_message TEXT,
additional_info JSONB
);
`);
// Ensure the calculation_status enum type exists if needed
await connection.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'calculation_status') THEN
CREATE TYPE calculation_status AS ENUM ('running', 'completed', 'failed', 'cancelled');
-- If needed, alter the existing table to use the enum
ALTER TABLE calculate_history
ALTER COLUMN status TYPE calculation_status
USING status::calculation_status;
END IF;
END
$$;
`);
// Mark any previous running combined calculations as cancelled
await connection.query(`
UPDATE calculate_history
SET
status = 'cancelled'::calculation_status,
end_time = NOW(),
duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER,
error_message = 'Previous calculation was not completed properly or was superseded.'
WHERE status = 'running'::calculation_status AND additional_info->>'type' = 'combined_metrics';
`);
// Create a single history record for this run
const historyResult = await connection.query(`
INSERT INTO calculate_history (status, additional_info)
VALUES ('running'::calculation_status, jsonb_build_object(
'type', 'combined_metrics',
'steps', $1::jsonb,
'sql_files', $2::jsonb
))
RETURNING id;
`, [JSON.stringify(stepNames), JSON.stringify(sqlFiles)]);
combinedHistoryId = historyResult.rows[0].id;
console.log(`Created combined history record ID: ${combinedHistoryId}`);
// Get initial counts for tracking
const productCount = await connection.query('SELECT COUNT(*) as count FROM products');
const totalProducts = parseInt(productCount.rows[0].count);
// Update history with initial counts
await connection.query(`
UPDATE calculate_history
SET additional_info = additional_info || jsonb_build_object('total_products', $1::integer)
WHERE id = $2
`, [totalProducts, combinedHistoryId]);
connection.release();
} catch (historyError) {
console.error('Error creating combined history record:', historyError);
if (connection) connection.release();
// Continue without history tracking if it fails
}
// First, sync the settings_product table to ensure all products have entries
progressUtils.outputProgress({
operation: 'Starting metrics calculation',
@@ -678,29 +726,124 @@ async function runAllCalculations() {
// Don't fail the entire process if settings sync fails
}
// Track completed steps
const completedSteps = [];
const stepTimings = {};
const stepRowCounts = {};
let currentStepIndex = 0;
// Now run the calculation steps
for (const step of steps) {
if (step.run) {
if (isCancelled) {
console.log(`Skipping step "${step.name}" due to cancellation.`);
overallSuccess = false; // Mark as not fully successful if steps are skipped due to cancel
continue; // Skip to next step
for (const step of stepsToRun) {
if (isCancelled) {
console.log(`Skipping step "${step.name}" due to cancellation.`);
overallSuccess = false; // Mark as not fully successful if steps are skipped due to cancel
continue; // Skip to next step
}
currentStepIndex++;
// Update overall progress
progressUtils.outputProgress({
status: 'running',
operation: 'Running calculations',
message: `Step ${currentStepIndex} of ${stepsToRun.length}: ${step.name}`,
current: currentStepIndex - 1,
total: stepsToRun.length,
elapsed: progressUtils.formatElapsedTime(overallStartTime),
remaining: progressUtils.estimateRemaining(overallStartTime, currentStepIndex - 1, stepsToRun.length),
percentage: Math.round(((currentStepIndex - 1) / stepsToRun.length) * 100).toString(),
timing: {
overall_start_time: new Date(overallStartTime).toISOString(),
current_step: step.name,
completed_steps: completedSteps.length
}
// Pass the progress utilities to the step executor
await executeSqlStep(step, progressUtils);
} else {
console.log(`Skipping step "${step.name}" (disabled by configuration).`);
});
// Pass the progress utilities to the step executor
const result = await executeSqlStep(step, progressUtils);
if (result.success) {
completedSteps.push({
name: step.name,
duration: result.duration,
status: 'completed',
rowsAffected: result.rowsAffected
});
stepTimings[step.name] = result.duration;
stepRowCounts[step.name] = result.rowsAffected;
}
}
// If we finished naturally (no errors thrown out)
clearTimeout(mainTimeoutHandle); // Clear the main timeout
// Update the combined history record on successful completion
if (combinedHistoryId) {
try {
connection = await getConnection();
const totalDuration = Math.round((Date.now() - overallStartTime) / 1000);
// Get final processed counts
const processedCounts = await connection.query(`
SELECT
(SELECT COUNT(*) FROM product_metrics WHERE last_calculated >= $1) as processed_products
`, [new Date(overallStartTime)]);
await connection.query(`
UPDATE calculate_history
SET
end_time = NOW(),
duration_seconds = $1::integer,
status = $2::calculation_status,
additional_info = additional_info || jsonb_build_object(
'processed_products', $3::integer,
'completed_steps', $4::jsonb,
'step_timings', $5::jsonb,
'step_row_counts', $6::jsonb
)
WHERE id = $7::integer;
`, [
totalDuration,
isCancelled ? 'cancelled' : 'completed',
processedCounts.rows[0].processed_products,
JSON.stringify(completedSteps),
JSON.stringify(stepTimings),
JSON.stringify(stepRowCounts),
combinedHistoryId
]);
connection.release();
} catch (historyError) {
console.error('Error updating combined history record on completion:', historyError);
if (connection) connection.release();
}
}
if (isCancelled) {
console.log("\n--- Calculation finished with cancellation ---");
overallSuccess = false;
} else {
console.log("\n--- All enabled calculations finished successfully ---");
// Send final completion progress
progressUtils.outputProgress({
status: 'complete',
operation: 'All calculations completed',
message: `Successfully completed ${completedSteps.length} of ${stepsToRun.length} steps`,
current: stepsToRun.length,
total: stepsToRun.length,
elapsed: progressUtils.formatElapsedTime(overallStartTime),
remaining: '0s',
percentage: '100',
timing: {
overall_start_time: new Date(overallStartTime).toISOString(),
overall_end_time: new Date().toISOString(),
total_duration_seconds: Math.round((Date.now() - overallStartTime) / 1000),
step_timings: stepTimings,
completed_steps: completedSteps.length
}
});
progressUtils.clearProgress(); // Clear progress only on full success
}
@@ -709,8 +852,34 @@ async function runAllCalculations() {
console.error("\n--- SCRIPT EXECUTION FAILED ---");
// Error details were already logged by executeSqlStep or global handlers
overallSuccess = false;
// Don't re-log the error here unless adding context
// console.error("Overall failure reason:", error.message);
// Update the combined history record on error
if (combinedHistoryId) {
try {
connection = await getConnection();
const totalDuration = Math.round((Date.now() - overallStartTime) / 1000);
await connection.query(`
UPDATE calculate_history
SET
end_time = NOW(),
duration_seconds = $1::integer,
status = $2::calculation_status,
error_message = $3::text
WHERE id = $4::integer;
`, [
totalDuration,
isCancelled ? 'cancelled' : 'failed',
error.message.substring(0, 1000),
combinedHistoryId
]);
connection.release();
} catch (historyError) {
console.error('Error updating combined history record on error:', historyError);
if (connection) connection.release();
}
}
} finally {
await closePool();
console.log(`Total execution time: ${progressUtils.formatElapsedTime(overallStartTime)}`);
+34 -45
View File
@@ -6,7 +6,6 @@ const importCategories = require('./import/categories');
const { importProducts } = require('./import/products');
const importOrders = require('./import/orders');
const importPurchaseOrders = require('./import/purchase-orders');
const importHistoricalData = require('./import/historical-data');
dotenv.config({ path: path.join(__dirname, "../.env") });
@@ -15,7 +14,6 @@ const IMPORT_CATEGORIES = true;
const IMPORT_PRODUCTS = true;
const IMPORT_ORDERS = true;
const IMPORT_PURCHASE_ORDERS = true;
const IMPORT_HISTORICAL_DATA = false;
// Add flag for incremental updates
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
@@ -38,7 +36,7 @@ const sshConfig = {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
timezone: 'Z',
timezone: '-05:00', // Production DB always stores times in EST (UTC-5) regardless of DST
},
localDbConfig: {
// PostgreSQL config for local
@@ -80,8 +78,7 @@ async function main() {
IMPORT_CATEGORIES,
IMPORT_PRODUCTS,
IMPORT_ORDERS,
IMPORT_PURCHASE_ORDERS,
IMPORT_HISTORICAL_DATA
IMPORT_PURCHASE_ORDERS
].filter(Boolean).length;
try {
@@ -129,11 +126,10 @@ async function main() {
'categories_enabled', $2::boolean,
'products_enabled', $3::boolean,
'orders_enabled', $4::boolean,
'purchase_orders_enabled', $5::boolean,
'historical_data_enabled', $6::boolean
'purchase_orders_enabled', $5::boolean
)
) RETURNING id
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS, IMPORT_HISTORICAL_DATA]);
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS]);
importHistoryId = historyResult.rows[0].id;
} catch (error) {
console.error("Error creating import history record:", error);
@@ -150,16 +146,21 @@ async function main() {
categories: null,
products: null,
orders: null,
purchaseOrders: null,
historicalData: null
purchaseOrders: null
};
let totalRecordsAdded = 0;
let totalRecordsUpdated = 0;
let totalRecordsDeleted = 0; // Add tracking for deleted records
let totalRecordsSkipped = 0; // Track skipped/filtered records
const stepTimings = {};
// Run each import based on constants
if (IMPORT_CATEGORIES) {
const stepStart = Date.now();
results.categories = await importCategories(prodConnection, localConnection);
stepTimings.categories = Math.round((Date.now() - stepStart) / 1000);
if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++;
console.log('Categories import result:', results.categories);
@@ -168,26 +169,37 @@ async function main() {
}
if (IMPORT_PRODUCTS) {
const stepStart = Date.now();
results.products = await importProducts(prodConnection, localConnection, INCREMENTAL_UPDATE);
stepTimings.products = Math.round((Date.now() - stepStart) / 1000);
if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++;
console.log('Products import result:', results.products);
totalRecordsAdded += parseInt(results.products?.recordsAdded || 0);
totalRecordsUpdated += parseInt(results.products?.recordsUpdated || 0);
totalRecordsSkipped += parseInt(results.products?.skippedUnchanged || 0);
}
if (IMPORT_ORDERS) {
const stepStart = Date.now();
results.orders = await importOrders(prodConnection, localConnection, INCREMENTAL_UPDATE);
stepTimings.orders = Math.round((Date.now() - stepStart) / 1000);
if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++;
console.log('Orders import result:', results.orders);
totalRecordsAdded += parseInt(results.orders?.recordsAdded || 0);
totalRecordsUpdated += parseInt(results.orders?.recordsUpdated || 0);
totalRecordsSkipped += parseInt(results.orders?.totalSkipped || 0);
}
if (IMPORT_PURCHASE_ORDERS) {
try {
const stepStart = Date.now();
results.purchaseOrders = await importPurchaseOrders(prodConnection, localConnection, INCREMENTAL_UPDATE);
stepTimings.purchaseOrders = Math.round((Date.now() - stepStart) / 1000);
if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++;
console.log('Purchase orders import result:', results.purchaseOrders);
@@ -198,6 +210,7 @@ async function main() {
} else {
totalRecordsAdded += parseInt(results.purchaseOrders?.recordsAdded || 0);
totalRecordsUpdated += parseInt(results.purchaseOrders?.recordsUpdated || 0);
totalRecordsDeleted += parseInt(results.purchaseOrders?.recordsDeleted || 0);
}
} catch (error) {
console.error('Error during purchase orders import:', error);
@@ -211,32 +224,6 @@ async function main() {
}
}
if (IMPORT_HISTORICAL_DATA) {
try {
results.historicalData = await importHistoricalData(prodConnection, localConnection, INCREMENTAL_UPDATE);
if (isImportCancelled) throw new Error("Import cancelled");
completedSteps++;
console.log('Historical data import result:', results.historicalData);
// Handle potential error status
if (results.historicalData?.status === 'error') {
console.error('Historical data import had an error:', results.historicalData.error);
} else {
totalRecordsAdded += parseInt(results.historicalData?.recordsAdded || 0);
totalRecordsUpdated += parseInt(results.historicalData?.recordsUpdated || 0);
}
} catch (error) {
console.error('Error during historical data import:', error);
// Continue with other imports, don't fail the whole process
results.historicalData = {
status: 'error',
error: error.message,
recordsAdded: 0,
recordsUpdated: 0
};
}
}
const endTime = Date.now();
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
@@ -254,14 +241,15 @@ async function main() {
'products_enabled', $5::boolean,
'orders_enabled', $6::boolean,
'purchase_orders_enabled', $7::boolean,
'historical_data_enabled', $8::boolean,
'categories_result', COALESCE($9::jsonb, 'null'::jsonb),
'products_result', COALESCE($10::jsonb, 'null'::jsonb),
'orders_result', COALESCE($11::jsonb, 'null'::jsonb),
'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb),
'historical_data_result', COALESCE($13::jsonb, 'null'::jsonb)
'categories_result', COALESCE($8::jsonb, 'null'::jsonb),
'products_result', COALESCE($9::jsonb, 'null'::jsonb),
'orders_result', COALESCE($10::jsonb, 'null'::jsonb),
'purchase_orders_result', COALESCE($11::jsonb, 'null'::jsonb),
'total_deleted', $12::integer,
'total_skipped', $13::integer,
'step_timings', $14::jsonb
)
WHERE id = $14
WHERE id = $15
`, [
totalElapsedSeconds,
parseInt(totalRecordsAdded),
@@ -270,12 +258,13 @@ async function main() {
IMPORT_PRODUCTS,
IMPORT_ORDERS,
IMPORT_PURCHASE_ORDERS,
IMPORT_HISTORICAL_DATA,
JSON.stringify(results.categories),
JSON.stringify(results.products),
JSON.stringify(results.orders),
JSON.stringify(results.purchaseOrders),
JSON.stringify(results.historicalData),
totalRecordsDeleted,
totalRecordsSkipped,
JSON.stringify(stepTimings),
importHistoryId
]);
@@ -92,6 +92,12 @@ async function importCategories(prodConnection, localConnection) {
description = EXCLUDED.description,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at
WHERE -- Only update if at least one field has changed
categories.name IS DISTINCT FROM EXCLUDED.name OR
categories.type IS DISTINCT FROM EXCLUDED.type OR
categories.parent_id IS DISTINCT FROM EXCLUDED.parent_id OR
categories.description IS DISTINCT FROM EXCLUDED.description OR
categories.status IS DISTINCT FROM EXCLUDED.status
RETURNING
cat_id,
CASE
@@ -133,7 +139,7 @@ async function importCategories(prodConnection, localConnection) {
message: `Imported ${inserted} (updated ${updated}) categories of type ${type}`,
current: totalInserted + totalUpdated,
total: categories.length,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
elapsed: formatElapsedTime(startTime),
});
} catch (error) {
// Rollback to the savepoint for this type
@@ -161,7 +167,7 @@ async function importCategories(prodConnection, localConnection) {
operation: "Categories import completed",
current: totalInserted + totalUpdated,
total: totalInserted + totalUpdated,
duration: formatElapsedTime((Date.now() - startTime) / 1000),
duration: formatElapsedTime(startTime),
warnings: skippedCategories.length > 0 ? {
message: "Some categories were skipped due to missing parents",
skippedCategories
+516 -356
View File
@@ -26,10 +26,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
let cumulativeProcessedOrders = 0;
try {
// Begin transaction
await localConnection.beginTransaction();
// Get last sync info
// Get last sync info - NOT in a transaction anymore
const [syncInfo] = await localConnection.query(
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'"
);
@@ -43,8 +40,8 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
WHERE o.order_status >= 15
AND o.date_placed_onlydate >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR)
AND o.date_placed_onlydate IS NOT NULL
AND o.date_placed >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR)
AND o.date_placed IS NOT NULL
${incrementalUpdate ? `
AND (
o.stamp > ?
@@ -82,8 +79,8 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
WHERE o.order_status >= 15
AND o.date_placed_onlydate >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR)
AND o.date_placed_onlydate IS NOT NULL
AND o.date_placed >= DATE_SUB(CURRENT_DATE, INTERVAL ${incrementalUpdate ? '1' : '5'} YEAR)
AND o.date_placed IS NOT NULL
${incrementalUpdate ? `
AND (
o.stamp > ?
@@ -107,91 +104,131 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
console.log('Orders: Found', orderItems.length, 'order items to process');
// Create tables in PostgreSQL for data processing
await localConnection.query(`
DROP TABLE IF EXISTS temp_order_items;
DROP TABLE IF EXISTS temp_order_meta;
DROP TABLE IF EXISTS temp_order_discounts;
DROP TABLE IF EXISTS temp_order_taxes;
DROP TABLE IF EXISTS temp_order_costs;
CREATE TEMP TABLE temp_order_items (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
sku TEXT NOT NULL,
price NUMERIC(14, 4) NOT NULL,
quantity INTEGER NOT NULL,
base_discount NUMERIC(14, 4) DEFAULT 0,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_order_meta (
order_id INTEGER NOT NULL,
date TIMESTAMP WITH TIME ZONE NOT NULL,
customer TEXT NOT NULL,
customer_name TEXT NOT NULL,
status TEXT,
canceled BOOLEAN,
summary_discount NUMERIC(14, 4) DEFAULT 0.0000,
summary_subtotal NUMERIC(14, 4) DEFAULT 0.0000,
PRIMARY KEY (order_id)
);
CREATE TEMP TABLE temp_order_discounts (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
discount NUMERIC(14, 4) NOT NULL,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_order_taxes (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
tax NUMERIC(14, 4) NOT NULL,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_order_costs (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
costeach NUMERIC(14, 4) DEFAULT 0.0000,
PRIMARY KEY (order_id, pid)
);
CREATE INDEX idx_temp_order_items_pid ON temp_order_items(pid);
CREATE INDEX idx_temp_order_meta_order_id ON temp_order_meta(order_id);
`);
// Insert order items in batches
for (let i = 0; i < orderItems.length; i += 5000) {
const batch = orderItems.slice(i, Math.min(i + 5000, orderItems.length));
const placeholders = batch.map((_, idx) =>
`($${idx * 6 + 1}, $${idx * 6 + 2}, $${idx * 6 + 3}, $${idx * 6 + 4}, $${idx * 6 + 5}, $${idx * 6 + 6})`
).join(",");
const values = batch.flatMap(item => [
item.order_id, item.prod_pid, item.SKU, item.price, item.quantity, item.base_discount
]);
// Start a transaction just for creating the temp tables
await localConnection.beginTransaction();
try {
await localConnection.query(`
INSERT INTO temp_order_items (order_id, pid, sku, price, quantity, base_discount)
VALUES ${placeholders}
ON CONFLICT (order_id, pid) DO UPDATE SET
sku = EXCLUDED.sku,
price = EXCLUDED.price,
quantity = EXCLUDED.quantity,
base_discount = EXCLUDED.base_discount
`, values);
DROP TABLE IF EXISTS temp_order_items;
DROP TABLE IF EXISTS temp_order_meta;
DROP TABLE IF EXISTS temp_order_discounts;
DROP TABLE IF EXISTS temp_order_taxes;
DROP TABLE IF EXISTS temp_order_costs;
DROP TABLE IF EXISTS temp_main_discounts;
DROP TABLE IF EXISTS temp_item_discounts;
processedCount = i + batch.length;
outputProgress({
status: "running",
operation: "Orders import",
message: `Loading order items: ${processedCount} of ${totalOrderItems}`,
current: processedCount,
total: totalOrderItems,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, processedCount, totalOrderItems),
rate: calculateRate(startTime, processedCount)
});
CREATE TEMP TABLE temp_order_items (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
sku TEXT NOT NULL,
price NUMERIC(14, 4) NOT NULL,
quantity INTEGER NOT NULL,
base_discount NUMERIC(14, 4) DEFAULT 0,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_order_meta (
order_id INTEGER NOT NULL,
date TIMESTAMP WITH TIME ZONE NOT NULL,
customer TEXT NOT NULL,
customer_name TEXT NOT NULL,
status TEXT,
canceled BOOLEAN,
summary_discount NUMERIC(14, 4) DEFAULT 0.0000,
summary_subtotal NUMERIC(14, 4) DEFAULT 0.0000,
summary_discount_subtotal NUMERIC(14, 4) DEFAULT 0.0000,
PRIMARY KEY (order_id)
);
CREATE TEMP TABLE temp_order_discounts (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
discount NUMERIC(14, 4) NOT NULL,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_main_discounts (
order_id INTEGER NOT NULL,
discount_id INTEGER NOT NULL,
discount_amount_subtotal NUMERIC(14, 4) DEFAULT 0.0000,
PRIMARY KEY (order_id, discount_id)
);
CREATE TEMP TABLE temp_item_discounts (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
discount_id INTEGER NOT NULL,
amount NUMERIC(14, 4) NOT NULL,
PRIMARY KEY (order_id, pid, discount_id)
);
CREATE TEMP TABLE temp_order_taxes (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
tax NUMERIC(14, 4) NOT NULL,
PRIMARY KEY (order_id, pid)
);
CREATE TEMP TABLE temp_order_costs (
order_id INTEGER NOT NULL,
pid INTEGER NOT NULL,
costeach NUMERIC(14, 4) DEFAULT 0.0000,
PRIMARY KEY (order_id, pid)
);
CREATE INDEX idx_temp_order_items_pid ON temp_order_items(pid);
CREATE INDEX idx_temp_order_meta_order_id ON temp_order_meta(order_id);
CREATE INDEX idx_temp_order_discounts_order_pid ON temp_order_discounts(order_id, pid);
CREATE INDEX idx_temp_order_taxes_order_pid ON temp_order_taxes(order_id, pid);
CREATE INDEX idx_temp_order_costs_order_pid ON temp_order_costs(order_id, pid);
CREATE INDEX idx_temp_main_discounts_discount_id ON temp_main_discounts(discount_id);
CREATE INDEX idx_temp_item_discounts_order_pid ON temp_item_discounts(order_id, pid);
CREATE INDEX idx_temp_item_discounts_discount_id ON temp_item_discounts(discount_id);
`);
await localConnection.commit();
} catch (error) {
await localConnection.rollback();
throw error;
}
// Insert order items in batches - each batch gets its own transaction
for (let i = 0; i < orderItems.length; i += 5000) {
await localConnection.beginTransaction();
try {
const batch = orderItems.slice(i, Math.min(i + 5000, orderItems.length));
const placeholders = batch.map((_, idx) =>
`($${idx * 6 + 1}, $${idx * 6 + 2}, $${idx * 6 + 3}, $${idx * 6 + 4}, $${idx * 6 + 5}, $${idx * 6 + 6})`
).join(",");
const values = batch.flatMap(item => [
item.order_id, item.prod_pid, item.SKU, item.price, item.quantity, item.base_discount
]);
await localConnection.query(`
INSERT INTO temp_order_items (order_id, pid, sku, price, quantity, base_discount)
VALUES ${placeholders}
ON CONFLICT (order_id, pid) DO UPDATE SET
sku = EXCLUDED.sku,
price = EXCLUDED.price,
quantity = EXCLUDED.quantity,
base_discount = EXCLUDED.base_discount
`, values);
await localConnection.commit();
processedCount = i + batch.length;
outputProgress({
status: "running",
operation: "Orders import",
message: `Loading order items: ${processedCount} of ${totalOrderItems}`,
current: processedCount,
total: totalOrderItems,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalOrderItems),
rate: calculateRate(startTime, processedCount)
});
} catch (error) {
await localConnection.rollback();
throw error;
}
}
// Get unique order IDs
@@ -218,86 +255,162 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
const [orders] = await prodConnection.query(`
SELECT
o.order_id,
o.date_placed_onlydate as date,
o.date_placed as date,
o.order_cid as customer,
CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) as customer_name,
o.order_status as status,
CASE WHEN o.date_cancelled != '0000-00-00 00:00:00' THEN 1 ELSE 0 END as canceled,
o.summary_discount,
o.summary_subtotal
o.summary_subtotal,
o.summary_discount_subtotal
FROM _order o
LEFT JOIN users u ON o.order_cid = u.cid
WHERE o.order_id IN (?)
`, [batchIds]);
// Process in sub-batches for PostgreSQL
for (let j = 0; j < orders.length; j += PG_BATCH_SIZE) {
const subBatch = orders.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
await localConnection.beginTransaction();
try {
for (let j = 0; j < orders.length; j += PG_BATCH_SIZE) {
const subBatch = orders.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
const placeholders = subBatch.map((_, idx) =>
`($${idx * 8 + 1}, $${idx * 8 + 2}, $${idx * 8 + 3}, $${idx * 8 + 4}, $${idx * 8 + 5}, $${idx * 8 + 6}, $${idx * 8 + 7}, $${idx * 8 + 8})`
).join(",");
const values = subBatch.flatMap(order => [
order.order_id,
new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE
order.customer,
toTitleCase(order.customer_name) || '',
order.status.toString(), // Convert status to TEXT
order.canceled,
order.summary_discount || 0,
order.summary_subtotal || 0
]);
const placeholders = subBatch.map((_, idx) =>
`($${idx * 9 + 1}, $${idx * 9 + 2}, $${idx * 9 + 3}, $${idx * 9 + 4}, $${idx * 9 + 5}, $${idx * 9 + 6}, $${idx * 9 + 7}, $${idx * 9 + 8}, $${idx * 9 + 9})`
).join(",");
const values = subBatch.flatMap(order => [
order.order_id,
new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE
order.customer,
toTitleCase(order.customer_name) || '',
order.status.toString(), // Convert status to TEXT
order.canceled,
order.summary_discount || 0,
order.summary_subtotal || 0,
order.summary_discount_subtotal || 0
]);
await localConnection.query(`
INSERT INTO temp_order_meta (
order_id, date, customer, customer_name, status, canceled,
summary_discount, summary_subtotal
)
VALUES ${placeholders}
ON CONFLICT (order_id) DO UPDATE SET
date = EXCLUDED.date,
customer = EXCLUDED.customer,
customer_name = EXCLUDED.customer_name,
status = EXCLUDED.status,
canceled = EXCLUDED.canceled,
summary_discount = EXCLUDED.summary_discount,
summary_subtotal = EXCLUDED.summary_subtotal
`, values);
await localConnection.query(`
INSERT INTO temp_order_meta (
order_id, date, customer, customer_name, status, canceled,
summary_discount, summary_subtotal, summary_discount_subtotal
)
VALUES ${placeholders}
ON CONFLICT (order_id) DO UPDATE SET
date = EXCLUDED.date,
customer = EXCLUDED.customer,
customer_name = EXCLUDED.customer_name,
status = EXCLUDED.status,
canceled = EXCLUDED.canceled,
summary_discount = EXCLUDED.summary_discount,
summary_subtotal = EXCLUDED.summary_subtotal,
summary_discount_subtotal = EXCLUDED.summary_discount_subtotal
`, values);
}
await localConnection.commit();
} catch (error) {
await localConnection.rollback();
throw error;
}
};
const processDiscountsBatch = async (batchIds) => {
// First, load main discount records
const [mainDiscounts] = await prodConnection.query(`
SELECT order_id, discount_id, discount_amount_subtotal
FROM order_discounts
WHERE order_id IN (?)
`, [batchIds]);
if (mainDiscounts.length > 0) {
await localConnection.beginTransaction();
try {
for (let j = 0; j < mainDiscounts.length; j += PG_BATCH_SIZE) {
const subBatch = mainDiscounts.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(",");
const values = subBatch.flatMap(d => [
d.order_id,
d.discount_id,
d.discount_amount_subtotal || 0
]);
await localConnection.query(`
INSERT INTO temp_main_discounts (order_id, discount_id, discount_amount_subtotal)
VALUES ${placeholders}
ON CONFLICT (order_id, discount_id) DO UPDATE SET
discount_amount_subtotal = EXCLUDED.discount_amount_subtotal
`, values);
}
await localConnection.commit();
} catch (error) {
await localConnection.rollback();
throw error;
}
}
// Then, load item discount records
const [discounts] = await prodConnection.query(`
SELECT order_id, pid, SUM(amount) as discount
SELECT order_id, pid, discount_id, amount
FROM order_discount_items
WHERE order_id IN (?)
GROUP BY order_id, pid
`, [batchIds]);
if (discounts.length === 0) return;
for (let j = 0; j < discounts.length; j += PG_BATCH_SIZE) {
const subBatch = discounts.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
// Process in memory to handle potential duplicates
const discountMap = new Map();
for (const d of discounts) {
const key = `${d.order_id}-${d.pid}-${d.discount_id}`;
discountMap.set(key, d);
}
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(",");
const values = subBatch.flatMap(d => [
d.order_id,
d.pid,
d.discount || 0
]);
const uniqueDiscounts = Array.from(discountMap.values());
await localConnection.beginTransaction();
try {
for (let j = 0; j < uniqueDiscounts.length; j += PG_BATCH_SIZE) {
const subBatch = uniqueDiscounts.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
const placeholders = subBatch.map((_, idx) =>
`($${idx * 4 + 1}, $${idx * 4 + 2}, $${idx * 4 + 3}, $${idx * 4 + 4})`
).join(",");
const values = subBatch.flatMap(d => [
d.order_id,
d.pid,
d.discount_id,
d.amount || 0
]);
await localConnection.query(`
INSERT INTO temp_item_discounts (order_id, pid, discount_id, amount)
VALUES ${placeholders}
ON CONFLICT (order_id, pid, discount_id) DO UPDATE SET
amount = EXCLUDED.amount
`, values);
}
// Create aggregated view with a simpler, safer query that avoids duplicates
await localConnection.query(`
TRUNCATE temp_order_discounts;
INSERT INTO temp_order_discounts (order_id, pid, discount)
VALUES ${placeholders}
ON CONFLICT (order_id, pid) DO UPDATE SET
discount = EXCLUDED.discount
`, values);
SELECT order_id, pid, SUM(amount) as discount
FROM temp_item_discounts
GROUP BY order_id, pid
`);
await localConnection.commit();
} catch (error) {
await localConnection.rollback();
throw error;
}
};
@@ -318,26 +431,33 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
if (taxes.length === 0) return;
for (let j = 0; j < taxes.length; j += PG_BATCH_SIZE) {
const subBatch = taxes.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
await localConnection.beginTransaction();
try {
for (let j = 0; j < taxes.length; j += PG_BATCH_SIZE) {
const subBatch = taxes.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(",");
const values = subBatch.flatMap(t => [
t.order_id,
t.pid,
t.tax || 0
]);
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(",");
const values = subBatch.flatMap(t => [
t.order_id,
t.pid,
t.tax || 0
]);
await localConnection.query(`
INSERT INTO temp_order_taxes (order_id, pid, tax)
VALUES ${placeholders}
ON CONFLICT (order_id, pid) DO UPDATE SET
tax = EXCLUDED.tax
`, values);
await localConnection.query(`
INSERT INTO temp_order_taxes (order_id, pid, tax)
VALUES ${placeholders}
ON CONFLICT (order_id, pid) DO UPDATE SET
tax = EXCLUDED.tax
`, values);
}
await localConnection.commit();
} catch (error) {
await localConnection.rollback();
throw error;
}
};
@@ -363,39 +483,45 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
if (costs.length === 0) return;
for (let j = 0; j < costs.length; j += PG_BATCH_SIZE) {
const subBatch = costs.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
await localConnection.beginTransaction();
try {
for (let j = 0; j < costs.length; j += PG_BATCH_SIZE) {
const subBatch = costs.slice(j, j + PG_BATCH_SIZE);
if (subBatch.length === 0) continue;
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(",");
const values = subBatch.flatMap(c => [
c.order_id,
c.pid,
c.costeach || 0
]);
const placeholders = subBatch.map((_, idx) =>
`($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`
).join(",");
const values = subBatch.flatMap(c => [
c.order_id,
c.pid,
c.costeach || 0
]);
await localConnection.query(`
INSERT INTO temp_order_costs (order_id, pid, costeach)
VALUES ${placeholders}
ON CONFLICT (order_id, pid) DO UPDATE SET
costeach = EXCLUDED.costeach
`, values);
await localConnection.query(`
INSERT INTO temp_order_costs (order_id, pid, costeach)
VALUES ${placeholders}
ON CONFLICT (order_id, pid) DO UPDATE SET
costeach = EXCLUDED.costeach
`, values);
}
await localConnection.commit();
} catch (error) {
await localConnection.rollback();
throw error;
}
};
// Process all data types in parallel for each batch
// Process all data types SEQUENTIALLY for each batch - not in parallel
for (let i = 0; i < orderIds.length; i += METADATA_BATCH_SIZE) {
const batchIds = orderIds.slice(i, i + METADATA_BATCH_SIZE);
await Promise.all([
processMetadataBatch(batchIds),
processDiscountsBatch(batchIds),
processTaxesBatch(batchIds),
processCostsBatch(batchIds)
]);
// Run these sequentially instead of in parallel to avoid transaction conflicts
await processMetadataBatch(batchIds);
await processDiscountsBatch(batchIds);
await processTaxesBatch(batchIds);
await processCostsBatch(batchIds);
processedCount = i + batchIds.length;
outputProgress({
@@ -404,8 +530,8 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
message: `Loading order data: ${processedCount} of ${totalUniqueOrders}`,
current: processedCount,
total: totalUniqueOrders,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, processedCount, totalUniqueOrders),
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalUniqueOrders),
rate: calculateRate(startTime, processedCount)
});
}
@@ -422,175 +548,210 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
const existingPids = new Set(existingProducts.rows.map(p => p.pid));
// Process in smaller batches
for (let i = 0; i < orderIds.length; i += 1000) {
const batchIds = orderIds.slice(i, i + 1000);
for (let i = 0; i < orderIds.length; i += 2000) { // Increased from 1000 to 2000
const batchIds = orderIds.slice(i, i + 2000);
// Get combined data for this batch in sub-batches
const PG_BATCH_SIZE = 100; // Process 100 records at a time
const PG_BATCH_SIZE = 200; // Increased from 100 to 200
for (let j = 0; j < batchIds.length; j += PG_BATCH_SIZE) {
const subBatchIds = batchIds.slice(j, j + PG_BATCH_SIZE);
const [orders] = await localConnection.query(`
WITH order_totals AS (
SELECT
oi.order_id,
oi.pid,
SUM(COALESCE(od.discount, 0)) as promo_discount,
COALESCE(ot.tax, 0) as total_tax,
COALESCE(oc.costeach, oi.price * 0.5) as costeach
FROM temp_order_items oi
LEFT JOIN temp_order_discounts od ON oi.order_id = od.order_id AND oi.pid = od.pid
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach
)
SELECT
oi.order_id as order_number,
oi.pid::bigint as pid,
oi.sku,
om.date,
oi.price,
oi.quantity,
(oi.base_discount +
COALESCE(ot.promo_discount, 0) +
CASE
WHEN om.summary_discount > 0 AND om.summary_subtotal > 0 THEN
ROUND((om.summary_discount * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 2)
ELSE 0
END)::NUMERIC(14, 4) as discount,
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
false as tax_included,
0 as shipping,
om.customer,
om.customer_name,
om.status,
om.canceled,
COALESCE(ot.costeach, oi.price * 0.5)::NUMERIC(14, 4) as costeach
FROM (
SELECT DISTINCT ON (order_id, pid)
order_id, pid, sku, price, quantity, base_discount
FROM temp_order_items
WHERE order_id = ANY($1)
ORDER BY order_id, pid
) oi
JOIN temp_order_meta om ON oi.order_id = om.order_id
LEFT JOIN order_totals ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
ORDER BY oi.order_id, oi.pid
`, [subBatchIds]);
// Filter orders and track missing products
const validOrders = [];
const processedOrderItems = new Set();
const processedOrders = new Set();
for (const order of orders.rows) {
if (!existingPids.has(order.pid)) {
missingProducts.add(order.pid);
skippedOrders.add(order.order_number);
continue;
}
validOrders.push(order);
processedOrderItems.add(`${order.order_number}-${order.pid}`);
processedOrders.add(order.order_number);
}
// Process valid orders in smaller sub-batches
const FINAL_BATCH_SIZE = 50;
for (let k = 0; k < validOrders.length; k += FINAL_BATCH_SIZE) {
const subBatch = validOrders.slice(k, k + FINAL_BATCH_SIZE);
const placeholders = subBatch.map((_, idx) => {
const base = idx * 15; // 15 columns including costeach
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`;
}).join(',');
const batchValues = subBatch.flatMap(o => [
o.order_number,
o.pid,
o.sku || 'NO-SKU',
o.date, // This is now a TIMESTAMP WITH TIME ZONE
o.price,
o.quantity,
o.discount,
o.tax,
o.tax_included,
o.shipping,
o.customer,
o.customer_name,
o.status.toString(), // Convert status to TEXT
o.canceled,
o.costeach
]);
const [result] = await localConnection.query(`
WITH inserted_orders AS (
INSERT INTO orders (
order_number, pid, sku, date, price, quantity, discount,
tax, tax_included, shipping, customer, customer_name,
status, canceled, costeach
)
VALUES ${placeholders}
ON CONFLICT (order_number, pid) DO UPDATE SET
sku = EXCLUDED.sku,
date = EXCLUDED.date,
price = EXCLUDED.price,
quantity = EXCLUDED.quantity,
discount = EXCLUDED.discount,
tax = EXCLUDED.tax,
tax_included = EXCLUDED.tax_included,
shipping = EXCLUDED.shipping,
customer = EXCLUDED.customer,
customer_name = EXCLUDED.customer_name,
status = EXCLUDED.status,
canceled = EXCLUDED.canceled,
costeach = EXCLUDED.costeach
RETURNING xmax = 0 as inserted
// Start a transaction for this sub-batch
await localConnection.beginTransaction();
try {
const [orders] = await localConnection.query(`
WITH order_totals AS (
SELECT
oi.order_id,
oi.pid,
-- Instead of using ARRAY_AGG which can cause duplicate issues, use SUM with a CASE
SUM(CASE
WHEN COALESCE(md.discount_amount_subtotal, 0) > 0 THEN id.amount
ELSE 0
END) as promo_discount_sum,
COALESCE(ot.tax, 0) as total_tax,
COALESCE(oc.costeach, oi.price * 0.5) as costeach
FROM temp_order_items oi
LEFT JOIN temp_item_discounts id ON oi.order_id = id.order_id AND oi.pid = id.pid
LEFT JOIN temp_main_discounts md ON id.order_id = md.order_id AND id.discount_id = md.discount_id
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
WHERE oi.order_id = ANY($1)
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach
)
SELECT
COUNT(*) FILTER (WHERE inserted) as inserted,
COUNT(*) FILTER (WHERE NOT inserted) as updated
FROM inserted_orders
`, batchValues);
const { inserted, updated } = result.rows[0];
recordsAdded += parseInt(inserted) || 0;
recordsUpdated += parseInt(updated) || 0;
importedCount += subBatch.length;
}
oi.order_id as order_number,
oi.pid::bigint as pid,
oi.sku,
om.date,
oi.price,
oi.quantity,
(
-- Part 1: Sale Savings for the Line
(oi.base_discount * oi.quantity)
+
-- Part 2: Prorated Points Discount (if applicable)
CASE
WHEN om.summary_discount_subtotal > 0 AND om.summary_subtotal > 0 THEN
COALESCE(ROUND((om.summary_discount_subtotal * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 4), 0)
ELSE 0
END
+
-- Part 3: Specific Item-Level Discount (only if parent discount affected subtotal)
COALESCE(ot.promo_discount_sum, 0)
)::NUMERIC(14, 4) as discount,
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
false as tax_included,
0 as shipping,
om.customer,
om.customer_name,
om.status,
om.canceled,
COALESCE(ot.costeach, oi.price * 0.5)::NUMERIC(14, 4) as costeach
FROM temp_order_items oi
JOIN temp_order_meta om ON oi.order_id = om.order_id
LEFT JOIN order_totals ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
WHERE oi.order_id = ANY($1)
ORDER BY oi.order_id, oi.pid
`, [subBatchIds]);
cumulativeProcessedOrders += processedOrders.size;
outputProgress({
status: "running",
operation: "Orders import",
message: `Importing orders: ${cumulativeProcessedOrders} of ${totalUniqueOrders}`,
current: cumulativeProcessedOrders,
total: totalUniqueOrders,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, cumulativeProcessedOrders, totalUniqueOrders),
rate: calculateRate(startTime, cumulativeProcessedOrders)
});
// Filter orders and track missing products
const validOrders = [];
const processedOrderItems = new Set();
const processedOrders = new Set();
for (const order of orders.rows) {
if (!existingPids.has(order.pid)) {
missingProducts.add(order.pid);
skippedOrders.add(order.order_number);
continue;
}
validOrders.push(order);
processedOrderItems.add(`${order.order_number}-${order.pid}`);
processedOrders.add(order.order_number);
}
// Process valid orders in smaller sub-batches
const FINAL_BATCH_SIZE = 100; // Increased from 50 to 100
for (let k = 0; k < validOrders.length; k += FINAL_BATCH_SIZE) {
const subBatch = validOrders.slice(k, k + FINAL_BATCH_SIZE);
const placeholders = subBatch.map((_, idx) => {
const base = idx * 15; // 15 columns including costeach
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`;
}).join(',');
const batchValues = subBatch.flatMap(o => [
o.order_number,
o.pid,
o.sku || 'NO-SKU',
o.date, // This is now a TIMESTAMP WITH TIME ZONE
o.price,
o.quantity,
o.discount,
o.tax,
o.tax_included,
o.shipping,
o.customer,
o.customer_name,
o.status.toString(), // Convert status to TEXT
o.canceled,
o.costeach
]);
const [result] = await localConnection.query(`
WITH inserted_orders AS (
INSERT INTO orders (
order_number, pid, sku, date, price, quantity, discount,
tax, tax_included, shipping, customer, customer_name,
status, canceled, costeach
)
VALUES ${placeholders}
ON CONFLICT (order_number, pid) DO UPDATE SET
sku = EXCLUDED.sku,
date = EXCLUDED.date,
price = EXCLUDED.price,
quantity = EXCLUDED.quantity,
discount = EXCLUDED.discount,
tax = EXCLUDED.tax,
tax_included = EXCLUDED.tax_included,
shipping = EXCLUDED.shipping,
customer = EXCLUDED.customer,
customer_name = EXCLUDED.customer_name,
status = EXCLUDED.status,
canceled = EXCLUDED.canceled,
costeach = EXCLUDED.costeach
WHERE -- Only update if at least one key field has changed
orders.price IS DISTINCT FROM EXCLUDED.price OR
orders.quantity IS DISTINCT FROM EXCLUDED.quantity OR
orders.discount IS DISTINCT FROM EXCLUDED.discount OR
orders.tax IS DISTINCT FROM EXCLUDED.tax OR
orders.status IS DISTINCT FROM EXCLUDED.status OR
orders.canceled IS DISTINCT FROM EXCLUDED.canceled OR
orders.costeach IS DISTINCT FROM EXCLUDED.costeach OR
orders.date IS DISTINCT FROM EXCLUDED.date
RETURNING xmax = 0 as inserted
)
SELECT
COUNT(*) FILTER (WHERE inserted) as inserted,
COUNT(*) FILTER (WHERE NOT inserted) as updated
FROM inserted_orders
`, batchValues);
const { inserted, updated } = result.rows[0];
recordsAdded += parseInt(inserted) || 0;
recordsUpdated += parseInt(updated) || 0;
importedCount += subBatch.length;
}
await localConnection.commit();
cumulativeProcessedOrders += processedOrders.size;
outputProgress({
status: "running",
operation: "Orders import",
message: `Importing orders: ${cumulativeProcessedOrders} of ${totalUniqueOrders}`,
current: cumulativeProcessedOrders,
total: totalUniqueOrders,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, cumulativeProcessedOrders, totalUniqueOrders),
rate: calculateRate(startTime, cumulativeProcessedOrders)
});
} catch (error) {
await localConnection.rollback();
throw error;
}
}
}
// Update sync status
await localConnection.query(`
INSERT INTO sync_status (table_name, last_sync_timestamp)
VALUES ('orders', NOW())
ON CONFLICT (table_name) DO UPDATE SET
last_sync_timestamp = NOW()
`);
// Cleanup temporary tables
await localConnection.query(`
DROP TABLE IF EXISTS temp_order_items;
DROP TABLE IF EXISTS temp_order_meta;
DROP TABLE IF EXISTS temp_order_discounts;
DROP TABLE IF EXISTS temp_order_taxes;
DROP TABLE IF EXISTS temp_order_costs;
`);
// Commit transaction
await localConnection.commit();
// Start a transaction for updating sync status and dropping temp tables
await localConnection.beginTransaction();
try {
// Update sync status
await localConnection.query(`
INSERT INTO sync_status (table_name, last_sync_timestamp)
VALUES ('orders', NOW())
ON CONFLICT (table_name) DO UPDATE SET
last_sync_timestamp = NOW()
`);
// Cleanup temporary tables
await localConnection.query(`
DROP TABLE IF EXISTS temp_order_items;
DROP TABLE IF EXISTS temp_order_meta;
DROP TABLE IF EXISTS temp_order_discounts;
DROP TABLE IF EXISTS temp_order_taxes;
DROP TABLE IF EXISTS temp_order_costs;
DROP TABLE IF EXISTS temp_main_discounts;
DROP TABLE IF EXISTS temp_item_discounts;
`);
// Commit final transaction
await localConnection.commit();
} catch (error) {
await localConnection.rollback();
throw error;
}
return {
status: "complete",
@@ -599,21 +760,20 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
recordsUpdated: parseInt(recordsUpdated) || 0,
totalSkipped: skippedOrders.size || 0,
missingProducts: missingProducts.size || 0,
totalProcessed: orderItems.length, // Total order items in source
incrementalUpdate,
lastSyncTime
lastSyncTime,
details: {
uniqueOrdersProcessed: cumulativeProcessedOrders,
totalOrderItems: orderItems.length,
skippedDueToMissingProducts: skippedOrders.size,
missingProductIds: Array.from(missingProducts).slice(0, 100) // First 100 for debugging
}
};
} catch (error) {
console.error("Error during orders import:", error);
// Rollback transaction
try {
await localConnection.rollback();
} catch (rollbackError) {
console.error("Error during rollback:", rollbackError);
}
throw error;
}
}
module.exports = importOrders;
module.exports = importOrders;
+89 -18
View File
@@ -98,6 +98,7 @@ async function setupTemporaryTables(connection) {
baskets INTEGER,
notifies INTEGER,
date_last_sold TIMESTAMP WITH TIME ZONE,
primary_iid INTEGER,
image TEXT,
image_175 TEXT,
image_full TEXT,
@@ -193,8 +194,12 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
p.country_of_origin,
(SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets,
(SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies,
(SELECT COALESCE(SUM(oi.qty_ordered), 0) FROM order_items oi WHERE oi.prod_pid = p.pid) AS total_sold,
(SELECT COALESCE(SUM(oi.qty_ordered), 0)
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
pls.date_sold as date_last_sold,
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
GROUP_CONCAT(DISTINCT CASE
WHEN pc.cat_id IS NOT NULL
AND pc.type IN (10, 20, 11, 21, 12, 13)
@@ -233,12 +238,12 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
const batch = prodData.slice(i, i + BATCH_SIZE);
const placeholders = batch.map((_, idx) => {
const base = idx * 47; // 47 columns
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
const base = idx * 48; // 48 columns
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(',');
const values = batch.flatMap(row => {
const imageUrls = getImageUrls(row.pid);
const imageUrls = getImageUrls(row.pid, row.primary_iid || 1);
return [
row.pid,
row.title,
@@ -282,6 +287,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
row.baskets,
row.notifies,
validateDate(row.date_last_sold),
row.primary_iid,
imageUrls.image,
imageUrls.image_175,
imageUrls.image_full,
@@ -299,7 +305,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
weight, length, width, height, country_of_origin, location, total_sold,
baskets, notifies, date_last_sold, image, image_175, image_full, options, tags
baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags
)
VALUES ${placeholders}
ON CONFLICT (pid) DO NOTHING
@@ -394,8 +400,12 @@ async function materializeCalculations(prodConnection, localConnection, incremen
p.country_of_origin,
(SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets,
(SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies,
(SELECT COALESCE(SUM(oi.qty_ordered), 0) FROM order_items oi WHERE oi.prod_pid = p.pid) AS total_sold,
(SELECT COALESCE(SUM(oi.qty_ordered), 0)
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
pls.date_sold as date_last_sold,
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
GROUP_CONCAT(DISTINCT CASE
WHEN pc.cat_id IS NOT NULL
AND pc.type IN (10, 20, 11, 21, 12, 13)
@@ -422,9 +432,11 @@ async function materializeCalculations(prodConnection, localConnection, incremen
pcp.date_deactive > ? OR
pcp.date_active > ? OR
pnb.date_updated > ?
-- Add condition for product_images changes if needed for incremental updates
-- OR EXISTS (SELECT 1 FROM product_images pi WHERE pi.pid = p.pid AND pi.stamp > ?)
` : 'TRUE'}
GROUP BY p.pid
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime] : []);
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime /*, lastSyncTime */] : []);
outputProgress({
status: "running",
@@ -438,12 +450,12 @@ async function materializeCalculations(prodConnection, localConnection, incremen
await withRetry(async () => {
const placeholders = batch.map((_, idx) => {
const base = idx * 47; // 47 columns
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
const base = idx * 48; // 48 columns
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(',');
const values = batch.flatMap(row => {
const imageUrls = getImageUrls(row.pid);
const imageUrls = getImageUrls(row.pid, row.primary_iid || 1);
return [
row.pid,
row.title,
@@ -487,6 +499,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
row.baskets,
row.notifies,
validateDate(row.date_last_sold),
row.primary_iid,
imageUrls.image,
imageUrls.image_175,
imageUrls.image_full,
@@ -503,7 +516,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
weight, length, width, height, country_of_origin, location, total_sold,
baskets, notifies, date_last_sold, image, image_175, image_full, options, tags
baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags
) VALUES ${placeholders}
ON CONFLICT (pid) DO UPDATE SET
title = EXCLUDED.title,
@@ -546,6 +559,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
baskets = EXCLUDED.baskets,
notifies = EXCLUDED.notifies,
date_last_sold = EXCLUDED.date_last_sold,
primary_iid = EXCLUDED.primary_iid,
image = EXCLUDED.image,
image_175 = EXCLUDED.image_175,
image_full = EXCLUDED.image_full,
@@ -562,8 +576,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
message: `Imported ${i + batch.length} of ${prodData.length} products`,
current: i + batch.length,
total: prodData.length,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, i + batch.length, prodData.length),
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, i + batch.length, prodData.length),
rate: calculateRate(startTime, i + batch.length)
});
}
@@ -573,6 +587,59 @@ async function materializeCalculations(prodConnection, localConnection, incremen
operation: "Products import",
message: "Finished materializing calculations"
});
// Add step to identify which products actually need updating
outputProgress({
status: "running",
operation: "Products import",
message: "Identifying changed products"
});
// Mark products that haven't changed as needs_update = false
await localConnection.query(`
UPDATE temp_products t
SET needs_update = FALSE
FROM products p
WHERE t.pid = p.pid
AND t.title IS NOT DISTINCT FROM p.title
AND t.description IS NOT DISTINCT FROM p.description
AND t.sku IS NOT DISTINCT FROM p.sku
AND t.stock_quantity = p.stock_quantity
AND t.price = p.price
AND t.regular_price = p.regular_price
AND t.cost_price IS NOT DISTINCT FROM p.cost_price
AND t.vendor IS NOT DISTINCT FROM p.vendor
AND t.brand IS NOT DISTINCT FROM p.brand
AND t.visible = p.visible
AND t.replenishable = p.replenishable
AND t.barcode IS NOT DISTINCT FROM p.barcode
AND t.updated_at IS NOT DISTINCT FROM p.updated_at
AND t.total_sold IS NOT DISTINCT FROM p.total_sold
-- Check key fields that are likely to change
-- We don't need to check every single field, just the important ones
`);
// Get count of products that need updating
const [countResult] = await localConnection.query(`
SELECT
COUNT(*) FILTER (WHERE needs_update = true) as update_count,
COUNT(*) FILTER (WHERE needs_update = false) as skip_count,
COUNT(*) as total_count
FROM temp_products
`);
outputProgress({
status: "running",
operation: "Products import",
message: `Found ${countResult.rows[0].update_count} products that need updating, ${countResult.rows[0].skip_count} unchanged`
});
// Return the total products processed
return {
totalProcessed: prodData.length,
needsUpdate: parseInt(countResult.rows[0].update_count),
skipped: parseInt(countResult.rows[0].skip_count)
};
}
async function importProducts(prodConnection, localConnection, incrementalUpdate = true) {
@@ -598,7 +665,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
await setupTemporaryTables(localConnection);
// Materialize calculations into temp table
await materializeCalculations(prodConnection, localConnection, incrementalUpdate, lastSyncTime, startTime);
const materializeResult = await materializeCalculations(prodConnection, localConnection, incrementalUpdate, lastSyncTime, startTime);
// Get the list of products that need updating
const [products] = await localConnection.query(`
@@ -644,6 +711,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
t.baskets,
t.notifies,
t.date_last_sold,
t.primary_iid,
t.image,
t.image_175,
t.image_full,
@@ -666,7 +734,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
}).join(',');
const values = batch.flatMap(row => {
const imageUrls = getImageUrls(row.pid);
const imageUrls = getImageUrls(row.pid, row.primary_iid || 1);
return [
row.pid,
row.title,
@@ -832,8 +900,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
message: `Processing products: ${i + batch.length} of ${products.rows.length}`,
current: i + batch.length,
total: products.rows.length,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, i + batch.length, products.rows.length),
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, i + batch.length, products.rows.length),
rate: calculateRate(startTime, i + batch.length)
});
}
@@ -857,7 +925,10 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
recordsAdded,
recordsUpdated,
totalRecords: products.rows.length,
duration: formatElapsedTime(Date.now() - startTime)
totalProcessed: materializeResult.totalProcessed,
duration: formatElapsedTime(startTime),
needsUpdate: materializeResult.needsUpdate,
skippedUnchanged: materializeResult.skipped
};
} catch (error) {
// Rollback on error
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
-- historically backfilled daily_product_snapshots and current product/PO data.
-- Calculates all metrics considering the full available history up to 'yesterday'.
-- Run ONCE after backfill_historical_snapshots_final.sql completes successfully.
-- Dependencies: Core import tables (products, purchase_orders), daily_product_snapshots (historically populated),
-- Dependencies: Core import tables (products, purchase_orders, receivings), daily_product_snapshots (historically populated),
-- configuration tables (settings_*), product_metrics table must exist.
-- Frequency: Run ONCE.
DO $$
@@ -31,42 +31,34 @@ BEGIN
p.stock_quantity as current_stock, -- Use actual current stock for forecast base
p.created_at, p.first_received, p.date_last_sold,
p.moq,
p.uom
p.uom,
p.total_sold as historical_total_sold -- Add historical total_sold from products table
FROM public.products p
),
OnOrderInfo AS (
-- Calculates current on-order quantities and costs
SELECT
pid,
COALESCE(SUM(ordered - received), 0) AS on_order_qty,
COALESCE(SUM((ordered - received) * cost_price), 0.00) AS on_order_cost,
SUM(ordered) AS on_order_qty,
SUM(ordered * po_cost_price) AS on_order_cost,
MIN(expected_date) AS earliest_expected_date
FROM public.purchase_orders
-- Use the most common statuses representing active, unfulfilled POs
WHERE status IN ('open', 'partially_received', 'ordered', 'preordered', 'receiving_started', 'electronically_sent', 'electronically_ready_send')
AND (ordered - received) > 0
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
AND status NOT IN ('canceled', 'done')
GROUP BY pid
),
HistoricalDates AS (
-- Determines key historical dates from orders and PO history (receiving_history)
-- Determines key historical dates from orders and receivings
SELECT
p.pid,
MIN(o.date)::date AS date_first_sold,
MAX(o.date)::date AS max_order_date, -- Used as fallback for date_last_sold
MIN(rh.first_receipt_date) AS date_first_received_calc,
MAX(rh.last_receipt_date) AS date_last_received_calc
MIN(r.received_date)::date AS date_first_received_calc,
MAX(r.received_date)::date AS date_last_received_calc
FROM public.products p
LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned')
LEFT JOIN (
SELECT
po.pid,
MIN((rh.item->>'received_at')::date) as first_receipt_date,
MAX((rh.item->>'received_at')::date) as last_receipt_date
FROM public.purchase_orders po
CROSS JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item)
WHERE jsonb_typeof(po.receiving_history) = 'array' AND jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
) rh ON p.pid = rh.pid
LEFT JOIN public.receivings r ON p.pid = r.pid
GROUP BY p.pid
),
SnapshotAggregates AS (
@@ -99,9 +91,30 @@ BEGIN
AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_retail END) AS avg_stock_retail_30d,
AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_gross END) AS avg_stock_gross_30d,
-- Lifetime (Sum over ALL available snapshots up to calculation date)
SUM(units_sold) AS lifetime_sales,
SUM(net_revenue) AS lifetime_revenue,
-- Lifetime (Using historical total from products table)
(SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) AS lifetime_sales,
COALESCE(
-- Option 1: Use 30-day average price if available
CASE WHEN SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END) > 0 THEN
(SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) * (
SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN net_revenue ELSE 0 END) /
NULLIF(SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END), 0)
)
ELSE NULL END,
-- Option 2: Try 365-day average price if available
CASE WHEN SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END) > 0 THEN
(SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) * (
SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN net_revenue ELSE 0 END) /
NULLIF(SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END), 0)
)
ELSE NULL END,
-- Option 3: Use current price from products table
(SELECT total_sold * price FROM public.products WHERE public.products.pid = daily_product_snapshots.pid),
-- Option 4: Use regular price if current price might be zero
(SELECT total_sold * regular_price FROM public.products WHERE public.products.pid = daily_product_snapshots.pid),
-- Final fallback: Use accumulated revenue (less accurate for old products)
SUM(net_revenue)
) AS lifetime_revenue,
-- Yesterday (Sales for the specific _calculation_date)
SUM(CASE WHEN snapshot_date = _calculation_date THEN units_sold ELSE 0 END) as yesterday_sales
@@ -143,22 +156,23 @@ BEGIN
LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor
),
AvgLeadTime AS (
-- Calculate Average Lead Time from historical POs
-- Calculate Average Lead Time by joining purchase_orders with receivings
SELECT
pid,
po.pid,
AVG(GREATEST(1,
CASE
WHEN last_received_date IS NOT NULL AND date IS NOT NULL
THEN (last_received_date::date - date::date)
WHEN r.received_date IS NOT NULL AND po.date IS NOT NULL
THEN (r.received_date::date - po.date::date)
ELSE 1
END
))::int AS avg_lead_time_days_calc
FROM public.purchase_orders
WHERE status = 'received' -- Assumes 'received' marks full receipt
AND last_received_date IS NOT NULL
AND date IS NOT NULL
AND last_received_date >= date
GROUP BY pid
FROM public.purchase_orders po
JOIN public.receivings r ON r.pid = po.pid
WHERE po.status = 'done' -- Completed POs
AND r.received_date IS NOT NULL
AND po.date IS NOT NULL
AND r.received_date >= po.date
GROUP BY po.pid
),
RankedForABC AS (
-- Ranks products based on the configured ABC metric (using historical data)
@@ -176,7 +190,7 @@ BEGIN
WHEN 'sales_30d' THEN COALESCE(sa.sales_30d, 0)
WHEN 'lifetime_revenue' THEN COALESCE(sa.lifetime_revenue, 0)::numeric
ELSE COALESCE(sa.revenue_30d, 0)
END) > 0 -- Exclude zero-value products from ranking
END) > 0 -- Only include products with non-zero contribution
),
CumulativeABC AS (
-- Calculates cumulative metric values for ABC ranking
@@ -0,0 +1,152 @@
-- Description: Rebuilds daily product snapshots from scratch using real orders data.
-- Fixes issues with duplicated/inflated metrics.
-- Dependencies: Core import tables (products, orders, receivings).
-- Frequency: One-time run to clear out problematic data.
DO $$
DECLARE
_module_name TEXT := 'rebuild_daily_snapshots';
_start_time TIMESTAMPTZ := clock_timestamp();
_date DATE;
_count INT;
_total_records INT := 0;
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2024-01-01'); -- Starting point for data rebuild
_end_date DATE := CURRENT_DATE;
BEGIN
RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time;
-- First truncate the existing snapshots to ensure a clean slate
TRUNCATE TABLE public.daily_product_snapshots;
RAISE NOTICE 'Cleared existing snapshot data';
-- Now rebuild the snapshots day by day
_date := _begin_date;
WHILE _date <= _end_date LOOP
RAISE NOTICE 'Processing date %...', _date;
-- Create snapshots for this date
WITH SalesData AS (
SELECT
p.pid,
p.sku,
-- Count orders to ensure we only include products with real activity
COUNT(o.id) as order_count,
-- Aggregate Sales (Quantity > 0, Status not Canceled/Returned)
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
-- Aggregate Returns (Quantity < 0 or Status = Returned)
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned,
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue
FROM public.products p
LEFT JOIN public.orders o
ON p.pid = o.pid
AND o.date::date = _date
GROUP BY p.pid, p.sku
HAVING COUNT(o.id) > 0 -- Only include products with actual orders for this date
),
ReceivingData AS (
SELECT
r.pid,
-- Count receiving documents to ensure we only include products with real activity
COUNT(DISTINCT r.receiving_id) as receiving_count,
-- Calculate received quantity for this day
SUM(r.qty_each) AS units_received,
-- Calculate received cost for this day
SUM(r.qty_each * r.cost_each) AS cost_received
FROM public.receivings r
WHERE r.received_date::date = _date
GROUP BY r.pid
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
),
-- Get stock quantities for the day - note this is approximate since we're using current products data
StockData AS (
SELECT
p.pid,
p.stock_quantity,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price
FROM public.products p
)
INSERT INTO public.daily_product_snapshots (
snapshot_date,
pid,
sku,
eod_stock_quantity,
eod_stock_cost,
eod_stock_retail,
eod_stock_gross,
stockout_flag,
units_sold,
units_returned,
gross_revenue,
discounts,
returns_revenue,
net_revenue,
cogs,
gross_regular_revenue,
profit,
units_received,
cost_received,
calculation_timestamp
)
SELECT
_date AS snapshot_date,
COALESCE(sd.pid, rd.pid) AS pid,
sd.sku,
-- Use current stock as approximation, since historical stock data may not be available
s.stock_quantity AS eod_stock_quantity,
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
s.stock_quantity * s.current_price AS eod_stock_retail,
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
(s.stock_quantity <= 0) AS stockout_flag,
-- Sales metrics
COALESCE(sd.units_sold, 0),
COALESCE(sd.units_returned, 0),
COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
-- Receiving metrics
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),
_start_time
FROM SalesData sd
FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid
LEFT JOIN StockData s ON COALESCE(sd.pid, rd.pid) = s.pid
WHERE (COALESCE(sd.order_count, 0) > 0 OR COALESCE(rd.receiving_count, 0) > 0);
-- Get record count for this day
GET DIAGNOSTICS _count = ROW_COUNT;
_total_records := _total_records + _count;
RAISE NOTICE 'Added % snapshot records for date %', _count, _date;
-- Move to next day
_date := _date + INTERVAL '1 day';
END LOOP;
RAISE NOTICE 'Rebuilding daily snapshots complete. Added % total records across % days.', _total_records, (_end_date - _begin_date)::integer + 1;
-- Update the status table for daily_snapshots
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
VALUES ('daily_snapshots', _start_time)
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
-- Now update product_metrics based on the rebuilt snapshots
RAISE NOTICE 'Triggering update of product_metrics table...';
-- Call the update_product_metrics procedure directly
-- Your system might use a different method to trigger this update
PERFORM pg_notify('recalculate_metrics', 'product_metrics');
RAISE NOTICE 'Rebuild complete. Duration: %', clock_timestamp() - _start_time;
END $$;
@@ -6,6 +6,7 @@ DO $$
DECLARE
_module_name VARCHAR := 'brand_metrics';
_start_time TIMESTAMPTZ := clock_timestamp();
_min_revenue NUMERIC := 50.00; -- Minimum revenue threshold for margin calculation
BEGIN
RAISE NOTICE 'Running % calculation...', _module_name;
@@ -19,14 +20,40 @@ BEGIN
SUM(pm.current_stock) AS current_stock_units,
SUM(pm.current_stock_cost) AS current_stock_cost,
SUM(pm.current_stock_retail) AS current_stock_retail,
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue
-- Only include products with valid sales data in each time period
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
FROM public.product_metrics pm
JOIN public.products p ON pm.pid = p.pid
-- WHERE p.visible = true -- Optional: filter only visible products for brand metrics?
GROUP BY brand_group
),
PreviousPeriodBrandMetrics AS (
-- Get previous period metrics for growth calculation
SELECT
COALESCE(p.brand, 'Unbranded') AS brand_group,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
FROM public.daily_product_snapshots dps
JOIN public.products p ON dps.pid = p.pid
GROUP BY brand_group
),
AllBrands AS (
@@ -40,7 +67,8 @@ BEGIN
current_stock_units, current_stock_cost, current_stock_retail,
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
avg_margin_30d
avg_margin_30d,
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
)
SELECT
b.brand_group,
@@ -58,10 +86,20 @@ BEGIN
COALESCE(ba.profit_30d, 0.00), COALESCE(ba.cogs_30d, 0.00),
COALESCE(ba.sales_365d, 0), COALESCE(ba.revenue_365d, 0.00),
COALESCE(ba.lifetime_sales, 0), COALESCE(ba.lifetime_revenue, 0.00),
-- KPIs
(ba.profit_30d / NULLIF(ba.revenue_30d, 0)) * 100.0
-- KPIs - Calculate margin only for brands with significant revenue
CASE
WHEN COALESCE(ba.revenue_30d, 0) >= _min_revenue THEN
-- Directly calculate margin from revenue and cogs for consistency
-- This is mathematically equivalent to profit/revenue but more explicit
((COALESCE(ba.revenue_30d, 0) - COALESCE(ba.cogs_30d, 0)) / COALESCE(ba.revenue_30d, 1)) * 100.0
ELSE NULL -- No margin for low/no revenue brands
END,
-- Growth metrics
std_numeric(safe_divide((ba.sales_30d - ppbm.sales_prev_30d) * 100.0, ppbm.sales_prev_30d), 2),
std_numeric(safe_divide((ba.revenue_30d - ppbm.revenue_prev_30d) * 100.0, ppbm.revenue_prev_30d), 2)
FROM AllBrands b
LEFT JOIN BrandAggregates ba ON b.brand_group = ba.brand_group
LEFT JOIN PreviousPeriodBrandMetrics ppbm ON b.brand_group = ppbm.brand_group
ON CONFLICT (brand_name) DO UPDATE SET
last_calculated = EXCLUDED.last_calculated,
@@ -76,7 +114,16 @@ BEGIN
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
avg_margin_30d = EXCLUDED.avg_margin_30d;
avg_margin_30d = EXCLUDED.avg_margin_30d,
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
WHERE -- Only update if at least one value has changed
brand_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
brand_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR
brand_metrics.current_stock_units IS DISTINCT FROM EXCLUDED.current_stock_units OR
brand_metrics.sales_30d IS DISTINCT FROM EXCLUDED.sales_30d OR
brand_metrics.revenue_30d IS DISTINCT FROM EXCLUDED.revenue_30d OR
brand_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales;
-- Update calculate_status
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
@@ -84,4 +131,26 @@ BEGIN
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
RAISE NOTICE 'Finished % calculation. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$;
END $$;
-- Return metrics about the update operation for tracking
WITH update_stats AS (
SELECT
COUNT(*) as total_brands,
COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed,
SUM(product_count) as total_products,
SUM(active_product_count) as total_active_products,
SUM(sales_30d) as total_sales_30d,
SUM(revenue_30d) as total_revenue_30d,
AVG(avg_margin_30d) as overall_avg_margin_30d
FROM public.brand_metrics
)
SELECT
rows_processed,
total_brands,
total_products::int,
total_active_products::int,
total_sales_30d::int,
ROUND(total_revenue_30d, 2) as total_revenue_30d,
ROUND(overall_avg_margin_30d, 2) as overall_avg_margin_30d
FROM update_stats;
@@ -1,76 +1,173 @@
-- Description: Calculates and updates aggregated metrics per category.
-- Dependencies: product_metrics, products, categories, product_categories, calculate_status table.
-- Description: Calculates and updates aggregated metrics per category with hierarchy rollups.
-- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table.
-- Frequency: Daily (after product_metrics update).
DO $$
DECLARE
_module_name VARCHAR := 'category_metrics';
_start_time TIMESTAMPTZ := clock_timestamp();
_min_revenue NUMERIC := 50.00; -- Minimum revenue threshold for margin calculation
BEGIN
RAISE NOTICE 'Running % calculation...', _module_name;
-- Refresh the category hierarchy materialized view first
REFRESH MATERIALIZED VIEW CONCURRENTLY category_hierarchy;
WITH CategoryAggregates AS (
-- First calculate direct metrics (products directly in each category)
WITH DirectCategoryMetrics AS (
SELECT
pc.cat_id,
-- Counts
COUNT(DISTINCT pm.pid) AS product_count,
COUNT(DISTINCT CASE WHEN pm.is_visible THEN pm.pid END) AS active_product_count,
COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN pm.pid END) AS replenishable_product_count,
-- Current Stock
SUM(pm.current_stock) AS current_stock_units,
SUM(pm.current_stock_cost) AS current_stock_cost,
SUM(pm.current_stock_retail) AS current_stock_retail,
-- Rolling Periods (Sum directly from product_metrics)
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue,
-- Data for KPIs
SUM(pm.avg_stock_units_30d) AS total_avg_stock_units_30d -- Sum of averages (use cautiously)
FROM public.product_metrics pm
JOIN public.product_categories pc ON pm.pid = pc.pid
-- Optional: JOIN products p ON pm.pid = p.pid if needed for filtering (e.g., only visible products)
-- WHERE p.visible = true -- Example filter
-- Sales metrics with proper filtering
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
FROM public.product_categories pc
JOIN public.product_metrics pm ON pc.pid = pm.pid
GROUP BY pc.cat_id
),
-- Calculate rolled-up metrics (including all descendant categories)
RolledUpMetrics AS (
SELECT
ch.cat_id,
-- Sum metrics from this category and all its descendants
SUM(dcm.product_count) AS product_count,
SUM(dcm.active_product_count) AS active_product_count,
SUM(dcm.replenishable_product_count) AS replenishable_product_count,
SUM(dcm.current_stock_units) AS current_stock_units,
SUM(dcm.current_stock_cost) AS current_stock_cost,
SUM(dcm.current_stock_retail) AS current_stock_retail,
SUM(dcm.sales_7d) AS sales_7d,
SUM(dcm.revenue_7d) AS revenue_7d,
SUM(dcm.sales_30d) AS sales_30d,
SUM(dcm.revenue_30d) AS revenue_30d,
SUM(dcm.cogs_30d) AS cogs_30d,
SUM(dcm.profit_30d) AS profit_30d,
SUM(dcm.sales_365d) AS sales_365d,
SUM(dcm.revenue_365d) AS revenue_365d,
SUM(dcm.lifetime_sales) AS lifetime_sales,
SUM(dcm.lifetime_revenue) AS lifetime_revenue
FROM category_hierarchy ch
LEFT JOIN DirectCategoryMetrics dcm ON
dcm.cat_id = ch.cat_id OR
dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
GROUP BY ch.cat_id
),
PreviousPeriodCategoryMetrics AS (
-- Get previous period metrics for growth calculation
SELECT
pc.cat_id,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
FROM public.daily_product_snapshots dps
JOIN public.product_categories pc ON dps.pid = pc.pid
GROUP BY pc.cat_id
),
RolledUpPreviousPeriod AS (
-- Calculate rolled-up previous period metrics
SELECT
ch.cat_id,
SUM(ppcm.sales_prev_30d) AS sales_prev_30d,
SUM(ppcm.revenue_prev_30d) AS revenue_prev_30d
FROM category_hierarchy ch
LEFT JOIN PreviousPeriodCategoryMetrics ppcm ON
ppcm.cat_id = ch.cat_id OR
ppcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
GROUP BY ch.cat_id
),
AllCategories AS (
-- Ensure all categories are included
SELECT
c.cat_id,
c.name,
c.type,
c.parent_id
FROM public.categories c
WHERE c.status = 'active'
)
INSERT INTO public.category_metrics (
category_id, category_name, category_type, parent_id, last_calculated,
-- Rolled-up metrics
product_count, active_product_count, replenishable_product_count,
current_stock_units, current_stock_cost, current_stock_retail,
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
avg_margin_30d, stock_turn_30d
-- Direct metrics
direct_product_count, direct_active_product_count, direct_replenishable_product_count,
direct_current_stock_units, direct_stock_cost, direct_stock_retail,
direct_sales_7d, direct_revenue_7d, direct_sales_30d, direct_revenue_30d,
direct_profit_30d, direct_cogs_30d, direct_sales_365d, direct_revenue_365d,
direct_lifetime_sales, direct_lifetime_revenue,
-- KPIs
avg_margin_30d,
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
)
SELECT
c.cat_id,
c.name,
c.type,
c.parent_id,
ac.cat_id,
ac.name,
ac.type,
ac.parent_id,
_start_time,
-- Base Aggregates
COALESCE(ca.product_count, 0),
COALESCE(ca.active_product_count, 0),
COALESCE(ca.replenishable_product_count, 0),
COALESCE(ca.current_stock_units, 0),
COALESCE(ca.current_stock_cost, 0.00),
COALESCE(ca.current_stock_retail, 0.00),
COALESCE(ca.sales_7d, 0), COALESCE(ca.revenue_7d, 0.00),
COALESCE(ca.sales_30d, 0), COALESCE(ca.revenue_30d, 0.00),
COALESCE(ca.profit_30d, 0.00), COALESCE(ca.cogs_30d, 0.00),
COALESCE(ca.sales_365d, 0), COALESCE(ca.revenue_365d, 0.00),
COALESCE(ca.lifetime_sales, 0), COALESCE(ca.lifetime_revenue, 0.00),
-- KPIs
(ca.profit_30d / NULLIF(ca.revenue_30d, 0)) * 100.0,
ca.sales_30d / NULLIF(ca.total_avg_stock_units_30d, 0) -- Simple unit-based turnover
FROM public.categories c -- Start from categories to include those with no products yet
LEFT JOIN CategoryAggregates ca ON c.cat_id = ca.cat_id
-- Rolled-up metrics (includes descendants)
COALESCE(rum.product_count, 0),
COALESCE(rum.active_product_count, 0),
COALESCE(rum.replenishable_product_count, 0),
COALESCE(rum.current_stock_units, 0),
COALESCE(rum.current_stock_cost, 0.00),
COALESCE(rum.current_stock_retail, 0.00),
COALESCE(rum.sales_7d, 0), COALESCE(rum.revenue_7d, 0.00),
COALESCE(rum.sales_30d, 0), COALESCE(rum.revenue_30d, 0.00),
COALESCE(rum.profit_30d, 0.00), COALESCE(rum.cogs_30d, 0.00),
COALESCE(rum.sales_365d, 0), COALESCE(rum.revenue_365d, 0.00),
COALESCE(rum.lifetime_sales, 0), COALESCE(rum.lifetime_revenue, 0.00),
-- Direct metrics (only this category)
COALESCE(dcm.product_count, 0),
COALESCE(dcm.active_product_count, 0),
COALESCE(dcm.replenishable_product_count, 0),
COALESCE(dcm.current_stock_units, 0),
COALESCE(dcm.current_stock_cost, 0.00),
COALESCE(dcm.current_stock_retail, 0.00),
COALESCE(dcm.sales_7d, 0), COALESCE(dcm.revenue_7d, 0.00),
COALESCE(dcm.sales_30d, 0), COALESCE(dcm.revenue_30d, 0.00),
COALESCE(dcm.profit_30d, 0.00), COALESCE(dcm.cogs_30d, 0.00),
COALESCE(dcm.sales_365d, 0), COALESCE(dcm.revenue_365d, 0.00),
COALESCE(dcm.lifetime_sales, 0), COALESCE(dcm.lifetime_revenue, 0.00),
-- KPIs - Calculate margin only for categories with significant revenue
CASE
WHEN COALESCE(rum.revenue_30d, 0) >= _min_revenue THEN
((COALESCE(rum.revenue_30d, 0) - COALESCE(rum.cogs_30d, 0)) / COALESCE(rum.revenue_30d, 1)) * 100.0
ELSE NULL
END,
-- Growth metrics for rolled-up values
std_numeric(safe_divide((rum.sales_30d - rupp.sales_prev_30d) * 100.0, rupp.sales_prev_30d), 2),
std_numeric(safe_divide((rum.revenue_30d - rupp.revenue_prev_30d) * 100.0, rupp.revenue_prev_30d), 2)
FROM AllCategories ac
LEFT JOIN DirectCategoryMetrics dcm ON ac.cat_id = dcm.cat_id
LEFT JOIN RolledUpMetrics rum ON ac.cat_id = rum.cat_id
LEFT JOIN RolledUpPreviousPeriod rupp ON ac.cat_id = rupp.cat_id
ON CONFLICT (category_id) DO UPDATE SET
last_calculated = EXCLUDED.last_calculated,
category_name = EXCLUDED.category_name,
category_type = EXCLUDED.category_type,
parent_id = EXCLUDED.parent_id,
last_calculated = EXCLUDED.last_calculated,
-- Rolled-up metrics
product_count = EXCLUDED.product_count,
active_product_count = EXCLUDED.active_product_count,
replenishable_product_count = EXCLUDED.replenishable_product_count,
@@ -82,8 +179,30 @@ BEGIN
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
-- Direct metrics
direct_product_count = EXCLUDED.direct_product_count,
direct_active_product_count = EXCLUDED.direct_active_product_count,
direct_replenishable_product_count = EXCLUDED.direct_replenishable_product_count,
direct_current_stock_units = EXCLUDED.direct_current_stock_units,
direct_stock_cost = EXCLUDED.direct_stock_cost,
direct_stock_retail = EXCLUDED.direct_stock_retail,
direct_sales_7d = EXCLUDED.direct_sales_7d, direct_revenue_7d = EXCLUDED.direct_revenue_7d,
direct_sales_30d = EXCLUDED.direct_sales_30d, direct_revenue_30d = EXCLUDED.direct_revenue_30d,
direct_profit_30d = EXCLUDED.direct_profit_30d, direct_cogs_30d = EXCLUDED.direct_cogs_30d,
direct_sales_365d = EXCLUDED.direct_sales_365d, direct_revenue_365d = EXCLUDED.direct_revenue_365d,
direct_lifetime_sales = EXCLUDED.direct_lifetime_sales, direct_lifetime_revenue = EXCLUDED.direct_lifetime_revenue,
avg_margin_30d = EXCLUDED.avg_margin_30d,
stock_turn_30d = EXCLUDED.stock_turn_30d;
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
WHERE -- Only update if at least one value has changed
category_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
category_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR
category_metrics.current_stock_units IS DISTINCT FROM EXCLUDED.current_stock_units OR
category_metrics.sales_30d IS DISTINCT FROM EXCLUDED.sales_30d OR
category_metrics.revenue_30d IS DISTINCT FROM EXCLUDED.revenue_30d OR
category_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales OR
category_metrics.direct_product_count IS DISTINCT FROM EXCLUDED.direct_product_count OR
category_metrics.direct_sales_30d IS DISTINCT FROM EXCLUDED.direct_sales_30d;
-- Update calculate_status
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
@@ -91,4 +210,30 @@ BEGIN
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
RAISE NOTICE 'Finished % calculation. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$;
END $$;
-- Return metrics about the update operation for tracking
WITH update_stats AS (
SELECT
COUNT(*) as total_categories,
COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed,
COUNT(*) FILTER (WHERE category_type = 10) as sections,
COUNT(*) FILTER (WHERE category_type = 11) as categories,
COUNT(*) FILTER (WHERE category_type = 12) as subcategories,
SUM(product_count) as total_products_rolled,
SUM(direct_product_count) as total_products_direct,
SUM(sales_30d) as total_sales_30d,
SUM(revenue_30d) as total_revenue_30d
FROM public.category_metrics
)
SELECT
rows_processed,
total_categories,
sections,
categories,
subcategories,
total_products_rolled::int,
total_products_direct::int,
total_sales_30d::int,
ROUND(total_revenue_30d, 2) as total_revenue_30d
FROM update_stats;
@@ -21,30 +21,65 @@ BEGIN
SUM(pm.current_stock_retail) AS current_stock_retail,
SUM(pm.on_order_qty) AS on_order_units,
SUM(pm.on_order_cost) AS on_order_cost,
SUM(pm.sales_7d) AS sales_7d, SUM(pm.revenue_7d) AS revenue_7d,
SUM(pm.sales_30d) AS sales_30d, SUM(pm.revenue_30d) AS revenue_30d,
SUM(pm.profit_30d) AS profit_30d, SUM(pm.cogs_30d) AS cogs_30d,
SUM(pm.sales_365d) AS sales_365d, SUM(pm.revenue_365d) AS revenue_365d,
SUM(pm.lifetime_sales) AS lifetime_sales, SUM(pm.lifetime_revenue) AS lifetime_revenue
-- Only include products with valid sales data in each time period
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
FROM public.product_metrics pm
JOIN public.products p ON pm.pid = p.pid
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
GROUP BY p.vendor
),
VendorPOAggregates AS (
-- Aggregate PO related stats
PreviousPeriodVendorMetrics AS (
-- Get previous period metrics for growth calculation
SELECT
vendor,
COUNT(DISTINCT po_id) AS po_count_365d,
AVG(GREATEST(1, CASE WHEN last_received_date IS NOT NULL AND date IS NOT NULL THEN (last_received_date::date - date::date) ELSE NULL END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
FROM public.purchase_orders
WHERE vendor IS NOT NULL AND vendor <> ''
AND date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
AND status = 'received' -- Only calculate lead time on fully received POs
AND last_received_date IS NOT NULL
AND date IS NOT NULL
AND last_received_date >= date
GROUP BY vendor
p.vendor,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
FROM public.daily_product_snapshots dps
JOIN public.products p ON dps.pid = p.pid
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
GROUP BY p.vendor
),
VendorPOAggregates AS (
-- Aggregate PO related stats including lead time calculated from POs to receivings
SELECT
po.vendor,
COUNT(DISTINCT po.po_id) AS po_count_365d,
-- Calculate lead time by averaging the days between PO date and receiving date
AVG(GREATEST(1, CASE
WHEN r.received_date IS NOT NULL AND po.date IS NOT NULL
THEN (r.received_date::date - po.date::date)
ELSE NULL
END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
FROM public.purchase_orders po
-- Join to receivings table to find when items were received
LEFT JOIN public.receivings r ON r.pid = po.pid
WHERE po.vendor IS NOT NULL AND po.vendor <> ''
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
AND po.status = 'done' -- Only calculate lead time on completed POs
AND r.received_date IS NOT NULL
AND po.date IS NOT NULL
AND r.received_date >= po.date
GROUP BY po.vendor
),
AllVendors AS (
-- Ensure all vendors from products table are included
@@ -58,7 +93,8 @@ BEGIN
po_count_365d, avg_lead_time_days,
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
avg_margin_30d
avg_margin_30d,
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
)
SELECT
v.vendor,
@@ -82,10 +118,14 @@ BEGIN
COALESCE(vpa.sales_365d, 0), COALESCE(vpa.revenue_365d, 0.00),
COALESCE(vpa.lifetime_sales, 0), COALESCE(vpa.lifetime_revenue, 0.00),
-- KPIs
(vpa.profit_30d / NULLIF(vpa.revenue_30d, 0)) * 100.0
(vpa.profit_30d / NULLIF(vpa.revenue_30d, 0)) * 100.0,
-- Growth metrics
std_numeric(safe_divide((vpa.sales_30d - ppvm.sales_prev_30d) * 100.0, ppvm.sales_prev_30d), 2),
std_numeric(safe_divide((vpa.revenue_30d - ppvm.revenue_prev_30d) * 100.0, ppvm.revenue_prev_30d), 2)
FROM AllVendors v
LEFT JOIN VendorProductAggregates vpa ON v.vendor = vpa.vendor
LEFT JOIN VendorPOAggregates vpoa ON v.vendor = vpoa.vendor
LEFT JOIN PreviousPeriodVendorMetrics ppvm ON v.vendor = ppvm.vendor
ON CONFLICT (vendor_name) DO UPDATE SET
last_calculated = EXCLUDED.last_calculated,
@@ -104,7 +144,17 @@ BEGIN
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
avg_margin_30d = EXCLUDED.avg_margin_30d;
avg_margin_30d = EXCLUDED.avg_margin_30d,
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
WHERE -- Only update if at least one value has changed
vendor_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
vendor_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR
vendor_metrics.current_stock_units IS DISTINCT FROM EXCLUDED.current_stock_units OR
vendor_metrics.on_order_units IS DISTINCT FROM EXCLUDED.on_order_units OR
vendor_metrics.sales_30d IS DISTINCT FROM EXCLUDED.sales_30d OR
vendor_metrics.revenue_30d IS DISTINCT FROM EXCLUDED.revenue_30d OR
vendor_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales;
-- Update calculate_status
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
@@ -112,4 +162,24 @@ BEGIN
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
RAISE NOTICE 'Finished % calculation. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$;
END $$;
-- Return metrics about the update operation for tracking
WITH update_stats AS (
SELECT
COUNT(*) as total_vendors,
COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed,
SUM(product_count) as total_products,
SUM(active_product_count) as total_active_products,
SUM(po_count_365d) as total_pos_365d,
AVG(avg_lead_time_days) as overall_avg_lead_time
FROM public.vendor_metrics
)
SELECT
rows_processed,
total_vendors,
total_products::int,
total_active_products::int,
total_pos_365d::int,
ROUND(overall_avg_lead_time, 1) as overall_avg_lead_time
FROM update_stats;
@@ -1,4 +1,4 @@
-- Description: Calculates and updates daily aggregated product data for the current day.
-- Description: Calculates and updates daily aggregated product data for recent days.
-- Uses UPSERT (INSERT ON CONFLICT UPDATE) for idempotency.
-- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table.
-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes).
@@ -8,176 +8,215 @@ DECLARE
_module_name TEXT := 'daily_snapshots';
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started
_last_calc_time TIMESTAMPTZ;
_target_date DATE := CURRENT_DATE; -- Always recalculate today for simplicity with hourly runs
_target_date DATE; -- Will be set in the loop
_total_records INT := 0;
_has_orders BOOLEAN := FALSE;
_process_days INT := 5; -- Number of days to check/process (today plus previous 4 days)
_day_counter INT;
_missing_days INT[] := ARRAY[]::INT[]; -- Array to store days with missing or incomplete data
BEGIN
-- Get the timestamp before the last successful run of this module
SELECT last_calculation_timestamp INTO _last_calc_time
FROM public.calculate_status
WHERE module_name = _module_name;
RAISE NOTICE 'Running % for date %. Start Time: %', _module_name, _target_date, _start_time;
RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time;
-- First, check which days need processing by comparing orders data with snapshot data
FOR _day_counter IN 0..(_process_days-1) LOOP
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
-- Check if this date needs updating by comparing orders to snapshot data
-- If the date has orders but not enough snapshots, or if snapshots show zero sales but orders exist, it's incomplete
SELECT
CASE WHEN (
-- We have orders for this date but not enough snapshots, or snapshots with wrong total
(EXISTS (SELECT 1 FROM public.orders WHERE date::date = _target_date) AND
(
-- No snapshots exist for this date
NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) OR
-- Or snapshots show zero sales but orders exist
(SELECT COALESCE(SUM(units_sold), 0) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) = 0 OR
-- Or the count of snapshot records is significantly less than distinct products in orders
(SELECT COUNT(*) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) <
(SELECT COUNT(DISTINCT pid) FROM public.orders WHERE date::date = _target_date) * 0.8
)
)
) THEN TRUE ELSE FALSE END
INTO _has_orders;
IF _has_orders THEN
-- This day needs processing - add to our array
_missing_days := _missing_days || _day_counter;
RAISE NOTICE 'Day % needs updating (incomplete or missing data)', _target_date;
END IF;
END LOOP;
-- If no days need updating, exit early
IF array_length(_missing_days, 1) IS NULL THEN
RAISE NOTICE 'No days need updating - all snapshot data appears complete';
-- Still update the calculate_status to record this run
UPDATE public.calculate_status
SET last_calculation_timestamp = _start_time
WHERE module_name = _module_name;
RETURN;
END IF;
RAISE NOTICE 'Need to update % days with missing or incomplete data', array_length(_missing_days, 1);
-- Use CTEs to aggregate data for the target date
WITH SalesData AS (
SELECT
p.pid,
p.sku,
-- Aggregate Sales (Quantity > 0, Status not Canceled/Returned)
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted, -- Before discount
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue, -- Use current regular price for simplicity here
-- Process only the days that need updating
FOREACH _day_counter IN ARRAY _missing_days LOOP
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
RAISE NOTICE 'Processing date: %', _target_date;
-- IMPORTANT: First delete any existing data for this date to prevent duplication
DELETE FROM public.daily_product_snapshots
WHERE snapshot_date = _target_date;
-- Aggregate Returns (Quantity < 0 or Status = Returned)
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned,
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue
FROM public.products p -- Start from products to include those with no orders today
LEFT JOIN public.orders o
ON p.pid = o.pid
AND o.date::date = _target_date -- Cast to date to ensure compatibility regardless of original type
GROUP BY p.pid, p.sku
),
ReceivingData AS (
SELECT
po.pid,
-- Prioritize the actual table fields over the JSON data
COALESCE(
-- First try the received field from purchase_orders table
SUM(CASE WHEN po.date::date = _target_date THEN po.received ELSE 0 END),
-- Otherwise fall back to the receiving_history JSON as secondary source
SUM(
CASE
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric
ELSE 0
END
),
0
) AS units_received,
COALESCE(
-- First try the actual cost_price from purchase_orders
SUM(CASE WHEN po.date::date = _target_date THEN po.received * po.cost_price ELSE 0 END),
-- Otherwise fall back to receiving_history JSON
SUM(
CASE
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric
ELSE 0
END
* COALESCE((rh.item->>'cost')::numeric, po.cost_price)
),
0.00
) AS cost_received
FROM public.purchase_orders po
LEFT JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item) ON
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0 AND
(
(rh.item->>'date')::date = _target_date OR
(rh.item->>'received_at')::date = _target_date OR
(rh.item->>'receipt_date')::date = _target_date
)
-- Include POs with the current date or relevant receiving_history
WHERE
po.date::date = _target_date OR
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
),
CurrentStock AS (
-- Select current stock values directly from products table
SELECT
-- Proceed with calculating daily metrics only for products with actual activity
WITH SalesData AS (
SELECT
p.pid,
p.sku,
-- Track number of orders to ensure we have real data
COUNT(o.id) as order_count,
-- Aggregate Sales (Quantity > 0, Status not Canceled/Returned)
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted, -- Before discount
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN
COALESCE(
o.costeach, -- First use order-specific cost if available
get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost
p.landing_cost_price, -- Fallback to landing cost
p.cost_price -- Final fallback to current cost
) * o.quantity
ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue, -- Use current regular price for simplicity here
-- Aggregate Returns (Quantity < 0 or Status = Returned)
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned,
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue
FROM public.products p -- Start from products to include those with no orders today
JOIN public.orders o -- Changed to INNER JOIN to only process products with orders
ON p.pid = o.pid
AND o.date::date = _target_date -- Cast to date to ensure compatibility regardless of original type
GROUP BY p.pid, p.sku
-- No HAVING clause here - we always want to include all orders
),
ReceivingData AS (
SELECT
r.pid,
-- Track number of receiving docs to ensure we have real data
COUNT(DISTINCT r.receiving_id) as receiving_doc_count,
-- Sum the quantities received on this date
SUM(r.qty_each) AS units_received,
-- Calculate the cost received (qty * cost)
SUM(r.qty_each * r.cost_each) AS cost_received
FROM public.receivings r
WHERE r.received_date::date = _target_date
-- Optional: Filter out canceled receivings if needed
-- AND r.status <> 'canceled'
GROUP BY r.pid
-- Only include products with actual receiving activity
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
),
CurrentStock AS (
-- Select current stock values directly from products table
SELECT
pid,
stock_quantity,
COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price,
COALESCE(price, 0.00) as current_price,
COALESCE(regular_price, 0.00) as current_regular_price
FROM public.products
),
ProductsWithActivity AS (
-- Quick pre-filter to only process products with activity
SELECT DISTINCT pid
FROM (
SELECT pid FROM SalesData
UNION
SELECT pid FROM ReceivingData
) a
)
-- Now insert records, but ONLY for products with actual activity
INSERT INTO public.daily_product_snapshots (
snapshot_date,
pid,
stock_quantity,
COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price,
COALESCE(price, 0.00) as current_price,
COALESCE(regular_price, 0.00) as current_regular_price
FROM public.products
)
-- Upsert into the daily snapshots table
INSERT INTO public.daily_product_snapshots (
snapshot_date,
pid,
sku,
eod_stock_quantity,
eod_stock_cost,
eod_stock_retail,
eod_stock_gross,
stockout_flag,
units_sold,
units_returned,
gross_revenue,
discounts,
returns_revenue,
net_revenue,
cogs,
gross_regular_revenue,
profit,
units_received,
cost_received,
calculation_timestamp
)
SELECT
_target_date AS snapshot_date,
p.pid,
p.sku,
-- Inventory Metrics (Using CurrentStock)
cs.stock_quantity AS eod_stock_quantity,
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
cs.stock_quantity * cs.current_price AS eod_stock_retail,
cs.stock_quantity * cs.current_regular_price AS eod_stock_gross,
(cs.stock_quantity <= 0) AS stockout_flag,
-- Sales Metrics (From SalesData)
COALESCE(sd.units_sold, 0),
COALESCE(sd.units_returned, 0),
COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS
-- Receiving Metrics (From ReceivingData)
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),
_start_time -- Timestamp of this calculation run
FROM public.products p
LEFT JOIN CurrentStock cs ON p.pid = cs.pid
LEFT JOIN SalesData sd ON p.pid = sd.pid
LEFT JOIN ReceivingData rd ON p.pid = rd.pid
WHERE p.pid IS NOT NULL -- Ensure we only insert for existing products
sku,
eod_stock_quantity,
eod_stock_cost,
eod_stock_retail,
eod_stock_gross,
stockout_flag,
units_sold,
units_returned,
gross_revenue,
discounts,
returns_revenue,
net_revenue,
cogs,
gross_regular_revenue,
profit,
units_received,
cost_received,
calculation_timestamp
)
SELECT
_target_date AS snapshot_date,
COALESCE(sd.pid, rd.pid) AS pid, -- Use sales or receiving PID
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
-- Inventory Metrics (Using CurrentStock)
cs.stock_quantity AS eod_stock_quantity,
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
cs.stock_quantity * cs.current_price AS eod_stock_retail,
cs.stock_quantity * cs.current_regular_price AS eod_stock_gross,
(cs.stock_quantity <= 0) AS stockout_flag,
-- Sales Metrics (From SalesData)
COALESCE(sd.units_sold, 0),
COALESCE(sd.units_returned, 0),
COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS
-- Receiving Metrics (From ReceivingData)
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),
_start_time -- Timestamp of this calculation run
FROM SalesData sd
FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid
JOIN ProductsWithActivity pwa ON COALESCE(sd.pid, rd.pid) = pwa.pid
LEFT JOIN public.products p ON COALESCE(sd.pid, rd.pid) = p.pid
LEFT JOIN CurrentStock cs ON COALESCE(sd.pid, rd.pid) = cs.pid
WHERE p.pid IS NOT NULL; -- Ensure we only insert for existing products
ON CONFLICT (snapshot_date, pid) DO UPDATE SET
sku = EXCLUDED.sku,
eod_stock_quantity = EXCLUDED.eod_stock_quantity,
eod_stock_cost = EXCLUDED.eod_stock_cost,
eod_stock_retail = EXCLUDED.eod_stock_retail,
eod_stock_gross = EXCLUDED.eod_stock_gross,
stockout_flag = EXCLUDED.stockout_flag,
units_sold = EXCLUDED.units_sold,
units_returned = EXCLUDED.units_returned,
gross_revenue = EXCLUDED.gross_revenue,
discounts = EXCLUDED.discounts,
returns_revenue = EXCLUDED.returns_revenue,
net_revenue = EXCLUDED.net_revenue,
cogs = EXCLUDED.cogs,
gross_regular_revenue = EXCLUDED.gross_regular_revenue,
profit = EXCLUDED.profit,
units_received = EXCLUDED.units_received,
cost_received = EXCLUDED.cost_received,
calculation_timestamp = EXCLUDED.calculation_timestamp; -- Use the timestamp from this run
-- Get the total number of records inserted for this date
GET DIAGNOSTICS _total_records = ROW_COUNT;
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
END LOOP;
-- Update the status table with the timestamp from the START of this run
UPDATE public.calculate_status
SET last_calculation_timestamp = _start_time
WHERE module_name = _module_name;
RAISE NOTICE 'Finished % for date %. Duration: %', _module_name, _target_date, clock_timestamp() - _start_time;
RAISE NOTICE 'Finished % processing for multiple dates. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$;
END $$;
-- Return the total records processed for tracking
SELECT
COUNT(*) as rows_processed,
COUNT(DISTINCT snapshot_date) as days_processed,
MIN(snapshot_date) as earliest_date,
MAX(snapshot_date) as latest_date,
SUM(units_sold) as total_units_sold,
SUM(units_received) as total_units_received
FROM public.daily_product_snapshots
WHERE calculation_timestamp >= (NOW() - INTERVAL '5 minutes'); -- Recent updates only
@@ -24,14 +24,17 @@ BEGIN
RAISE NOTICE 'Calculating Average Lead Time...';
WITH LeadTimes AS (
SELECT
pid,
AVG(GREATEST(1, (last_received_date::date - date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
FROM public.purchase_orders
WHERE status = 'received' -- Or potentially 'full_received' if using that status
AND last_received_date IS NOT NULL
AND date IS NOT NULL
AND last_received_date >= date -- Ensure received date is not before order date
GROUP BY pid
po.pid,
-- Calculate lead time by looking at when items ordered on POs were received
AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
FROM public.purchase_orders po
-- Join to receivings table to find actual receipts
JOIN public.receivings r ON r.pid = po.pid
WHERE po.status = 'done' -- Only include completed POs
AND r.received_date >= po.date -- Ensure received date is not before order date
-- Optional: add check to make sure receiving is related to PO if you have source_po_id
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL)
GROUP BY po.pid
)
UPDATE public.product_metrics pm
SET avg_lead_time_days = lt.avg_days::int
@@ -111,4 +114,26 @@ BEGIN
RAISE NOTICE 'Finished % module. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$;
END $$;
-- Return metrics about the update operation for tracking
WITH update_stats AS (
SELECT
COUNT(*) as total_products,
COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed,
COUNT(*) FILTER (WHERE abc_class = 'A') as abc_a_count,
COUNT(*) FILTER (WHERE abc_class = 'B') as abc_b_count,
COUNT(*) FILTER (WHERE abc_class = 'C') as abc_c_count,
COUNT(*) FILTER (WHERE avg_lead_time_days IS NOT NULL) as products_with_lead_time,
AVG(avg_lead_time_days) as overall_avg_lead_time
FROM public.product_metrics
)
SELECT
rows_processed,
total_products,
abc_a_count,
abc_b_count,
abc_c_count,
products_with_lead_time,
ROUND(overall_avg_lead_time, 1) as overall_avg_lead_time
FROM update_stats;
@@ -28,6 +28,27 @@ BEGIN
COALESCE(p.image_175, p.image) as image_url,
p.visible as is_visible,
p.replenishable as is_replenishable,
-- Add new product fields
p.barcode,
p.harmonized_tariff_code,
p.vendor_reference,
p.notions_reference,
p.line,
p.subline,
p.artist,
p.moq,
p.rating,
p.reviews,
p.weight,
p.length,
p.width,
p.height,
p.country_of_origin,
p.location,
p.baskets,
p.notifies,
p.preorder_count,
p.notions_inv_count,
COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price,
COALESCE(p.cost_price, 0.00) as current_cost_price,
@@ -36,19 +57,19 @@ BEGIN
p.created_at,
p.first_received,
p.date_last_sold,
p.moq,
p.total_sold as historical_total_sold, -- Add historical total_sold from products table
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
FROM public.products p
),
OnOrderInfo AS (
SELECT
pid,
COALESCE(SUM(ordered - received), 0) AS on_order_qty,
COALESCE(SUM((ordered - received) * cost_price), 0.00) AS on_order_cost,
SUM(ordered) AS on_order_qty,
SUM(ordered * po_cost_price) AS on_order_cost,
MIN(expected_date) AS earliest_expected_date
FROM public.purchase_orders
WHERE status IN ('open', 'partially_received', 'ordered', 'preordered', 'receiving_started', 'electronically_sent', 'electronically_ready_send') -- Adjust based on your status workflow representing active POs not fully received
AND (ordered - received) > 0
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
AND status NOT IN ('canceled', 'done')
GROUP BY pid
),
HistoricalDates AS (
@@ -59,45 +80,14 @@ BEGIN
MIN(o.date)::date AS date_first_sold,
MAX(o.date)::date AS max_order_date, -- Use MAX for potential recalc of date_last_sold
-- For first received date, try table data first then fall back to JSON
COALESCE(
MIN(po.date)::date, -- Try purchase_order date first
MIN(rh.first_receipt_date) -- Fall back to JSON data if needed
) AS date_first_received_calc,
-- For first received, use the new receivings table
MIN(r.received_date)::date AS date_first_received_calc,
-- If we only have one receipt date (first = last), use that for last_received too
COALESCE(
MAX(po.date)::date, -- Try purchase_order date first
NULLIF(MAX(rh.last_receipt_date), NULL),
MIN(rh.first_receipt_date)
) AS date_last_received_calc
-- For last received, use the new receivings table
MAX(r.received_date)::date AS date_last_received_calc
FROM public.products p
LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned')
LEFT JOIN public.purchase_orders po ON p.pid = po.pid AND po.received > 0
LEFT JOIN (
SELECT
po.pid,
MIN(
CASE
WHEN rh.item->>'date' IS NOT NULL THEN (rh.item->>'date')::date
WHEN rh.item->>'received_at' IS NOT NULL THEN (rh.item->>'received_at')::date
WHEN rh.item->>'receipt_date' IS NOT NULL THEN (rh.item->>'receipt_date')::date
ELSE NULL
END
) as first_receipt_date,
MAX(
CASE
WHEN rh.item->>'date' IS NOT NULL THEN (rh.item->>'date')::date
WHEN rh.item->>'received_at' IS NOT NULL THEN (rh.item->>'received_at')::date
WHEN rh.item->>'receipt_date' IS NOT NULL THEN (rh.item->>'receipt_date')::date
ELSE NULL
END
) as last_receipt_date
FROM public.purchase_orders po
CROSS JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item)
WHERE jsonb_typeof(po.receiving_history) = 'array' AND jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
) rh ON p.pid = rh.pid
LEFT JOIN public.receivings r ON p.pid = r.pid
GROUP BY p.pid
),
SnapshotAggregates AS (
@@ -110,31 +100,37 @@ BEGIN
SUM(units_sold) AS total_units_sold,
SUM(net_revenue) AS total_net_revenue,
-- Specific time windows if we have enough data
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' THEN units_sold ELSE 0 END) AS sales_7d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS revenue_7d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' THEN units_sold ELSE 0 END) AS sales_14d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' THEN net_revenue ELSE 0 END) AS revenue_14d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN units_sold ELSE 0 END) AS sales_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN net_revenue ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN cogs ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN profit ELSE 0 END) AS profit_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN units_returned ELSE 0 END) AS returns_units_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN returns_revenue ELSE 0 END) AS returns_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN discounts ELSE 0 END) AS discounts_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN gross_revenue ELSE 0 END) AS gross_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN gross_regular_revenue ELSE 0 END) AS gross_regular_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND stockout_flag THEN 1 ELSE 0 END) AS stockout_days_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' THEN units_sold ELSE 0 END) AS sales_365d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' THEN net_revenue ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN units_received ELSE 0 END) AS received_qty_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN cost_received ELSE 0 END) AS received_cost_30d,
-- Specific time windows using date range boundaries precisely
-- Use _current_date - INTERVAL '6 days' to include 7 days (today + 6 previous days)
-- This ensures we count exactly the right number of days in each period
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_7d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '6 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_7d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_14d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '13 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_14d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN cogs ELSE 0 END) AS cogs_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN profit ELSE 0 END) AS profit_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_returned ELSE 0 END) AS returns_units_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN returns_revenue ELSE 0 END) AS returns_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN discounts ELSE 0 END) AS discounts_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN gross_revenue ELSE 0 END) AS gross_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN gross_regular_revenue ELSE 0 END) AS gross_regular_revenue_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date AND stockout_flag THEN 1 ELSE 0 END) AS stockout_days_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' AND snapshot_date <= _current_date THEN units_sold ELSE 0 END) AS sales_365d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '364 days' AND snapshot_date <= _current_date THEN net_revenue ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN units_received ELSE 0 END) AS received_qty_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN cost_received ELSE 0 END) AS received_cost_30d,
-- Averages (check for NULLIF 0 days in period if filtering dates)
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_quantity END) AS avg_stock_units_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_cost END) AS avg_stock_cost_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_retail END) AS avg_stock_retail_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' THEN eod_stock_gross END) AS avg_stock_gross_30d,
-- Averages for stock levels - only include dates within the specified period
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_quantity END) AS avg_stock_units_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_cost END) AS avg_stock_cost_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_retail END) AS avg_stock_retail_30d,
AVG(CASE WHEN snapshot_date >= _current_date - INTERVAL '29 days' AND snapshot_date <= _current_date THEN eod_stock_gross END) AS avg_stock_gross_30d,
-- Lifetime - should match total values above
SUM(units_sold) AS lifetime_sales,
@@ -150,14 +146,14 @@ BEGIN
SELECT
pid,
date_first_sold,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '6 days' THEN units_sold ELSE 0 END) AS first_7_days_sales,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS first_7_days_revenue,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '29 days' THEN units_sold ELSE 0 END) AS first_30_days_sales,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '29 days' THEN net_revenue ELSE 0 END) AS first_30_days_revenue,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '59 days' THEN units_sold ELSE 0 END) AS first_60_days_sales,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '59 days' THEN net_revenue ELSE 0 END) AS first_60_days_revenue,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '89 days' THEN units_sold ELSE 0 END) AS first_90_days_sales,
SUM(CASE WHEN snapshot_date BETWEEN date_first_sold AND date_first_sold + INTERVAL '89 days' THEN net_revenue ELSE 0 END) AS first_90_days_revenue
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '6 days' THEN units_sold ELSE 0 END) AS first_7_days_sales,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '6 days' THEN net_revenue ELSE 0 END) AS first_7_days_revenue,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '29 days' THEN units_sold ELSE 0 END) AS first_30_days_sales,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '29 days' THEN net_revenue ELSE 0 END) AS first_30_days_revenue,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '59 days' THEN units_sold ELSE 0 END) AS first_60_days_sales,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '59 days' THEN net_revenue ELSE 0 END) AS first_60_days_revenue,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '89 days' THEN units_sold ELSE 0 END) AS first_90_days_sales,
SUM(CASE WHEN snapshot_date >= date_first_sold AND snapshot_date <= date_first_sold + INTERVAL '89 days' THEN net_revenue ELSE 0 END) AS first_90_days_revenue
FROM public.daily_product_snapshots ds
JOIN HistoricalDates hd USING(pid)
WHERE date_first_sold IS NOT NULL
@@ -175,10 +171,92 @@ BEGIN
FROM public.products p
LEFT JOIN public.settings_product sp ON p.pid = sp.pid
LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor
),
LifetimeRevenue AS (
-- Calculate actual revenue from orders table
SELECT
o.pid,
SUM(o.price * o.quantity - COALESCE(o.discount, 0)) AS lifetime_revenue_from_orders,
SUM(o.quantity) AS lifetime_units_from_orders
FROM public.orders o
WHERE o.status NOT IN ('canceled', 'returned')
AND o.quantity > 0
GROUP BY o.pid
),
PreviousPeriodMetrics AS (
-- Calculate metrics for previous 30-day period for growth comparison
SELECT
pid,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '59 days'
AND snapshot_date < _current_date - INTERVAL '29 days'
THEN units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '59 days'
AND snapshot_date < _current_date - INTERVAL '29 days'
THEN net_revenue ELSE 0 END) AS revenue_prev_30d,
-- Year-over-year comparison
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '395 days'
AND snapshot_date < _current_date - INTERVAL '365 days'
THEN units_sold ELSE 0 END) AS sales_30d_last_year,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '395 days'
AND snapshot_date < _current_date - INTERVAL '365 days'
THEN net_revenue ELSE 0 END) AS revenue_30d_last_year
FROM public.daily_product_snapshots
GROUP BY pid
),
DemandVariability AS (
-- Calculate variance and standard deviation of daily sales
SELECT
pid,
COUNT(*) AS days_with_data,
AVG(units_sold) AS avg_daily_sales,
VARIANCE(units_sold) AS sales_variance,
STDDEV(units_sold) AS sales_std_dev,
-- Coefficient of variation
CASE
WHEN AVG(units_sold) > 0 THEN STDDEV(units_sold) / AVG(units_sold)
ELSE NULL
END AS sales_cv
FROM public.daily_product_snapshots
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
AND snapshot_date <= _current_date
GROUP BY pid
),
ServiceLevels AS (
-- Calculate service level and fill rate metrics
SELECT
pid,
COUNT(*) FILTER (WHERE stockout_flag = true) AS stockout_incidents_30d,
COUNT(*) FILTER (WHERE stockout_flag = true AND units_sold > 0) AS lost_sales_incidents_30d,
-- Service level: percentage of days without stockouts
(1.0 - (COUNT(*) FILTER (WHERE stockout_flag = true)::NUMERIC / NULLIF(COUNT(*), 0))) * 100 AS service_level_30d,
-- Fill rate: units sold / (units sold + potential lost sales)
CASE
WHEN SUM(units_sold) > 0 THEN
(SUM(units_sold)::NUMERIC /
(SUM(units_sold) + SUM(CASE WHEN stockout_flag THEN units_sold * 0.2 ELSE 0 END))) * 100
ELSE NULL
END AS fill_rate_30d
FROM public.daily_product_snapshots
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
AND snapshot_date <= _current_date
GROUP BY pid
),
SeasonalityAnalysis AS (
-- Simple seasonality detection
SELECT
p.pid,
sp.seasonal_pattern,
sp.seasonality_index,
sp.peak_season
FROM products p
CROSS JOIN LATERAL detect_seasonal_pattern(p.pid) sp
)
-- Final UPSERT into product_metrics
INSERT INTO public.product_metrics (
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist,
moq, rating, reviews, weight, length, width, height, country_of_origin, location,
baskets, notifies, preorder_count, notions_inv_count,
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
@@ -188,7 +266,7 @@ BEGIN
stockout_days_30d, sales_365d, revenue_365d,
avg_stock_units_30d, avg_stock_cost_30d, avg_stock_retail_30d, avg_stock_gross_30d,
received_qty_30d, received_cost_30d,
lifetime_sales, lifetime_revenue,
lifetime_sales, lifetime_revenue, lifetime_revenue_quality,
first_7_days_sales, first_7_days_revenue, first_30_days_sales, first_30_days_revenue,
first_60_days_sales, first_60_days_revenue, first_90_days_sales, first_90_days_revenue,
asp_30d, acp_30d, avg_ros_30d, avg_sales_per_day_30d, avg_sales_per_month_30d,
@@ -203,10 +281,20 @@ BEGIN
to_order_units, forecast_lost_sales_units, forecast_lost_revenue,
stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date,
overstocked_units, overstocked_cost, overstocked_retail, is_old_stock,
yesterday_sales
yesterday_sales,
status, -- Add status field for calculated status
-- New fields
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev,
sales_growth_yoy, revenue_growth_yoy,
sales_variance_30d, sales_std_dev_30d, sales_cv_30d, demand_pattern,
fill_rate_30d, stockout_incidents_30d, service_level_30d, lost_sales_incidents_30d,
seasonality_index, seasonal_pattern, peak_season
)
SELECT
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable,
ci.barcode, ci.harmonized_tariff_code, ci.vendor_reference, ci.notions_reference, ci.line, ci.subline, ci.artist,
ci.moq, ci.rating, ci.reviews, ci.weight, ci.length, ci.width, ci.height, ci.country_of_origin, ci.location,
ci.baskets, ci.notifies, ci.preorder_count, ci.notions_inv_count,
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price,
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date,
@@ -222,13 +310,35 @@ BEGIN
sa.stockout_days_30d, sa.sales_365d, sa.revenue_365d,
sa.avg_stock_units_30d, sa.avg_stock_cost_30d, sa.avg_stock_retail_30d, sa.avg_stock_gross_30d,
sa.received_qty_30d, sa.received_cost_30d,
-- Use total counts for lifetime values to ensure we have data even with limited history
COALESCE(sa.total_units_sold, sa.lifetime_sales) AS lifetime_sales,
COALESCE(sa.total_net_revenue, sa.lifetime_revenue) AS lifetime_revenue,
-- Use total_sold from products table as the source of truth for lifetime sales
-- This includes all historical data from the production database
ci.historical_total_sold AS lifetime_sales,
-- Calculate lifetime revenue using actual historical prices where available
CASE
WHEN lr.lifetime_revenue_from_orders IS NOT NULL THEN
-- We have some order history - use it plus estimate for remaining
lr.lifetime_revenue_from_orders +
(GREATEST(0, ci.historical_total_sold - COALESCE(lr.lifetime_units_from_orders, 0)) *
COALESCE(
-- Use oldest known price from snapshots as proxy
(SELECT revenue_7d / NULLIF(sales_7d, 0)
FROM daily_product_snapshots
WHERE pid = ci.pid AND sales_7d > 0
ORDER BY snapshot_date ASC
LIMIT 1),
ci.current_price
))
ELSE
-- No order history - estimate using current price
ci.historical_total_sold * ci.current_price
END AS lifetime_revenue,
CASE
WHEN lr.lifetime_units_from_orders >= ci.historical_total_sold * 0.9 THEN 'exact'
WHEN lr.lifetime_units_from_orders >= ci.historical_total_sold * 0.5 THEN 'partial'
ELSE 'estimated'
END AS lifetime_revenue_quality,
fpm.first_7_days_sales, fpm.first_7_days_revenue, fpm.first_30_days_sales, fpm.first_30_days_revenue,
fpm.first_60_days_sales, fpm.first_60_days_revenue, fpm.first_90_days_sales, fpm.first_90_days_revenue,
-- Calculated KPIs
sa.revenue_30d / NULLIF(sa.sales_30d, 0) AS asp_30d,
sa.cogs_30d / NULLIF(sa.sales_30d, 0) AS acp_30d,
sa.profit_30d / NULLIF(sa.sales_30d, 0) AS avg_ros_30d,
@@ -243,48 +353,59 @@ BEGIN
(sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d,
sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d,
((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d,
(sa.sales_30d / NULLIF(ci.current_stock + sa.sales_30d, 0)) * 100 AS sell_through_30d,
-- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
-- Approximating beginning inventory as current stock + units sold - units received
(sa.sales_30d / NULLIF(
ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d,
0
)) * 100 AS sell_through_30d,
-- Forecasting intermediate values
(sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) AS sales_velocity_daily,
-- Use the calculate_sales_velocity function instead of repetitive calculation
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) AS sales_velocity_daily,
s.effective_lead_time AS config_lead_time,
s.effective_days_of_stock AS config_days_of_stock,
s.effective_safety_stock AS config_safety_stock,
(s.effective_lead_time + s.effective_days_of_stock) AS planning_period_days,
(sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time AS lead_time_forecast_units,
(sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock AS days_of_stock_forecast_units,
((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock) AS planning_period_forecast_units,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time)) AS lead_time_closing_stock,
((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time))) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock) AS days_of_stock_closing_stock,
(((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw,
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time AS lead_time_forecast_units,
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock AS days_of_stock_forecast_units,
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_forecast_units,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time)) AS lead_time_closing_stock,
((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock) AS days_of_stock_closing_stock,
((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw,
-- Final Forecasting / Replenishment Metrics (apply CEILING/GREATEST/etc.)
-- Note: These calculations are nested for clarity, can be simplified in prod
CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units,
(CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost,
(CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail,
(CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit,
-- Final Forecasting / Replenishment Metrics
CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units,
(CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost,
(CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail,
(CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit,
-- Placeholder for To Order (Apply MOQ/UOM logic here if needed, otherwise equals replenishment)
CEILING(GREATEST(0, ((((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units,
-- To Order (Apply MOQ/UOM logic here if needed, otherwise equals replenishment)
CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time))) AS forecast_lost_sales_units,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) AS forecast_lost_sales_units,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue,
ci.current_stock / NULLIF((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)), 0) AS stock_cover_in_days,
COALESCE(ooi.on_order_qty, 0) / NULLIF((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)), 0) AS po_cover_in_days,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)), 0) AS sells_out_in_days,
ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS stock_cover_in_days,
COALESCE(ooi.on_order_qty, 0) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS po_cover_in_days,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS sells_out_in_days,
-- Replenish Date: Date when stock is projected to hit safety stock, minus lead time
CASE
WHEN (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) > 0
THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)))::int - s.effective_lead_time
WHEN calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) > 0
THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int))::int - s.effective_lead_time
ELSE NULL
END AS replenish_date,
GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)))::int AS overstocked_units,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_lead_time) + ((sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail,
GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))::int AS overstocked_units,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail,
-- Old Stock Flag
(ci.created_at::date < _current_date - INTERVAL '60 day') AND
@@ -293,7 +414,86 @@ BEGIN
COALESCE(ooi.on_order_qty, 0) = 0
AS is_old_stock,
sa.yesterday_sales
sa.yesterday_sales,
-- Calculate status using direct CASE statements (inline logic)
CASE
-- Non-replenishable items default to Healthy
WHEN NOT ci.is_replenishable THEN 'Healthy'
-- Calculate lead time and thresholds
ELSE
CASE
-- Check for overstock first
WHEN GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock))) > 0 THEN 'Overstock'
-- Check for Critical stock
WHEN ci.current_stock <= 0 OR
(ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) <= 0 THEN 'Critical'
WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
-- Check for reorder soon
WHEN ((ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) + 7) THEN
CASE
WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
ELSE 'Reorder Soon'
END
-- Check for 'At Risk' - old stock
WHEN (ci.created_at::date < _current_date - INTERVAL '60 day') AND
(COALESCE(ci.date_last_sold, hd.max_order_date) IS NULL OR COALESCE(ci.date_last_sold, hd.max_order_date) < _current_date - INTERVAL '60 day') AND
(hd.date_last_received_calc IS NULL OR hd.date_last_received_calc < _current_date - INTERVAL '60 day') AND
COALESCE(ooi.on_order_qty, 0) = 0 THEN 'At Risk'
-- Check for 'At Risk' - hasn't sold in a long time
WHEN COALESCE(ci.date_last_sold, hd.max_order_date) IS NOT NULL
AND COALESCE(ci.date_last_sold, hd.max_order_date) < (_current_date - INTERVAL '90 days')
AND (CASE
WHEN ci.created_at IS NULL AND hd.date_first_sold IS NULL THEN 0
WHEN ci.created_at IS NULL THEN (_current_date - hd.date_first_sold)::integer
WHEN hd.date_first_sold IS NULL THEN (_current_date - ci.created_at::date)::integer
ELSE (_current_date - LEAST(ci.created_at::date, hd.date_first_sold))::integer
END) > 180 THEN 'At Risk'
-- Very high stock cover is at risk too
WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) > 365 THEN 'At Risk'
-- New products (less than 30 days old)
WHEN (CASE
WHEN ci.created_at IS NULL AND hd.date_first_sold IS NULL THEN 0
WHEN ci.created_at IS NULL THEN (_current_date - hd.date_first_sold)::integer
WHEN hd.date_first_sold IS NULL THEN (_current_date - ci.created_at::date)::integer
ELSE (_current_date - LEAST(ci.created_at::date, hd.date_first_sold))::integer
END) <= 30 THEN 'New'
-- If none of the above, assume Healthy
ELSE 'Healthy'
END
END AS status,
-- Growth Metrics (P3) - using safe_divide and std_numeric for consistency
std_numeric(safe_divide((sa.sales_30d - ppm.sales_prev_30d) * 100.0, ppm.sales_prev_30d), 2) AS sales_growth_30d_vs_prev,
std_numeric(safe_divide((sa.revenue_30d - ppm.revenue_prev_30d) * 100.0, ppm.revenue_prev_30d), 2) AS revenue_growth_30d_vs_prev,
std_numeric(safe_divide((sa.sales_30d - ppm.sales_30d_last_year) * 100.0, ppm.sales_30d_last_year), 2) AS sales_growth_yoy,
std_numeric(safe_divide((sa.revenue_30d - ppm.revenue_30d_last_year) * 100.0, ppm.revenue_30d_last_year), 2) AS revenue_growth_yoy,
-- Demand Variability (P3)
std_numeric(dv.sales_variance, 2) AS sales_variance_30d,
std_numeric(dv.sales_std_dev, 2) AS sales_std_dev_30d,
std_numeric(dv.sales_cv, 2) AS sales_cv_30d,
classify_demand_pattern(dv.avg_daily_sales, dv.sales_cv) AS demand_pattern,
-- Service Levels (P5)
std_numeric(COALESCE(sl.fill_rate_30d, 100), 2) AS fill_rate_30d,
COALESCE(sl.stockout_incidents_30d, 0)::int AS stockout_incidents_30d,
std_numeric(COALESCE(sl.service_level_30d, 100), 2) AS service_level_30d,
COALESCE(sl.lost_sales_incidents_30d, 0)::int AS lost_sales_incidents_30d,
-- Seasonality (P5)
std_numeric(season.seasonality_index, 2) AS seasonality_index,
COALESCE(season.seasonal_pattern, 'none') AS seasonal_pattern,
season.peak_season
FROM CurrentInfo ci
LEFT JOIN OnOrderInfo ooi ON ci.pid = ooi.pid
@@ -301,11 +501,19 @@ BEGIN
LEFT JOIN SnapshotAggregates sa ON ci.pid = sa.pid
LEFT JOIN FirstPeriodMetrics fpm ON ci.pid = fpm.pid
LEFT JOIN Settings s ON ci.pid = s.pid
LEFT JOIN LifetimeRevenue lr ON ci.pid = lr.pid
LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid
LEFT JOIN DemandVariability dv ON ci.pid = dv.pid
LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid
LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid
WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked
ON CONFLICT (pid) DO UPDATE SET
last_calculated = EXCLUDED.last_calculated,
sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
barcode = EXCLUDED.barcode, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, vendor_reference = EXCLUDED.vendor_reference, notions_reference = EXCLUDED.notions_reference, line = EXCLUDED.line, subline = EXCLUDED.subline, artist = EXCLUDED.artist,
moq = EXCLUDED.moq, rating = EXCLUDED.rating, reviews = EXCLUDED.reviews, weight = EXCLUDED.weight, length = EXCLUDED.length, width = EXCLUDED.width, height = EXCLUDED.height, country_of_origin = EXCLUDED.country_of_origin, location = EXCLUDED.location,
baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, preorder_count = EXCLUDED.preorder_count, notions_inv_count = EXCLUDED.notions_inv_count,
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
@@ -315,7 +523,7 @@ BEGIN
stockout_days_30d = EXCLUDED.stockout_days_30d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
avg_stock_units_30d = EXCLUDED.avg_stock_units_30d, avg_stock_cost_30d = EXCLUDED.avg_stock_cost_30d, avg_stock_retail_30d = EXCLUDED.avg_stock_retail_30d, avg_stock_gross_30d = EXCLUDED.avg_stock_gross_30d,
received_qty_30d = EXCLUDED.received_qty_30d, received_cost_30d = EXCLUDED.received_cost_30d,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, lifetime_revenue_quality = EXCLUDED.lifetime_revenue_quality,
first_7_days_sales = EXCLUDED.first_7_days_sales, first_7_days_revenue = EXCLUDED.first_7_days_revenue, first_30_days_sales = EXCLUDED.first_30_days_sales, first_30_days_revenue = EXCLUDED.first_30_days_revenue,
first_60_days_sales = EXCLUDED.first_60_days_sales, first_60_days_revenue = EXCLUDED.first_60_days_revenue, first_90_days_sales = EXCLUDED.first_90_days_sales, first_90_days_revenue = EXCLUDED.first_90_days_revenue,
asp_30d = EXCLUDED.asp_30d, acp_30d = EXCLUDED.acp_30d, avg_ros_30d = EXCLUDED.avg_ros_30d, avg_sales_per_day_30d = EXCLUDED.avg_sales_per_day_30d, avg_sales_per_month_30d = EXCLUDED.avg_sales_per_month_30d,
@@ -330,7 +538,40 @@ BEGIN
to_order_units = EXCLUDED.to_order_units, forecast_lost_sales_units = EXCLUDED.forecast_lost_sales_units, forecast_lost_revenue = EXCLUDED.forecast_lost_revenue,
stock_cover_in_days = EXCLUDED.stock_cover_in_days, po_cover_in_days = EXCLUDED.po_cover_in_days, sells_out_in_days = EXCLUDED.sells_out_in_days, replenish_date = EXCLUDED.replenish_date,
overstocked_units = EXCLUDED.overstocked_units, overstocked_cost = EXCLUDED.overstocked_cost, overstocked_retail = EXCLUDED.overstocked_retail, is_old_stock = EXCLUDED.is_old_stock,
yesterday_sales = EXCLUDED.yesterday_sales
yesterday_sales = EXCLUDED.yesterday_sales,
status = EXCLUDED.status,
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev,
sales_growth_yoy = EXCLUDED.sales_growth_yoy,
revenue_growth_yoy = EXCLUDED.revenue_growth_yoy,
sales_variance_30d = EXCLUDED.sales_variance_30d,
sales_std_dev_30d = EXCLUDED.sales_std_dev_30d,
sales_cv_30d = EXCLUDED.sales_cv_30d,
demand_pattern = EXCLUDED.demand_pattern,
fill_rate_30d = EXCLUDED.fill_rate_30d,
stockout_incidents_30d = EXCLUDED.stockout_incidents_30d,
service_level_30d = EXCLUDED.service_level_30d,
lost_sales_incidents_30d = EXCLUDED.lost_sales_incidents_30d,
seasonality_index = EXCLUDED.seasonality_index,
seasonal_pattern = EXCLUDED.seasonal_pattern,
peak_season = EXCLUDED.peak_season
WHERE -- Only update if at least one key metric has changed
product_metrics.current_stock IS DISTINCT FROM EXCLUDED.current_stock OR
product_metrics.current_price IS DISTINCT FROM EXCLUDED.current_price OR
product_metrics.current_cost_price IS DISTINCT FROM EXCLUDED.current_cost_price OR
product_metrics.on_order_qty IS DISTINCT FROM EXCLUDED.on_order_qty OR
product_metrics.sales_7d IS DISTINCT FROM EXCLUDED.sales_7d OR
product_metrics.sales_30d IS DISTINCT FROM EXCLUDED.sales_30d OR
product_metrics.revenue_30d IS DISTINCT FROM EXCLUDED.revenue_30d OR
product_metrics.status IS DISTINCT FROM EXCLUDED.status OR
product_metrics.replenishment_units IS DISTINCT FROM EXCLUDED.replenishment_units OR
product_metrics.stock_cover_in_days IS DISTINCT FROM EXCLUDED.stock_cover_in_days OR
product_metrics.yesterday_sales IS DISTINCT FROM EXCLUDED.yesterday_sales OR
-- Check a few other important fields that might change
product_metrics.date_last_sold IS DISTINCT FROM EXCLUDED.date_last_sold OR
product_metrics.earliest_expected_date IS DISTINCT FROM EXCLUDED.earliest_expected_date OR
product_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales OR
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality
;
-- Update the status table with the timestamp from the START of this run
@@ -340,4 +581,29 @@ BEGIN
RAISE NOTICE 'Finished % module. Duration: %', _module_name, clock_timestamp() - _start_time;
END $$;
END $$;
-- Return metrics about the update operation
WITH update_stats AS (
SELECT
COUNT(*) as total_products,
COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed,
COUNT(*) FILTER (WHERE status = 'Critical') as critical_count,
COUNT(*) FILTER (WHERE status = 'Reorder Soon') as reorder_soon_count,
COUNT(*) FILTER (WHERE status = 'Healthy') as healthy_count,
COUNT(*) FILTER (WHERE status = 'Overstock') as overstock_count,
COUNT(*) FILTER (WHERE status = 'At Risk') as at_risk_count,
COUNT(*) FILTER (WHERE status = 'New') as new_count
FROM public.product_metrics
)
SELECT
rows_processed,
total_products,
critical_count,
reorder_soon_count,
healthy_count,
overstock_count,
at_risk_count,
new_count,
ROUND((rows_processed::numeric / NULLIF(total_products, 0)) * 100, 2) as update_percentage
FROM update_stats;
@@ -2,13 +2,23 @@ const fs = require('fs');
const path = require('path');
// Helper function to format elapsed time
function formatElapsedTime(elapsed) {
// If elapsed is a timestamp, convert to elapsed milliseconds
if (elapsed instanceof Date || elapsed > 1000000000000) {
elapsed = Date.now() - elapsed;
function formatElapsedTime(startTime) {
let elapsed;
// If startTime is a timestamp (number representing milliseconds since epoch)
if (typeof startTime === 'number') {
// Check if it's a timestamp (will be a large number like 1700000000000)
if (startTime > 1000000000) { // timestamps are in milliseconds since 1970
elapsed = Date.now() - startTime;
} else {
// Assume it's already elapsed milliseconds
elapsed = startTime;
}
} else if (startTime instanceof Date) {
elapsed = Date.now() - startTime.getTime();
} else {
// If elapsed is in seconds, convert to milliseconds
elapsed = elapsed * 1000;
// Default to 0 if invalid input
elapsed = 0;
}
const seconds = Math.floor(elapsed / 1000);
@@ -16,7 +26,7 @@ function formatElapsedTime(elapsed) {
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
@@ -26,16 +36,31 @@ function formatElapsedTime(elapsed) {
// Helper function to estimate remaining time
function estimateRemaining(startTime, current, total) {
if (current === 0) return null;
// Handle edge cases
if (!current || current === 0 || !total || total === 0 || current >= total) {
return null;
}
// Calculate elapsed time in milliseconds
const elapsed = Date.now() - startTime;
if (elapsed <= 0) return null;
// Calculate rate (items per millisecond)
const rate = current / elapsed;
if (rate <= 0) return null;
// Calculate remaining time in milliseconds
const remaining = (total - current) / rate;
const minutes = Math.floor(remaining / 60000);
const seconds = Math.floor((remaining % 60000) / 1000);
// Convert to readable format
const seconds = Math.floor(remaining / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
+200 -109
View File
@@ -13,6 +13,22 @@ const dbConfig = {
port: process.env.DB_PORT || 5432
};
// Tables to always protect from being dropped
const PROTECTED_TABLES = [
'users',
'permissions',
'user_permissions',
'calculate_history',
'import_history',
'ai_prompts',
'ai_validation_performance',
'templates',
'reusable_images',
'imported_daily_inventory',
'imported_product_stat_history',
'imported_product_current_prices'
];
// Helper function to output progress in JSON format
function outputProgress(data) {
if (!data.status) {
@@ -33,17 +49,6 @@ const CORE_TABLES = [
'product_categories'
];
// Config tables that must be created
const CONFIG_TABLES = [
'stock_thresholds',
'lead_time_thresholds',
'sales_velocity_config',
'abc_classification_config',
'safety_stock_config',
'sales_seasonality',
'turnover_config'
];
// Split SQL into individual statements
function splitSQLStatements(sql) {
// First, normalize line endings
@@ -184,8 +189,8 @@ async function resetDatabase() {
SELECT string_agg(tablename, ', ') as tables
FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT IN ('users', 'permissions', 'user_permissions', 'calculate_history', 'import_history', 'ai_prompts', 'ai_validation_performance', 'templates', 'reusable_images');
`);
AND tablename NOT IN (SELECT unnest($1::text[]));
`, [PROTECTED_TABLES]);
if (!tablesResult.rows[0].tables) {
outputProgress({
@@ -204,7 +209,7 @@ async function resetDatabase() {
// Drop all tables except users
const tables = tablesResult.rows[0].tables.split(', ');
for (const table of tables) {
if (!['users', 'reusable_images'].includes(table)) {
if (!PROTECTED_TABLES.includes(table)) {
await client.query(`DROP TABLE IF EXISTS "${table}" CASCADE`);
}
}
@@ -259,7 +264,9 @@ async function resetDatabase() {
'category_metrics',
'brand_metrics',
'sales_forecasts',
'abc_classification'
'abc_classification',
'daily_snapshots',
'periodic_metrics'
)
`);
}
@@ -301,51 +308,67 @@ async function resetDatabase() {
}
});
for (let i = 0; i < statements.length; i++) {
const stmt = statements[i];
try {
const result = await client.query(stmt);
// Verify if table was created (if this was a CREATE TABLE statement)
if (stmt.trim().toLowerCase().startsWith('create table')) {
const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?["]?(\w+)["]?/i)?.[1];
if (tableName) {
const tableExists = await client.query(`
SELECT COUNT(*) as count
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
`, [tableName]);
outputProgress({
operation: 'Table Creation Verification',
message: {
table: tableName,
exists: tableExists.rows[0].count > 0
}
});
// Start a transaction for better error handling
await client.query('BEGIN');
try {
for (let i = 0; i < statements.length; i++) {
const stmt = statements[i];
try {
const result = await client.query(stmt);
// Verify if table was created (if this was a CREATE TABLE statement)
if (stmt.trim().toLowerCase().startsWith('create table')) {
const tableName = stmt.match(/create\s+table\s+(?:if\s+not\s+exists\s+)?["]?(\w+)["]?/i)?.[1];
if (tableName) {
const tableExists = await client.query(`
SELECT COUNT(*) as count
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
`, [tableName]);
outputProgress({
operation: 'Table Creation Verification',
message: {
table: tableName,
exists: tableExists.rows[0].count > 0
}
});
}
}
outputProgress({
operation: 'SQL Progress',
message: {
statement: i + 1,
total: statements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
rowCount: result.rowCount
}
});
// Commit in chunks of 10 statements to avoid long-running transactions
if (i > 0 && i % 10 === 0) {
await client.query('COMMIT');
await client.query('BEGIN');
}
} catch (sqlError) {
await client.query('ROLLBACK');
outputProgress({
status: 'error',
operation: 'SQL Error',
error: sqlError.message,
statement: stmt,
statementNumber: i + 1
});
throw sqlError;
}
outputProgress({
operation: 'SQL Progress',
message: {
statement: i + 1,
total: statements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
rowCount: result.rowCount
}
});
} catch (sqlError) {
outputProgress({
status: 'error',
operation: 'SQL Error',
error: sqlError.message,
statement: stmt,
statementNumber: i + 1
});
throw sqlError;
}
// Commit the final transaction
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
// Verify core tables were created
@@ -383,11 +406,25 @@ async function resetDatabase() {
operation: 'Running config setup',
message: 'Creating configuration tables...'
});
const configSchemaSQL = fs.readFileSync(
path.join(__dirname, '../db/config-schema-new.sql'),
'utf8'
);
const configSchemaPath = path.join(__dirname, '../db/config-schema-new.sql');
// Verify file exists
if (!fs.existsSync(configSchemaPath)) {
throw new Error(`Config schema file not found at: ${configSchemaPath}`);
}
const configSchemaSQL = fs.readFileSync(configSchemaPath, 'utf8');
outputProgress({
operation: 'Config Schema file',
message: {
path: configSchemaPath,
exists: fs.existsSync(configSchemaPath),
size: fs.statSync(configSchemaPath).size,
firstFewLines: configSchemaSQL.split('\n').slice(0, 5).join('\n')
}
});
// Execute config schema statements one at a time
const configStatements = splitSQLStatements(configSchemaSQL);
outputProgress({
@@ -401,30 +438,46 @@ async function resetDatabase() {
}
});
for (let i = 0; i < configStatements.length; i++) {
const stmt = configStatements[i];
try {
const result = await client.query(stmt);
outputProgress({
operation: 'Config SQL Progress',
message: {
statement: i + 1,
total: configStatements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
rowCount: result.rowCount
// Start a transaction for better error handling
await client.query('BEGIN');
try {
for (let i = 0; i < configStatements.length; i++) {
const stmt = configStatements[i];
try {
const result = await client.query(stmt);
outputProgress({
operation: 'Config SQL Progress',
message: {
statement: i + 1,
total: configStatements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
rowCount: result.rowCount
}
});
// Commit in chunks of 10 statements to avoid long-running transactions
if (i > 0 && i % 10 === 0) {
await client.query('COMMIT');
await client.query('BEGIN');
}
});
} catch (sqlError) {
outputProgress({
status: 'error',
operation: 'Config SQL Error',
error: sqlError.message,
statement: stmt,
statementNumber: i + 1
});
throw sqlError;
} catch (sqlError) {
await client.query('ROLLBACK');
outputProgress({
status: 'error',
operation: 'Config SQL Error',
error: sqlError.message,
statement: stmt,
statementNumber: i + 1
});
throw sqlError;
}
}
// Commit the final transaction
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
// Read and execute metrics schema (metrics tables)
@@ -432,11 +485,25 @@ async function resetDatabase() {
operation: 'Running metrics setup',
message: 'Creating metrics tables...'
});
const metricsSchemaSQL = fs.readFileSync(
path.join(__dirname, '../db/metrics-schema-new.sql'),
'utf8'
);
const metricsSchemaPath = path.join(__dirname, '../db/metrics-schema-new.sql');
// Verify file exists
if (!fs.existsSync(metricsSchemaPath)) {
throw new Error(`Metrics schema file not found at: ${metricsSchemaPath}`);
}
const metricsSchemaSQL = fs.readFileSync(metricsSchemaPath, 'utf8');
outputProgress({
operation: 'Metrics Schema file',
message: {
path: metricsSchemaPath,
exists: fs.existsSync(metricsSchemaPath),
size: fs.statSync(metricsSchemaPath).size,
firstFewLines: metricsSchemaSQL.split('\n').slice(0, 5).join('\n')
}
});
// Execute metrics schema statements one at a time
const metricsStatements = splitSQLStatements(metricsSchemaSQL);
outputProgress({
@@ -450,30 +517,46 @@ async function resetDatabase() {
}
});
for (let i = 0; i < metricsStatements.length; i++) {
const stmt = metricsStatements[i];
try {
const result = await client.query(stmt);
outputProgress({
operation: 'Metrics SQL Progress',
message: {
statement: i + 1,
total: metricsStatements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
rowCount: result.rowCount
// Start a transaction for better error handling
await client.query('BEGIN');
try {
for (let i = 0; i < metricsStatements.length; i++) {
const stmt = metricsStatements[i];
try {
const result = await client.query(stmt);
outputProgress({
operation: 'Metrics SQL Progress',
message: {
statement: i + 1,
total: metricsStatements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
rowCount: result.rowCount
}
});
// Commit in chunks of 10 statements to avoid long-running transactions
if (i > 0 && i % 10 === 0) {
await client.query('COMMIT');
await client.query('BEGIN');
}
});
} catch (sqlError) {
outputProgress({
status: 'error',
operation: 'Metrics SQL Error',
error: sqlError.message,
statement: stmt,
statementNumber: i + 1
});
throw sqlError;
} catch (sqlError) {
await client.query('ROLLBACK');
outputProgress({
status: 'error',
operation: 'Metrics SQL Error',
error: sqlError.message,
statement: stmt,
statementNumber: i + 1
});
throw sqlError;
}
}
// Commit the final transaction
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
outputProgress({
@@ -490,6 +573,14 @@ async function resetDatabase() {
});
process.exit(1);
} finally {
// Make sure to re-enable foreign key checks if they were disabled
try {
await client.query('SET session_replication_role = \'origin\'');
} catch (e) {
console.error('Error re-enabling foreign key checks:', e.message);
}
// Close the database connection
await client.end();
}
}
@@ -31,7 +31,10 @@ const PROTECTED_TABLES = [
'ai_prompts',
'ai_validation_performance',
'templates',
'reusable_images'
'reusable_images',
'imported_daily_inventory',
'imported_product_stat_history',
'imported_product_current_prices'
];
// Split SQL into individual statements
+2 -2
View File
@@ -4,7 +4,7 @@ const cors = require('cors');
const corsMiddleware = cors({
origin: [
'https://inventory.kent.pw',
'http://localhost:5173',
'http://localhost:5175',
/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/
],
@@ -26,7 +26,7 @@ const corsErrorHandler = (err, req, res, next) => {
res.status(403).json({
error: 'CORS not allowed',
origin: req.get('Origin'),
message: 'Origin not in allowed list: https://inventory.kent.pw, localhost:5173, 192.168.x.x, or 10.x.x.x'
message: 'Origin not in allowed list: https://inventory.kent.pw, localhost:5175, 192.168.x.x, or 10.x.x.x'
});
} else {
next(err);
+47 -63
View File
@@ -51,83 +51,67 @@ router.get('/:id', async (req, res) => {
}
});
// Get prompt by company
router.get('/company/:companyId', async (req, res) => {
// Get prompt by type (general, system, company_specific)
router.get('/by-type', async (req, res) => {
try {
const { companyId } = req.params;
const { type, company } = req.query;
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
SELECT * FROM ai_prompts
WHERE company = $1
`, [companyId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'AI prompt not found for this company' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching AI prompt by company:', error);
res.status(500).json({
error: 'Failed to fetch AI prompt by company',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Get general prompt
router.get('/type/general', async (req, res) => {
try {
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
// Validate prompt type
if (!type || !['general', 'system', 'company_specific'].includes(type)) {
return res.status(400).json({
error: 'Valid type query parameter is required (general, system, or company_specific)'
});
}
const result = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'general'
`);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'General AI prompt not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching general AI prompt:', error);
res.status(500).json({
error: 'Failed to fetch general AI prompt',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Get system prompt
router.get('/type/system', async (req, res) => {
try {
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
// For company_specific type, company ID is required
if (type === 'company_specific' && !company) {
return res.status(400).json({
error: 'Company ID is required for company_specific prompt type'
});
}
const result = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'system'
`);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'System AI prompt not found' });
// For general and system types, company should not be provided
if ((type === 'general' || type === 'system') && company) {
return res.status(400).json({
error: 'Company ID should not be provided for general or system prompt types'
});
}
// Build the query based on the type
let query, params;
if (type === 'company_specific') {
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1 AND company = $2';
params = [type, company];
} else {
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1';
params = [type];
}
// Execute the query
const result = await pool.query(query, params);
// Check if any prompt was found
if (result.rows.length === 0) {
let errorMessage;
if (type === 'company_specific') {
errorMessage = `AI prompt not found for company ${company}`;
} else {
errorMessage = `${type.charAt(0).toUpperCase() + type.slice(1)} AI prompt not found`;
}
return res.status(404).json({ error: errorMessage });
}
// Return the first matching prompt
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching system AI prompt:', error);
console.error('Error fetching AI prompt by type:', error);
res.status(500).json({
error: 'Failed to fetch system AI prompt',
error: 'Failed to fetch AI prompt',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
+22 -84
View File
@@ -6,6 +6,7 @@ const path = require("path");
const dotenv = require("dotenv");
const mysql = require('mysql2/promise');
const { Client } = require('ssh2');
const { getDbConnection } = require('../utils/dbConnection'); // Import the optimized connection function
// Ensure environment variables are loaded
dotenv.config({ path: path.join(__dirname, "../../.env") });
@@ -18,50 +19,6 @@ if (!process.env.OPENAI_API_KEY) {
console.error("Warning: OPENAI_API_KEY is not set in environment variables");
}
// Helper function to setup SSH tunnel to production database
async function setupSshTunnel() {
const sshConfig = {
host: process.env.PROD_SSH_HOST,
port: process.env.PROD_SSH_PORT || 22,
username: process.env.PROD_SSH_USER,
privateKey: process.env.PROD_SSH_KEY_PATH
? require('fs').readFileSync(process.env.PROD_SSH_KEY_PATH)
: undefined,
compress: true
};
const dbConfig = {
host: process.env.PROD_DB_HOST || 'localhost',
user: process.env.PROD_DB_USER,
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
timezone: 'Z'
};
return new Promise((resolve, reject) => {
const ssh = new Client();
ssh.on('error', (err) => {
console.error('SSH connection error:', err);
reject(err);
});
ssh.on('ready', () => {
ssh.forwardOut(
'127.0.0.1',
0,
dbConfig.host,
dbConfig.port,
(err, stream) => {
if (err) reject(err);
resolve({ ssh, stream, dbConfig });
}
);
}).connect(sshConfig);
});
}
// Debug endpoint for viewing prompt
router.post("/debug", async (req, res) => {
try {
@@ -195,16 +152,12 @@ async function generateDebugResponse(productsToUse, res) {
// Load taxonomy data first
console.log("Loading taxonomy data...");
try {
// Setup MySQL connection via SSH tunnel
const tunnel = await setupSshTunnel();
ssh = tunnel.ssh;
// Use optimized database connection
const { connection, ssh: connSsh } = await getDbConnection();
mysqlConnection = connection;
ssh = connSsh;
mysqlConnection = await mysql.createConnection({
...tunnel.dbConfig,
stream: tunnel.stream
});
console.log("MySQL connection established successfully");
console.log("MySQL connection established successfully using optimized connection");
taxonomy = await getTaxonomyData(mysqlConnection);
console.log("Successfully loaded taxonomy data");
@@ -218,10 +171,6 @@ async function generateDebugResponse(productsToUse, res) {
errno: taxonomyError.errno || null,
sql: taxonomyError.sql || null,
});
} finally {
// Make sure we close the connection
if (mysqlConnection) await mysqlConnection.end();
if (ssh) ssh.end();
}
// Verify the taxonomy data structure
@@ -282,11 +231,8 @@ async function generateDebugResponse(productsToUse, res) {
console.log("Loading prompt...");
// Setup a new connection for loading the prompt
const promptTunnel = await setupSshTunnel();
const promptConnection = await mysql.createConnection({
...promptTunnel.dbConfig,
stream: promptTunnel.stream
});
// Use optimized connection instead of creating a new one
const { connection: promptConnection } = await getDbConnection();
try {
// Get the local PostgreSQL pool to fetch prompts
@@ -296,7 +242,7 @@ async function generateDebugResponse(productsToUse, res) {
throw new Error("Database connection not available");
}
// First, fetch the system prompt
// First, fetch the system prompt using the consolidated endpoint approach
const systemPromptResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'system'
@@ -311,7 +257,7 @@ async function generateDebugResponse(productsToUse, res) {
console.warn("⚠️ No system prompt found in database, will use default");
}
// Then, fetch the general prompt
// Then, fetch the general prompt using the consolidated endpoint approach
const generalPromptResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'general'
@@ -458,7 +404,6 @@ async function generateDebugResponse(productsToUse, res) {
return response;
} finally {
if (promptConnection) await promptConnection.end();
if (promptTunnel.ssh) promptTunnel.ssh.end();
}
} catch (error) {
console.error("Error generating debug response:", error);
@@ -645,7 +590,7 @@ async function loadPrompt(connection, productsToValidate = null, appPool = null)
throw new Error("Database connection not available");
}
// Fetch the system prompt
// Fetch the system prompt using the consolidated endpoint approach
const systemPromptResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'system'
@@ -662,7 +607,7 @@ async function loadPrompt(connection, productsToValidate = null, appPool = null)
console.warn("⚠️ No system prompt found in database, using default");
}
// Fetch the general prompt
// Fetch the general prompt using the consolidated endpoint approach
const generalPromptResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'general'
@@ -926,15 +871,11 @@ router.post("/validate", async (req, res) => {
let promptLength = 0; // Track prompt length for performance metrics
try {
// Setup MySQL connection via SSH tunnel
console.log("🔄 Setting up connection to production database...");
const tunnel = await setupSshTunnel();
ssh = tunnel.ssh;
connection = await mysql.createConnection({
...tunnel.dbConfig,
stream: tunnel.stream
});
// Use the optimized connection utility instead of direct SSH tunnel
console.log("🔄 Setting up connection to production database using optimized connection...");
const { ssh: connSsh, connection: connDB } = await getDbConnection();
ssh = connSsh;
connection = connDB;
console.log("🔄 MySQL connection established successfully");
@@ -1238,14 +1179,11 @@ router.get("/test-taxonomy", async (req, res) => {
let connection = null;
try {
// Setup MySQL connection via SSH tunnel
const tunnel = await setupSshTunnel();
ssh = tunnel.ssh;
connection = await mysql.createConnection({
...tunnel.dbConfig,
stream: tunnel.stream
});
// Use the optimized connection utility instead of direct SSH tunnel
console.log("🔄 Setting up connection to production database using optimized connection...");
const { ssh: connSsh, connection: connDB } = await getDbConnection();
ssh = connSsh;
connection = connDB;
console.log("MySQL connection established successfully for test");
+225 -397
View File
@@ -7,37 +7,33 @@ router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
const { rows: [results] } = await pool.query(`
SELECT
COALESCE(
ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
),
0
) as profitMargin,
COALESCE(
ROUND(
(AVG(p.price / NULLIF(p.cost_price, 0) - 1) * 100)::numeric, 1
),
0
) as averageMarkup,
COALESCE(
ROUND(
(SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 2
),
0
) as stockTurnoverRate,
COALESCE(COUNT(DISTINCT p.vendor), 0) as vendorCount,
COALESCE(COUNT(DISTINCT p.categories), 0) as categoryCount,
COALESCE(
ROUND(
AVG(o.price * o.quantity)::numeric, 2
),
0
) as averageOrderValue
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
WITH vendor_count AS (
SELECT COUNT(DISTINCT vendor_name) AS count
FROM vendor_metrics
),
category_count AS (
SELECT COUNT(DISTINCT category_id) AS count
FROM category_metrics
),
metrics_summary AS (
SELECT
AVG(margin_30d) AS avg_profit_margin,
AVG(markup_30d) AS avg_markup,
AVG(stockturn_30d) AS avg_stock_turnover,
AVG(asp_30d) AS avg_order_value
FROM product_metrics
WHERE sales_30d > 0
)
SELECT
COALESCE(ms.avg_profit_margin, 0) AS profitMargin,
COALESCE(ms.avg_markup, 0) AS averageMarkup,
COALESCE(ms.avg_stock_turnover, 0) AS stockTurnoverRate,
COALESCE(vc.count, 0) AS vendorCount,
COALESCE(cc.count, 0) AS categoryCount,
COALESCE(ms.avg_order_value, 0) AS averageOrderValue
FROM metrics_summary ms
CROSS JOIN vendor_count vc
CROSS JOIN category_count cc
`);
// Ensure all values are numbers
@@ -84,43 +80,53 @@ router.get('/profit', async (req, res) => {
JOIN category_path cp ON c.parent_id = cp.cat_id
)
SELECT
c.name as category,
cp.path as categoryPath,
ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
) as profitMargin,
ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY c.name, cp.path
ORDER BY profitMargin DESC
cm.category_name as category,
COALESCE(cp.path, cm.category_name) as categorypath,
cm.avg_margin_30d as profitmargin,
cm.revenue_30d as revenue,
cm.cogs_30d as cost
FROM category_metrics cm
LEFT JOIN category_path cp ON cm.category_id = cp.cat_id
WHERE cm.revenue_30d > 0
ORDER BY cm.revenue_30d DESC
LIMIT 10
`);
// Get profit margin trend over time
// Get profit margin over time
const { rows: overTime } = await pool.query(`
SELECT
to_char(o.date, 'YYYY-MM-DD') as date,
ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
) as profitMargin,
ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY to_char(o.date, 'YYYY-MM-DD')
ORDER BY date
WITH time_series AS (
SELECT
date_trunc('day', generate_series(
CURRENT_DATE - INTERVAL '30 days',
CURRENT_DATE,
'1 day'::interval
))::date AS date
),
daily_profits AS (
SELECT
snapshot_date as date,
SUM(net_revenue) as revenue,
SUM(cogs) as cost,
CASE
WHEN SUM(net_revenue) > 0
THEN (SUM(net_revenue - cogs) / SUM(net_revenue)) * 100
ELSE 0
END as profit_margin
FROM daily_product_snapshots
WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY snapshot_date
)
SELECT
to_char(ts.date, 'YYYY-MM-DD') as date,
COALESCE(dp.profit_margin, 0) as profitmargin,
COALESCE(dp.revenue, 0) as revenue,
COALESCE(dp.cost, 0) as cost
FROM time_series ts
LEFT JOIN daily_profits dp ON ts.date = dp.date
ORDER BY ts.date
`);
// Get top performing products with category paths
// Get top performing products by profit margin
const { rows: topProducts } = await pool.query(`
WITH RECURSIVE category_path AS (
SELECT
@@ -140,26 +146,28 @@ router.get('/profit', async (req, res) => {
(cp.path || ' > ' || c.name)::text
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
),
product_categories AS (
SELECT
pc.pid,
c.name as category,
COALESCE(cp.path, c.name) as categorypath
FROM product_categories pc
JOIN categories c ON pc.cat_id = c.cat_id
LEFT JOIN category_path cp ON c.cat_id = cp.cat_id
)
SELECT
p.title as product,
c.name as category,
cp.path as categoryPath,
ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
) as profitMargin,
ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY p.pid, p.title, c.name, cp.path
HAVING SUM(o.price * o.quantity) > 0
ORDER BY profitMargin DESC
pm.title as product,
COALESCE(pc.category, 'Uncategorized') as category,
COALESCE(pc.categorypath, 'Uncategorized') as categorypath,
pm.margin_30d as profitmargin,
pm.revenue_30d as revenue,
pm.cogs_30d as cost
FROM product_metrics pm
LEFT JOIN product_categories pc ON pm.pid = pc.pid
WHERE pm.revenue_30d > 100
AND pm.margin_30d > 0
ORDER BY pm.margin_30d DESC
LIMIT 10
`);
@@ -184,93 +192,48 @@ router.get('/vendors', async (req, res) => {
console.log('Fetching vendor performance data...');
// First check if we have any vendors with sales
const { rows: [checkData] } = await pool.query(`
SELECT COUNT(DISTINCT p.vendor) as vendor_count,
COUNT(DISTINCT o.order_number) as order_count
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
WHERE p.vendor IS NOT NULL
`);
console.log('Vendor data check:', checkData);
// Get vendor performance metrics
// Get vendor performance metrics from the vendor_metrics table
const { rows: rawPerformance } = await pool.query(`
WITH monthly_sales AS (
SELECT
p.vendor,
ROUND(SUM(CASE
WHEN o.date >= CURRENT_DATE - INTERVAL '30 days'
THEN o.price * o.quantity
ELSE 0
END)::numeric, 3) as current_month,
ROUND(SUM(CASE
WHEN o.date >= CURRENT_DATE - INTERVAL '60 days'
AND o.date < CURRENT_DATE - INTERVAL '30 days'
THEN o.price * o.quantity
ELSE 0
END)::numeric, 3) as previous_month
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
WHERE p.vendor IS NOT NULL
AND o.date >= CURRENT_DATE - INTERVAL '60 days'
GROUP BY p.vendor
)
SELECT
p.vendor,
ROUND(SUM(o.price * o.quantity)::numeric, 3) as sales_volume,
COALESCE(ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
), 0) as profit_margin,
COALESCE(ROUND(
(SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1
), 0) as stock_turnover,
COUNT(DISTINCT p.pid) as product_count,
ROUND(
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
1
) as growth
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor
WHERE p.vendor IS NOT NULL
AND o.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY p.vendor, ms.current_month, ms.previous_month
ORDER BY sales_volume DESC
LIMIT 10
vendor_name as vendor,
revenue_30d as sales_volume,
avg_margin_30d as profit_margin,
COALESCE(
sales_30d / NULLIF(current_stock_units, 0),
0
) as stock_turnover,
product_count,
-- Use actual growth metrics from the vendor_metrics table
sales_growth_30d_vs_prev as growth
FROM vendor_metrics
WHERE revenue_30d > 0
ORDER BY revenue_30d DESC
LIMIT 20
`);
// Transform to camelCase properties for frontend consumption
const performance = rawPerformance.map(item => ({
vendor: item.vendor,
salesVolume: Number(item.sales_volume) || 0,
profitMargin: Number(item.profit_margin) || 0,
stockTurnover: Number(item.stock_turnover) || 0,
productCount: Number(item.product_count) || 0,
growth: Number(item.growth) || 0
// Format the performance data
const performance = rawPerformance.map(vendor => ({
vendor: vendor.vendor,
salesVolume: Number(vendor.sales_volume) || 0,
profitMargin: Number(vendor.profit_margin) || 0,
stockTurnover: Number(vendor.stock_turnover) || 0,
productCount: Number(vendor.product_count) || 0,
growth: Number(vendor.growth) || 0
}));
// Get vendor comparison metrics (sales per product vs margin)
const { rows: rawComparison } = await pool.query(`
SELECT
p.vendor,
COALESCE(ROUND(
SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.pid), 0),
2
), 0) as sales_per_product,
COALESCE(ROUND(
AVG((p.price - p.cost_price) / NULLIF(p.cost_price, 0) * 100),
2
), 0) as average_margin,
COUNT(DISTINCT p.pid) as size
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
WHERE p.vendor IS NOT NULL
AND o.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY p.vendor
HAVING COUNT(DISTINCT p.pid) > 0
vendor_name as vendor,
CASE
WHEN active_product_count > 0
THEN revenue_30d / active_product_count
ELSE 0
END as sales_per_product,
avg_margin_30d as average_margin,
product_count as size
FROM vendor_metrics
WHERE active_product_count > 0
ORDER BY sales_per_product DESC
LIMIT 10
`);
@@ -294,58 +257,7 @@ router.get('/vendors', async (req, res) => {
});
} catch (error) {
console.error('Error fetching vendor performance:', error);
console.error('Error details:', error.message);
// Return dummy data on error with complete structure
res.json({
performance: [
{
vendor: "Example Vendor 1",
salesVolume: 10000,
profitMargin: 25.5,
stockTurnover: 3.2,
productCount: 15,
growth: 12.3
},
{
vendor: "Example Vendor 2",
salesVolume: 8500,
profitMargin: 22.8,
stockTurnover: 2.9,
productCount: 12,
growth: 8.7
},
{
vendor: "Example Vendor 3",
salesVolume: 6200,
profitMargin: 19.5,
stockTurnover: 2.5,
productCount: 8,
growth: 5.2
}
],
comparison: [
{
vendor: "Example Vendor 1",
salesPerProduct: 650,
averageMargin: 35.2,
size: 15
},
{
vendor: "Example Vendor 2",
salesPerProduct: 710,
averageMargin: 28.5,
size: 12
},
{
vendor: "Example Vendor 3",
salesPerProduct: 770,
averageMargin: 22.8,
size: 8
}
],
trends: []
});
res.status(500).json({ error: 'Failed to fetch vendor performance data' });
}
});
@@ -353,108 +265,119 @@ router.get('/vendors', async (req, res) => {
router.get('/stock', async (req, res) => {
try {
const pool = req.app.locals.pool;
console.log('Fetching stock analysis data...');
// Get global configuration values
const { rows: configs } = await pool.query(`
SELECT
st.low_stock_threshold,
tc.calculation_period_days as turnover_period
FROM stock_thresholds st
CROSS JOIN turnover_config tc
WHERE st.id = 1 AND tc.id = 1
`);
const config = configs[0] || {
low_stock_threshold: 5,
turnover_period: 30
};
// Use the new metrics tables to get data
// Get turnover by category
const { rows: turnoverByCategory } = await pool.query(`
SELECT
c.name as category,
ROUND((SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1) as turnoverRate,
ROUND(AVG(p.stock_quantity)::numeric, 0) as averageStock,
SUM(o.quantity) as totalSales
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.cat_id = c.cat_id
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
GROUP BY c.name
HAVING ROUND((SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1) > 0
ORDER BY turnoverRate DESC
LIMIT 10
`);
// Get stock levels over time
const { rows: stockLevels } = await pool.query(`
SELECT
to_char(o.date, 'YYYY-MM-DD') as date,
SUM(CASE WHEN p.stock_quantity > $1 THEN 1 ELSE 0 END) as inStock,
SUM(CASE WHEN p.stock_quantity <= $1 AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
GROUP BY to_char(o.date, 'YYYY-MM-DD')
ORDER BY date
`, [config.low_stock_threshold]);
// Get critical stock items
const { rows: criticalItems } = await pool.query(`
WITH product_thresholds AS (
WITH category_metrics_with_path AS (
WITH RECURSIVE category_path AS (
SELECT
c.cat_id,
c.name,
c.parent_id,
c.name::text as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
(cp.path || ' > ' || c.name)::text
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
)
SELECT
p.pid,
COALESCE(
(SELECT reorder_days
FROM stock_thresholds st
WHERE st.vendor = p.vendor LIMIT 1),
(SELECT reorder_days
FROM stock_thresholds st
WHERE st.vendor IS NULL LIMIT 1),
14
) as reorder_days
FROM products p
cm.category_id,
cm.category_name,
cp.path as category_path,
cm.current_stock_units,
cm.sales_30d,
cm.stock_turn_30d
FROM category_metrics cm
LEFT JOIN category_path cp ON cm.category_id = cp.cat_id
WHERE cm.sales_30d > 0
)
SELECT
p.title as product,
p.SKU as sku,
p.stock_quantity as stockQuantity,
GREATEST(ROUND((AVG(o.quantity) * pt.reorder_days)::numeric), $1) as reorderPoint,
ROUND((SUM(o.quantity) / NULLIF(p.stock_quantity, 0))::numeric, 1) as turnoverRate,
CASE
WHEN p.stock_quantity = 0 THEN 0
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
END as daysUntilStockout
FROM products p
LEFT JOIN orders o ON p.pid = o.pid
JOIN product_thresholds pt ON p.pid = pt.pid
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
AND p.managing_stock = true
GROUP BY p.pid, pt.reorder_days
HAVING
CASE
WHEN p.stock_quantity = 0 THEN 0
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
END < $3
AND
CASE
WHEN p.stock_quantity = 0 THEN 0
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
END >= 0
ORDER BY daysUntilStockout
category_name as category,
COALESCE(stock_turn_30d, 0) as turnoverRate,
current_stock_units as averageStock,
sales_30d as totalSales
FROM category_metrics_with_path
ORDER BY stock_turn_30d DESC NULLS LAST
LIMIT 10
`, [
config.low_stock_threshold,
config.turnover_period,
config.turnover_period
]);
res.json({ turnoverByCategory, stockLevels, criticalItems });
`);
// Get stock levels over time (last 30 days)
const { rows: stockLevels } = await pool.query(`
WITH date_range AS (
SELECT generate_series(
CURRENT_DATE - INTERVAL '30 days',
CURRENT_DATE,
'1 day'::interval
)::date AS date
),
daily_stock_counts AS (
SELECT
snapshot_date,
COUNT(DISTINCT pid) as total_products,
COUNT(DISTINCT CASE WHEN eod_stock_quantity > 5 THEN pid END) as in_stock,
COUNT(DISTINCT CASE WHEN eod_stock_quantity <= 5 AND eod_stock_quantity > 0 THEN pid END) as low_stock,
COUNT(DISTINCT CASE WHEN eod_stock_quantity = 0 THEN pid END) as out_of_stock
FROM daily_product_snapshots
WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY snapshot_date
)
SELECT
to_char(dr.date, 'YYYY-MM-DD') as date,
COALESCE(dsc.in_stock, 0) as inStock,
COALESCE(dsc.low_stock, 0) as lowStock,
COALESCE(dsc.out_of_stock, 0) as outOfStock
FROM date_range dr
LEFT JOIN daily_stock_counts dsc ON dr.date = dsc.snapshot_date
ORDER BY dr.date
`);
// Get critical items (products that need reordering)
const { rows: criticalItems } = await pool.query(`
SELECT
pm.title as product,
pm.sku as sku,
pm.current_stock as stockQuantity,
COALESCE(pm.config_safety_stock, 0) as reorderPoint,
COALESCE(pm.stockturn_30d, 0) as turnoverRate,
CASE
WHEN pm.sales_velocity_daily > 0
THEN ROUND(pm.current_stock / pm.sales_velocity_daily)
ELSE 999
END as daysUntilStockout
FROM product_metrics pm
WHERE pm.is_visible = true
AND pm.is_replenishable = true
AND pm.sales_30d > 0
AND pm.current_stock <= pm.config_safety_stock * 2
ORDER BY
CASE
WHEN pm.sales_velocity_daily > 0
THEN pm.current_stock / pm.sales_velocity_daily
ELSE 999
END ASC,
pm.revenue_30d DESC
LIMIT 10
`);
res.json({
turnoverByCategory,
stockLevels,
criticalItems
});
} catch (error) {
console.error('Error fetching stock analysis:', error);
res.status(500).json({ error: 'Failed to fetch stock analysis' });
res.status(500).json({ error: 'Failed to fetch stock analysis', details: error.message });
}
});
@@ -685,99 +608,4 @@ router.get('/categories', async (req, res) => {
}
});
// Forecast endpoint
router.get('/forecast', async (req, res) => {
try {
const { brand, startDate, endDate } = req.query;
const pool = req.app.locals.pool;
const [results] = await pool.query(`
WITH RECURSIVE category_path AS (
SELECT
c.cat_id,
c.name,
c.parent_id,
CAST(c.name AS CHAR(1000)) as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
CONCAT(cp.path, ' > ', c.name)
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
),
category_metrics AS (
SELECT
c.cat_id,
c.name as category_name,
cp.path,
p.brand,
COUNT(DISTINCT p.pid) as num_products,
CAST(COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) AS DECIMAL(15,3)) as avg_daily_sales,
COALESCE(SUM(o.quantity), 0) as total_sold,
CAST(COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.pid), 2), 0) AS DECIMAL(15,3)) as avgTotalSold,
CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
FROM categories c
JOIN product_categories pc ON c.cat_id = pc.cat_id
JOIN products p ON pc.pid = p.pid
JOIN category_path cp ON c.cat_id = cp.cat_id
LEFT JOIN product_metrics pmet ON p.pid = pmet.pid
LEFT JOIN orders o ON p.pid = o.pid
AND o.date BETWEEN ? AND ?
AND o.canceled = false
WHERE p.brand = ?
AND pmet.first_received_date BETWEEN ? AND ?
GROUP BY c.cat_id, c.name, cp.path, p.brand
),
product_details AS (
SELECT
p.pid,
p.title,
p.SKU,
p.stock_quantity,
pc.cat_id,
pmet.first_received_date,
COALESCE(SUM(o.quantity), 0) as total_sold,
CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
FROM products p
JOIN product_categories pc ON p.pid = pc.pid
JOIN product_metrics pmet ON p.pid = pmet.pid
LEFT JOIN orders o ON p.pid = o.pid
AND o.date BETWEEN ? AND ?
AND o.canceled = false
WHERE p.brand = ?
AND pmet.first_received_date BETWEEN ? AND ?
GROUP BY p.pid, p.title, p.SKU, p.stock_quantity, pc.cat_id, pmet.first_received_date
)
SELECT
cm.*,
JSON_ARRAYAGG(
JSON_OBJECT(
'pid', pd.pid,
'title', pd.title,
'SKU', pd.SKU,
'stock_quantity', pd.stock_quantity,
'total_sold', pd.total_sold,
'avg_price', pd.avg_price,
'first_received_date', DATE_FORMAT(pd.first_received_date, '%Y-%m-%d')
)
) as products
FROM category_metrics cm
JOIN product_details pd ON cm.cat_id = pd.cat_id
GROUP BY cm.cat_id, cm.category_name, cm.path, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price
ORDER BY cm.total_sold DESC
`, [endDate, startDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
res.json(results);
} catch (error) {
console.error('Error fetching forecast data:', error);
res.status(500).json({ error: 'Failed to fetch forecast data' });
}
});
module.exports = router;
@@ -0,0 +1,284 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
// Maps query keys to DB columns in brand_metrics
const COLUMN_MAP = {
brandName: { dbCol: 'bm.brand_name', type: 'string' },
productCount: { dbCol: 'bm.product_count', type: 'number' },
activeProductCount: { dbCol: 'bm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'bm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'bm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'bm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'bm.current_stock_retail', type: 'number' },
sales7d: { dbCol: 'bm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'bm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'bm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'bm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'bm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'bm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'bm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'bm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'bm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'bm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'bm.avg_margin_30d', type: 'number' },
// Growth metrics
salesGrowth30dVsPrev: { dbCol: 'bm.sales_growth_30d_vs_prev', type: 'number' },
revenueGrowth30dVsPrev: { dbCol: 'bm.revenue_growth_30d_vs_prev', type: 'number' },
// Add aliases if needed
name: { dbCol: 'bm.brand_name', type: 'string' },
// Add status for filtering
status: { dbCol: 'brand_status', type: 'string' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// --- Route Handlers ---
// GET /brands-aggregate/filter-options (Just brands list for now)
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate/filter-options');
try {
// Get brand names
const { rows: brandRows } = await pool.query(`
SELECT DISTINCT brand_name FROM public.brand_metrics ORDER BY brand_name
`);
// Get status values - calculate them since they're derived
const { rows: statusRows } = await pool.query(`
SELECT DISTINCT
CASE
WHEN active_product_count > 0 AND sales_30d > 0 THEN 'active'
WHEN active_product_count > 0 THEN 'inactive'
ELSE 'pending'
END as status
FROM public.brand_metrics
ORDER BY status
`);
res.json({
brands: brandRows.map(r => r.brand_name),
statuses: statusRows.map(r => r.status)
});
} catch(error) {
console.error('Error fetching brand filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /brands-aggregate/stats (Overall brand stats)
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate/stats');
try {
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_brands,
COUNT(CASE WHEN active_product_count > 0 THEN 1 END) AS active_brands,
SUM(active_product_count) AS total_active_products,
SUM(current_stock_cost) AS total_stock_value,
-- Weighted Average Margin
SUM(profit_30d) * 100.0 / NULLIF(SUM(revenue_30d), 0) AS overall_avg_margin_weighted
FROM public.brand_metrics bm
`);
res.json({
totalBrands: parseInt(stats?.total_brands || 0),
activeBrands: parseInt(stats?.active_brands || 0),
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
avgMargin: parseFloat(stats?.overall_avg_margin_weighted || 0),
});
} catch (error) {
console.error('Error fetching brand stats:', error);
res.status(500).json({ error: 'Failed to fetch brand stats.' });
}
});
// GET /brands-aggregate/ (List brands)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'brandName'; // Default sort
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'bm.brand_name';
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
// Build conditions based on req.query, using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
}
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) { // Normalize operator
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
} else if (!conditionFragment) { // For LIKE/ILIKE
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
if (needsParam) paramCounter--;
}
} else {
console.warn(`Invalid filter key ignored: ${key}`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Status calculation similar to vendors
const statusCase = `
CASE
WHEN active_product_count > 0 AND sales_30d > 0 THEN 'active'
WHEN active_product_count > 0 THEN 'inactive'
ELSE 'pending'
END as brand_status
`;
const baseSql = `
FROM (
SELECT
bm.*,
${statusCase}
FROM public.brand_metrics bm
) bm
${whereClause}
`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `
WITH brand_data AS (
SELECT
bm.*,
${statusCase}
FROM public.brand_metrics bm
)
SELECT bm.*
FROM brand_data bm
${whereClause}
${sortClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const brands = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({
brands,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching brand metrics list:', error);
res.status(500).json({ error: 'Failed to fetch brand metrics.' });
}
});
// GET /brands-aggregate/:name (Get single brand metric)
// Implement if needed, remember to URL-decode the name parameter
module.exports = router;
-100
View File
@@ -1,100 +0,0 @@
const express = require('express');
const router = express.Router();
// Get all categories
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
try {
// Get all categories with metrics and hierarchy info
const { rows: categories } = await pool.query(`
SELECT
c.cat_id,
c.name,
c.type,
c.parent_id,
c.description,
c.status,
p.name as parent_name,
p.type as parent_type,
COALESCE(cm.product_count, 0) as product_count,
COALESCE(cm.active_products, 0) as active_products,
ROUND(COALESCE(cm.total_value, 0)::numeric, 3) as total_value,
COALESCE(cm.avg_margin, 0) as avg_margin,
COALESCE(cm.turnover_rate, 0) as turnover_rate,
COALESCE(cm.growth_rate, 0) as growth_rate
FROM categories c
LEFT JOIN categories p ON c.parent_id = p.cat_id
LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
ORDER BY
CASE
WHEN c.type = 10 THEN 1 -- sections first
WHEN c.type = 11 THEN 2 -- categories second
WHEN c.type = 12 THEN 3 -- subcategories third
WHEN c.type = 13 THEN 4 -- subsubcategories fourth
WHEN c.type = 20 THEN 5 -- themes fifth
WHEN c.type = 21 THEN 6 -- subthemes last
ELSE 7
END,
c.name ASC
`);
// Get overall stats
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(DISTINCT c.cat_id) as totalCategories,
COUNT(DISTINCT CASE WHEN c.status = 'active' THEN c.cat_id END) as activeCategories,
ROUND(COALESCE(SUM(cm.total_value), 0)::numeric, 3) as totalValue,
COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0))::numeric, 1), 0) as avgMargin,
COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0))::numeric, 1), 0) as avgGrowth
FROM categories c
LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
`);
// Get type counts for filtering
const { rows: typeCounts } = await pool.query(`
SELECT
type,
COUNT(*)::integer as count
FROM categories
GROUP BY type
ORDER BY type
`);
res.json({
categories: categories.map(cat => ({
cat_id: cat.cat_id,
name: cat.name,
type: cat.type,
parent_id: cat.parent_id,
parent_name: cat.parent_name,
parent_type: cat.parent_type,
description: cat.description,
status: cat.status,
metrics: {
product_count: parseInt(cat.product_count),
active_products: parseInt(cat.active_products),
total_value: parseFloat(cat.total_value),
avg_margin: parseFloat(cat.avg_margin),
turnover_rate: parseFloat(cat.turnover_rate),
growth_rate: parseFloat(cat.growth_rate)
}
})),
typeCounts: typeCounts.map(tc => ({
type: tc.type,
count: tc.count // Already cast to integer in the query
})),
stats: {
totalCategories: parseInt(stats.totalcategories),
activeCategories: parseInt(stats.activecategories),
totalValue: parseFloat(stats.totalvalue),
avgMargin: parseFloat(stats.avgmargin),
avgGrowth: parseFloat(stats.avggrowth)
}
});
} catch (error) {
console.error('Error fetching categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
module.exports = router;
@@ -0,0 +1,363 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 5000; // Increase this to allow retrieving all categories in one request
// Maps query keys to DB columns in category_metrics and categories tables
const COLUMN_MAP = {
categoryId: { dbCol: 'cm.category_id', type: 'integer' },
categoryName: { dbCol: 'cm.category_name', type: 'string' }, // From aggregate table
categoryType: { dbCol: 'cm.category_type', type: 'integer' }, // From aggregate table
parentId: { dbCol: 'cm.parent_id', type: 'integer' }, // From aggregate table
parentName: { dbCol: 'p.name', type: 'string' }, // Requires JOIN to categories
productCount: { dbCol: 'cm.product_count', type: 'number' },
activeProductCount: { dbCol: 'cm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'cm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'cm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'cm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'cm.current_stock_retail', type: 'number' },
sales7d: { dbCol: 'cm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'cm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'cm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'cm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'cm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'cm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'cm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'cm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'cm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'cm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'cm.avg_margin_30d', type: 'number' },
stockTurn30d: { dbCol: 'cm.stock_turn_30d', type: 'number' },
// Growth metrics
salesGrowth30dVsPrev: { dbCol: 'cm.sales_growth_30d_vs_prev', type: 'number' },
revenueGrowth30dVsPrev: { dbCol: 'cm.revenue_growth_30d_vs_prev', type: 'number' },
// Add status from the categories table for filtering
status: { dbCol: 'c.status', type: 'string' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// Type Labels (Consider moving to a shared config or fetching from DB)
const TYPE_LABELS = {
10: 'Section', 11: 'Category', 12: 'Subcategory', 13: 'Sub-subcategory',
1: 'Company', 2: 'Line', 3: 'Subline', 40: 'Artist', // From old schema comments
20: 'Theme', 21: 'Subtheme' // Additional types from categories.js
};
// --- Route Handlers ---
// GET /categories-aggregate/filter-options
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate/filter-options');
try {
// Fetch distinct types directly from the aggregate table if reliable
// Or join with categories table if source of truth is needed
const { rows: typeRows } = await pool.query(`
SELECT DISTINCT category_type
FROM public.category_metrics
ORDER BY category_type
`);
const typeOptions = typeRows.map(r => ({
value: r.category_type,
label: TYPE_LABELS[r.category_type] || `Type ${r.category_type}` // Add labels
}));
// Add status options for filtering (from categories.js)
const { rows: statusRows } = await pool.query(`
SELECT DISTINCT status FROM public.categories ORDER BY status
`);
// Get type counts (from categories.js)
const { rows: typeCounts } = await pool.query(`
SELECT
type,
COUNT(*)::integer as count
FROM categories
GROUP BY type
ORDER BY type
`);
res.json({
types: typeOptions,
statuses: statusRows.map(r => r.status),
typeCounts: typeCounts.map(tc => ({
type: tc.type,
count: tc.count
}))
});
} catch (error) {
console.error('Error fetching category filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /categories-aggregate/stats
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate/stats');
try {
// Calculate stats directly from the aggregate table
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_categories,
-- Count active based on the source categories table status
COUNT(CASE WHEN c.status = 'active' THEN cm.category_id END) AS active_categories,
SUM(cm.active_product_count) AS total_active_products, -- Sum from aggregates
SUM(cm.current_stock_cost) AS total_stock_value, -- Sum from aggregates
-- Weighted Average Margin (Revenue as weight)
SUM(cm.profit_30d) * 100.0 / NULLIF(SUM(cm.revenue_30d), 0) AS overall_avg_margin_weighted,
-- Simple Average Margin (less accurate if categories vary greatly in size)
AVG(NULLIF(cm.avg_margin_30d, 0)) AS overall_avg_margin_simple
-- Growth rate can be calculated from 30d vs previous 30d revenue if needed
FROM public.category_metrics cm
JOIN public.categories c ON cm.category_id = c.cat_id -- Join to check category status
`);
res.json({
totalCategories: parseInt(stats?.total_categories || 0),
activeCategories: parseInt(stats?.active_categories || 0), // Based on categories.status
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
// Choose which avg margin calculation to expose
avgMargin: parseFloat(stats?.overall_avg_margin_weighted || stats?.overall_avg_margin_simple || 0)
// Growth rate could be added if we implement the calculation
});
} catch (error) {
console.error('Error fetching category stats:', error);
res.status(500).json({ error: 'Failed to fetch category stats.' });
}
});
// GET /categories-aggregate/ (List categories)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'categoryName';
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
// Hierarchical sorting logic from categories.js
const hierarchicalSortOrder = `
ORDER BY
CASE
WHEN cm.category_type = 10 THEN 1 -- sections first
WHEN cm.category_type = 11 THEN 2 -- categories second
WHEN cm.category_type = 12 THEN 3 -- subcategories third
WHEN cm.category_type = 13 THEN 4 -- subsubcategories fourth
WHEN cm.category_type = 20 THEN 5 -- themes fifth
WHEN cm.category_type = 21 THEN 6 -- subthemes last
ELSE 7
END,
cm.category_name ASC
`;
// Use hierarchical sort as default
let sortClause = hierarchicalSortOrder;
// Override with custom sort if specified
if (sortColumnInfo && sortQueryKey !== 'categoryName') {
const sortColumn = sortColumnInfo.dbCol;
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
}
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
console.log("Starting to process filters from query:", req.query);
// Add filters based on req.query using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
console.log(`Processing filter key: "${key}" with value: "${value}"`);
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
console.log(`Parsed filter key: "${filterKey}" with operator: "${operator}"`);
}
// Special case for parentName requires join
const requiresJoin = filterKey === 'parentName';
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
console.log(`Column info for "${filterKey}":`, columnInfo);
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) {
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
try {
// Special handling for categoryType to ensure it works
if (filterKey === 'categoryType') {
console.log(`Special handling for categoryType: ${value}`);
// Force conversion to integer
const numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
console.log(`Successfully converted categoryType to integer: ${numericValue}`);
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(numericValue);
} else {
console.error(`Failed to convert categoryType to integer: "${value}"`);
throw new Error(`Invalid categoryType value: "${value}"`);
}
} else {
// Normal handling for other fields
const parsedValue = parseValue(value, valueType);
console.log(`Parsed "${value}" as ${valueType}: ${parsedValue}`);
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parsedValue);
}
} catch (innerError) {
console.error(`Failed to parse "${value}" as ${valueType}:`, innerError);
throw innerError;
}
} else if (!conditionFragment) { // For LIKE/ILIKE where needsParam is false
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; // paramCounter was already incremented in push
}
if (conditionFragment) {
console.log(`Adding condition: ${conditionFragment}`);
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.error(`Skipping filter for key "${key}" due to parsing error:`, parseError);
if (needsParam) paramCounter--; // Roll back counter if param push failed
}
} else {
console.warn(`Invalid filter key ignored: "${key}", not found in COLUMN_MAP`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Need JOIN for parent_name if sorting/filtering by it, or always include for display
const sortColumn = sortColumnInfo?.dbCol;
// Always include the category and parent joins for status and parent_name
const joinSql = `
JOIN public.categories c ON cm.category_id = c.cat_id
LEFT JOIN public.categories p ON cm.parent_id = p.cat_id
`;
const baseSql = `
FROM public.category_metrics cm
${joinSql}
${whereClause}
`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `
SELECT
cm.*,
c.status,
c.description,
p.name as parent_name,
p.type as parent_type
${baseSql}
${sortClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const categories = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({
categories,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching category metrics list:', error);
res.status(500).json({ error: 'Failed to fetch category metrics.' });
}
});
module.exports = router;
+263 -110
View File
@@ -7,164 +7,317 @@ router.use((req, res, next) => {
next();
});
// Get all configuration values
router.get('/', async (req, res) => {
// ===== GLOBAL SETTINGS =====
// Get all global settings
router.get('/global', async (req, res) => {
const pool = req.app.locals.pool;
try {
console.log('[Config Route] Fetching configuration values...');
console.log('[Config Route] Fetching global settings...');
const { rows: stockThresholds } = await pool.query('SELECT * FROM stock_thresholds WHERE id = 1');
console.log('[Config Route] Stock thresholds:', stockThresholds);
const { rows } = await pool.query('SELECT * FROM settings_global ORDER BY setting_key');
const { rows: leadTimeThresholds } = await pool.query('SELECT * FROM lead_time_thresholds WHERE id = 1');
console.log('[Config Route] Lead time thresholds:', leadTimeThresholds);
const { rows: salesVelocityConfig } = await pool.query('SELECT * FROM sales_velocity_config WHERE id = 1');
console.log('[Config Route] Sales velocity config:', salesVelocityConfig);
const { rows: abcConfig } = await pool.query('SELECT * FROM abc_classification_config WHERE id = 1');
console.log('[Config Route] ABC config:', abcConfig);
const { rows: safetyStockConfig } = await pool.query('SELECT * FROM safety_stock_config WHERE id = 1');
console.log('[Config Route] Safety stock config:', safetyStockConfig);
const { rows: turnoverConfig } = await pool.query('SELECT * FROM turnover_config WHERE id = 1');
console.log('[Config Route] Turnover config:', turnoverConfig);
console.log('[Config Route] Sending global settings:', rows);
res.json(rows);
} catch (error) {
console.error('[Config Route] Error fetching global settings:', error);
res.status(500).json({ error: 'Failed to fetch global settings', details: error.message });
}
});
// Update global settings
router.put('/global', async (req, res) => {
const pool = req.app.locals.pool;
try {
console.log('[Config Route] Updating global settings:', req.body);
// Validate request
if (!Array.isArray(req.body)) {
return res.status(400).json({ error: 'Request body must be an array of settings' });
}
// Begin transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const setting of req.body) {
if (!setting.setting_key || !setting.setting_value) {
throw new Error('Each setting must have a key and value');
}
await client.query(
`UPDATE settings_global
SET setting_value = $1,
updated_at = CURRENT_TIMESTAMP
WHERE setting_key = $2`,
[setting.setting_value, setting.setting_key]
);
}
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
console.error('[Config Route] Error updating global settings:', error);
res.status(500).json({ error: 'Failed to update global settings', details: error.message });
}
});
// ===== PRODUCT SETTINGS =====
// Get product settings with pagination and search
router.get('/products', async (req, res) => {
const pool = req.app.locals.pool;
try {
console.log('[Config Route] Fetching product settings...');
const page = parseInt(req.query.page) || 1;
const pageSize = parseInt(req.query.pageSize) || 10;
const offset = (page - 1) * pageSize;
const search = req.query.search || '';
// Get total count for pagination
const countQuery = search
? `SELECT COUNT(*) FROM settings_product sp
JOIN products p ON sp.pid::text = p.pid::text
WHERE sp.pid::text ILIKE $1 OR p.title ILIKE $1`
: 'SELECT COUNT(*) FROM settings_product';
const countParams = search ? [`%${search}%`] : [];
const { rows: countResult } = await pool.query(countQuery, countParams);
const total = parseInt(countResult[0].count);
// Get paginated settings
const query = search
? `SELECT sp.*, p.title as product_name
FROM settings_product sp
JOIN products p ON sp.pid::text = p.pid::text
WHERE sp.pid::text ILIKE $1 OR p.title ILIKE $1
ORDER BY sp.pid
LIMIT $2 OFFSET $3`
: `SELECT sp.*, p.title as product_name
FROM settings_product sp
JOIN products p ON sp.pid::text = p.pid::text
ORDER BY sp.pid
LIMIT $1 OFFSET $2`;
const queryParams = search
? [`%${search}%`, pageSize, offset]
: [pageSize, offset];
const { rows } = await pool.query(query, queryParams);
const response = {
stockThresholds: stockThresholds[0],
leadTimeThresholds: leadTimeThresholds[0],
salesVelocityConfig: salesVelocityConfig[0],
abcConfig: abcConfig[0],
safetyStockConfig: safetyStockConfig[0],
turnoverConfig: turnoverConfig[0]
items: rows,
total,
page,
pageSize
};
console.log('[Config Route] Sending response:', response);
console.log(`[Config Route] Sending ${rows.length} product settings`);
res.json(response);
} catch (error) {
console.error('[Config Route] Error fetching configuration:', error);
res.status(500).json({ error: 'Failed to fetch configuration', details: error.message });
console.error('[Config Route] Error fetching product settings:', error);
res.status(500).json({ error: 'Failed to fetch product settings', details: error.message });
}
});
// Update stock thresholds
router.put('/stock-thresholds/:id', async (req, res) => {
// Update product settings
router.put('/products/:pid', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { critical_days, reorder_days, overstock_days, low_stock_threshold, min_reorder_quantity } = req.body;
const { rows } = await pool.query(
`UPDATE stock_thresholds
SET critical_days = $1,
reorder_days = $2,
overstock_days = $3,
low_stock_threshold = $4,
min_reorder_quantity = $5
WHERE id = $6`,
[critical_days, reorder_days, overstock_days, low_stock_threshold, min_reorder_quantity, req.params.id]
const { pid } = req.params;
const { lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast } = req.body;
console.log(`[Config Route] Updating product settings for ${pid}:`, req.body);
// Check if product exists
const { rows: checkProduct } = await pool.query(
'SELECT 1 FROM settings_product WHERE pid::text = $1',
[pid]
);
if (checkProduct.length === 0) {
// Insert if it doesn't exist
await pool.query(
`INSERT INTO settings_product
(pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast)
VALUES ($1, $2, $3, $4, $5, $6)`,
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast]
);
} else {
// Update if it exists
await pool.query(
`UPDATE settings_product
SET lead_time_days = $2,
days_of_stock = $3,
safety_stock = $4,
forecast_method = $5,
exclude_from_forecast = $6,
updated_at = CURRENT_TIMESTAMP
WHERE pid::text = $1`,
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast]
);
}
res.json({ success: true });
} catch (error) {
console.error('[Config Route] Error updating stock thresholds:', error);
res.status(500).json({ error: 'Failed to update stock thresholds' });
console.error(`[Config Route] Error updating product settings for ${req.params.pid}:`, error);
res.status(500).json({ error: 'Failed to update product settings', details: error.message });
}
});
// Update lead time thresholds
router.put('/lead-time-thresholds/:id', async (req, res) => {
// Reset product settings to defaults
router.post('/products/:pid/reset', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { target_days, warning_days, critical_days } = req.body;
const { rows } = await pool.query(
`UPDATE lead_time_thresholds
SET target_days = $1,
warning_days = $2,
critical_days = $3
WHERE id = $4`,
[target_days, warning_days, critical_days, req.params.id]
const { pid } = req.params;
console.log(`[Config Route] Resetting product settings for ${pid}`);
// Reset by setting everything to null/default
await pool.query(
`UPDATE settings_product
SET lead_time_days = NULL,
days_of_stock = NULL,
safety_stock = 0,
forecast_method = NULL,
exclude_from_forecast = false,
updated_at = CURRENT_TIMESTAMP
WHERE pid::text = $1`,
[pid]
);
res.json({ success: true });
} catch (error) {
console.error('[Config Route] Error updating lead time thresholds:', error);
res.status(500).json({ error: 'Failed to update lead time thresholds' });
console.error(`[Config Route] Error resetting product settings for ${req.params.pid}:`, error);
res.status(500).json({ error: 'Failed to reset product settings', details: error.message });
}
});
// Update sales velocity config
router.put('/sales-velocity/:id', async (req, res) => {
// ===== VENDOR SETTINGS =====
// Get vendor settings with pagination and search
router.get('/vendors', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { daily_window_days, weekly_window_days, monthly_window_days } = req.body;
const { rows } = await pool.query(
`UPDATE sales_velocity_config
SET daily_window_days = $1,
weekly_window_days = $2,
monthly_window_days = $3
WHERE id = $4`,
[daily_window_days, weekly_window_days, monthly_window_days, req.params.id]
);
res.json({ success: true });
console.log('[Config Route] Fetching vendor settings...');
const page = parseInt(req.query.page) || 1;
const pageSize = parseInt(req.query.pageSize) || 10;
const offset = (page - 1) * pageSize;
const search = req.query.search || '';
// Get total count for pagination
const countQuery = search
? 'SELECT COUNT(*) FROM settings_vendor WHERE vendor ILIKE $1'
: 'SELECT COUNT(*) FROM settings_vendor';
const countParams = search ? [`%${search}%`] : [];
const { rows: countResult } = await pool.query(countQuery, countParams);
const total = parseInt(countResult[0].count);
// Get paginated settings
const query = search
? `SELECT * FROM settings_vendor
WHERE vendor ILIKE $1
ORDER BY vendor
LIMIT $2 OFFSET $3`
: `SELECT * FROM settings_vendor
ORDER BY vendor
LIMIT $1 OFFSET $2`;
const queryParams = search
? [`%${search}%`, pageSize, offset]
: [pageSize, offset];
const { rows } = await pool.query(query, queryParams);
const response = {
items: rows,
total,
page,
pageSize
};
console.log(`[Config Route] Sending ${rows.length} vendor settings`);
res.json(response);
} catch (error) {
console.error('[Config Route] Error updating sales velocity config:', error);
res.status(500).json({ error: 'Failed to update sales velocity config' });
console.error('[Config Route] Error fetching vendor settings:', error);
res.status(500).json({ error: 'Failed to fetch vendor settings', details: error.message });
}
});
// Update ABC classification config
router.put('/abc-classification/:id', async (req, res) => {
// Update vendor settings
router.put('/vendors/:vendor', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { a_threshold, b_threshold, classification_period_days } = req.body;
const { rows } = await pool.query(
`UPDATE abc_classification_config
SET a_threshold = $1,
b_threshold = $2,
classification_period_days = $3
WHERE id = $4`,
[a_threshold, b_threshold, classification_period_days, req.params.id]
const vendor = req.params.vendor;
const { default_lead_time_days, default_days_of_stock } = req.body;
console.log(`[Config Route] Updating vendor settings for ${vendor}:`, req.body);
// Check if vendor exists
const { rows: checkVendor } = await pool.query(
'SELECT 1 FROM settings_vendor WHERE vendor = $1',
[vendor]
);
if (checkVendor.length === 0) {
// Insert if it doesn't exist
await pool.query(
`INSERT INTO settings_vendor
(vendor, default_lead_time_days, default_days_of_stock)
VALUES ($1, $2, $3)`,
[vendor, default_lead_time_days, default_days_of_stock]
);
} else {
// Update if it exists
await pool.query(
`UPDATE settings_vendor
SET default_lead_time_days = $2,
default_days_of_stock = $3,
updated_at = CURRENT_TIMESTAMP
WHERE vendor = $1`,
[vendor, default_lead_time_days, default_days_of_stock]
);
}
res.json({ success: true });
} catch (error) {
console.error('[Config Route] Error updating ABC classification config:', error);
res.status(500).json({ error: 'Failed to update ABC classification config' });
console.error(`[Config Route] Error updating vendor settings for ${req.params.vendor}:`, error);
res.status(500).json({ error: 'Failed to update vendor settings', details: error.message });
}
});
// Update safety stock config
router.put('/safety-stock/:id', async (req, res) => {
// Reset vendor settings to defaults
router.post('/vendors/:vendor/reset', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { coverage_days, service_level } = req.body;
const { rows } = await pool.query(
`UPDATE safety_stock_config
SET coverage_days = $1,
service_level = $2
WHERE id = $3`,
[coverage_days, service_level, req.params.id]
const vendor = req.params.vendor;
console.log(`[Config Route] Resetting vendor settings for ${vendor}`);
// Reset by setting everything to null
await pool.query(
`UPDATE settings_vendor
SET default_lead_time_days = NULL,
default_days_of_stock = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE vendor = $1`,
[vendor]
);
res.json({ success: true });
} catch (error) {
console.error('[Config Route] Error updating safety stock config:', error);
res.status(500).json({ error: 'Failed to update safety stock config' });
}
});
// Update turnover config
router.put('/turnover/:id', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { calculation_period_days, target_rate } = req.body;
const { rows } = await pool.query(
`UPDATE turnover_config
SET calculation_period_days = $1,
target_rate = $2
WHERE id = $3`,
[calculation_period_days, target_rate, req.params.id]
);
res.json({ success: true });
} catch (error) {
console.error('[Config Route] Error updating turnover config:', error);
res.status(500).json({ error: 'Failed to update turnover config' });
console.error(`[Config Route] Error resetting vendor settings for ${req.params.vendor}:`, error);
res.status(500).json({ error: 'Failed to reset vendor settings', details: error.message });
}
});
-881
View File
@@ -1,881 +0,0 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const path = require('path');
const db = require('../utils/db');
// Debug middleware MUST be first
router.use((req, res, next) => {
console.log(`[CSV Route Debug] ${req.method} ${req.path}`);
next();
});
// Store active processes and their progress
let activeImport = null;
let importProgress = null;
let activeFullUpdate = null;
let activeFullReset = null;
// SSE clients for progress updates
const updateClients = new Set();
const importClients = new Set();
const resetClients = new Set();
const resetMetricsClients = new Set();
const calculateMetricsClients = new Set();
const fullUpdateClients = new Set();
const fullResetClients = new Set();
// Helper to send progress to specific clients
function sendProgressToClients(clients, data) {
// If data is a string, send it directly
// If it's an object, convert it to JSON
const message = typeof data === 'string'
? `data: ${data}\n\n`
: `data: ${JSON.stringify(data)}\n\n`;
clients.forEach(client => {
try {
client.write(message);
// Immediately flush the response
if (typeof client.flush === 'function') {
client.flush();
}
} catch (error) {
// Silently remove failed client
clients.delete(client);
}
});
}
// Helper to run a script and stream progress
function runScript(scriptPath, type, clients) {
return new Promise((resolve, reject) => {
// Kill any existing process of this type
let activeProcess;
switch (type) {
case 'update':
if (activeFullUpdate) {
try { activeFullUpdate.kill(); } catch (e) { }
}
activeProcess = activeFullUpdate;
break;
case 'reset':
if (activeFullReset) {
try { activeFullReset.kill(); } catch (e) { }
}
activeProcess = activeFullReset;
break;
}
const child = spawn('node', [scriptPath], {
stdio: ['inherit', 'pipe', 'pipe']
});
switch (type) {
case 'update':
activeFullUpdate = child;
break;
case 'reset':
activeFullReset = child;
break;
}
let output = '';
child.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Split by lines to handle multiple JSON outputs
const lines = text.split('\n');
lines.filter(line => line.trim()).forEach(line => {
try {
// Try to parse as JSON but don't let it affect the display
const jsonData = JSON.parse(line);
// Only end the process if we get a final status
if (jsonData.status === 'complete' || jsonData.status === 'error' || jsonData.status === 'cancelled') {
if (jsonData.status === 'complete' && !jsonData.operation?.includes('complete')) {
// Don't close for intermediate completion messages
sendProgressToClients(clients, line);
return;
}
// Close only on final completion/error/cancellation
switch (type) {
case 'update':
activeFullUpdate = null;
break;
case 'reset':
activeFullReset = null;
break;
}
if (jsonData.status === 'error') {
reject(new Error(jsonData.error || 'Unknown error'));
} else {
resolve({ output });
}
}
} catch (e) {
// Not JSON, just display as is
}
// Always send the raw line
sendProgressToClients(clients, line);
});
});
child.stderr.on('data', (data) => {
const text = data.toString();
console.error(text);
// Send stderr output directly too
sendProgressToClients(clients, text);
});
child.on('close', (code) => {
switch (type) {
case 'update':
activeFullUpdate = null;
break;
case 'reset':
activeFullReset = null;
break;
}
if (code !== 0) {
const error = `Script ${scriptPath} exited with code ${code}`;
sendProgressToClients(clients, error);
reject(new Error(error));
}
// Don't resolve here - let the completion message from the script trigger the resolve
});
child.on('error', (err) => {
switch (type) {
case 'update':
activeFullUpdate = null;
break;
case 'reset':
activeFullReset = null;
break;
}
sendProgressToClients(clients, err.message);
reject(err);
});
});
}
// Progress endpoints
router.get('/:type/progress', (req, res) => {
const { type } = req.params;
if (!['update', 'reset'].includes(type)) {
return res.status(400).json({ error: 'Invalid operation type' });
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': req.headers.origin || '*',
'Access-Control-Allow-Credentials': 'true'
});
// Add this client to the correct set
const clients = type === 'update' ? fullUpdateClients : fullResetClients;
clients.add(res);
// Send initial connection message
sendProgressToClients(new Set([res]), JSON.stringify({
status: 'running',
operation: 'Initializing connection...'
}));
// Handle client disconnect
req.on('close', () => {
clients.delete(res);
});
});
// Debug endpoint to verify route registration
router.get('/test', (req, res) => {
console.log('CSV test endpoint hit');
res.json({ message: 'CSV routes are working' });
});
// Route to check import status
router.get('/status', (req, res) => {
console.log('CSV status endpoint hit');
res.json({
active: !!activeImport,
progress: importProgress
});
});
// Add calculate-metrics status endpoint
router.get('/calculate-metrics/status', (req, res) => {
const calculateMetrics = require('../../scripts/calculate-metrics');
const progress = calculateMetrics.getProgress();
// Only consider it active if both the process is running and we have progress
const isActive = !!activeImport && !!progress;
res.json({
active: isActive,
progress: isActive ? progress : null
});
});
// Route to update CSV files
router.post('/update', async (req, res, next) => {
if (activeImport) {
return res.status(409).json({ error: 'Import already in progress' });
}
try {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'update-csv.js');
if (!require('fs').existsSync(scriptPath)) {
return res.status(500).json({ error: 'Update script not found' });
}
activeImport = spawn('node', [scriptPath]);
activeImport.stdout.on('data', (data) => {
const output = data.toString().trim();
try {
// Try to parse as JSON
const jsonData = JSON.parse(output);
sendProgressToClients(updateClients, {
status: 'running',
...jsonData
});
} catch (e) {
// If not JSON, send as plain progress
sendProgressToClients(updateClients, {
status: 'running',
progress: output
});
}
});
activeImport.stderr.on('data', (data) => {
const error = data.toString().trim();
try {
// Try to parse as JSON
const jsonData = JSON.parse(error);
sendProgressToClients(updateClients, {
status: 'error',
...jsonData
});
} catch {
sendProgressToClients(updateClients, {
status: 'error',
error
});
}
});
await new Promise((resolve, reject) => {
activeImport.on('close', (code) => {
// Don't treat cancellation (code 143/SIGTERM) as an error
if (code === 0 || code === 143) {
sendProgressToClients(updateClients, {
status: 'complete',
operation: code === 143 ? 'Operation cancelled' : 'Update complete'
});
resolve();
} else {
const errorMsg = `Update process exited with code ${code}`;
sendProgressToClients(updateClients, {
status: 'error',
error: errorMsg
});
reject(new Error(errorMsg));
}
activeImport = null;
importProgress = null;
});
});
res.json({ success: true });
} catch (error) {
console.error('Error updating CSV files:', error);
activeImport = null;
importProgress = null;
sendProgressToClients(updateClients, {
status: 'error',
error: error.message
});
next(error);
}
});
// Route to import CSV files
router.post('/import', async (req, res) => {
if (activeImport) {
return res.status(409).json({ error: 'Import already in progress' });
}
try {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'import-csv.js');
if (!require('fs').existsSync(scriptPath)) {
return res.status(500).json({ error: 'Import script not found' });
}
// Get test limits from request body
const { products = 0, orders = 10000, purchaseOrders = 10000 } = req.body;
// Create environment variables for the script
const env = {
...process.env,
PRODUCTS_TEST_LIMIT: products.toString(),
ORDERS_TEST_LIMIT: orders.toString(),
PURCHASE_ORDERS_TEST_LIMIT: purchaseOrders.toString()
};
activeImport = spawn('node', [scriptPath], { env });
activeImport.stdout.on('data', (data) => {
const output = data.toString().trim();
try {
// Try to parse as JSON
const jsonData = JSON.parse(output);
sendProgressToClients(importClients, {
status: 'running',
...jsonData
});
} catch {
// If not JSON, send as plain progress
sendProgressToClients(importClients, {
status: 'running',
progress: output
});
}
});
activeImport.stderr.on('data', (data) => {
const error = data.toString().trim();
try {
// Try to parse as JSON
const jsonData = JSON.parse(error);
sendProgressToClients(importClients, {
status: 'error',
...jsonData
});
} catch {
sendProgressToClients(importClients, {
status: 'error',
error
});
}
});
await new Promise((resolve, reject) => {
activeImport.on('close', (code) => {
// Don't treat cancellation (code 143/SIGTERM) as an error
if (code === 0 || code === 143) {
sendProgressToClients(importClients, {
status: 'complete',
operation: code === 143 ? 'Operation cancelled' : 'Import complete'
});
resolve();
} else {
sendProgressToClients(importClients, {
status: 'error',
error: `Process exited with code ${code}`
});
reject(new Error(`Import process exited with code ${code}`));
}
activeImport = null;
importProgress = null;
});
});
res.json({ success: true });
} catch (error) {
console.error('Error importing CSV files:', error);
activeImport = null;
importProgress = null;
sendProgressToClients(importClients, {
status: 'error',
error: error.message
});
res.status(500).json({ error: 'Failed to import CSV files', details: error.message });
}
});
// Route to cancel active process
router.post('/cancel', (req, res) => {
let killed = false;
// Get the operation type from the request
const { type } = req.query;
const clients = type === 'update' ? fullUpdateClients : fullResetClients;
const activeProcess = type === 'update' ? activeFullUpdate : activeFullReset;
if (activeProcess) {
try {
activeProcess.kill('SIGTERM');
if (type === 'update') {
activeFullUpdate = null;
} else {
activeFullReset = null;
}
killed = true;
sendProgressToClients(clients, JSON.stringify({
status: 'cancelled',
operation: 'Operation cancelled'
}));
} catch (err) {
console.error(`Error killing ${type} process:`, err);
}
}
if (killed) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'No active process to cancel' });
}
});
// Route to reset database
router.post('/reset', async (req, res) => {
if (activeImport) {
return res.status(409).json({ error: 'Import already in progress' });
}
try {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'reset-db.js');
if (!require('fs').existsSync(scriptPath)) {
return res.status(500).json({ error: 'Reset script not found' });
}
activeImport = spawn('node', [scriptPath]);
activeImport.stdout.on('data', (data) => {
const output = data.toString().trim();
try {
// Try to parse as JSON
const jsonData = JSON.parse(output);
sendProgressToClients(resetClients, {
status: 'running',
...jsonData
});
} catch (e) {
// If not JSON, send as plain progress
sendProgressToClients(resetClients, {
status: 'running',
progress: output
});
}
});
activeImport.stderr.on('data', (data) => {
const error = data.toString().trim();
try {
// Try to parse as JSON
const jsonData = JSON.parse(error);
sendProgressToClients(resetClients, {
status: 'error',
...jsonData
});
} catch {
sendProgressToClients(resetClients, {
status: 'error',
error
});
}
});
await new Promise((resolve, reject) => {
activeImport.on('close', (code) => {
// Don't treat cancellation (code 143/SIGTERM) as an error
if (code === 0 || code === 143) {
sendProgressToClients(resetClients, {
status: 'complete',
operation: code === 143 ? 'Operation cancelled' : 'Reset complete'
});
resolve();
} else {
const errorMsg = `Reset process exited with code ${code}`;
sendProgressToClients(resetClients, {
status: 'error',
error: errorMsg
});
reject(new Error(errorMsg));
}
activeImport = null;
importProgress = null;
});
});
res.json({ success: true });
} catch (error) {
console.error('Error resetting database:', error);
activeImport = null;
importProgress = null;
sendProgressToClients(resetClients, {
status: 'error',
error: error.message
});
res.status(500).json({ error: 'Failed to reset database', details: error.message });
}
});
// Add reset-metrics endpoint
router.post('/reset-metrics', async (req, res) => {
if (activeImport) {
res.status(400).json({ error: 'Operation already in progress' });
return;
}
try {
// Set active import to prevent concurrent operations
activeImport = {
type: 'reset-metrics',
status: 'running',
operation: 'Starting metrics reset'
};
// Send initial response
res.status(200).json({ message: 'Reset metrics started' });
// Send initial progress through SSE
sendProgressToClients(resetMetricsClients, {
status: 'running',
operation: 'Starting metrics reset'
});
// Run the reset metrics script
const resetMetrics = require('../../scripts/reset-metrics');
await resetMetrics();
// Send completion through SSE
sendProgressToClients(resetMetricsClients, {
status: 'complete',
operation: 'Metrics reset completed'
});
activeImport = null;
} catch (error) {
console.error('Error during metrics reset:', error);
// Send error through SSE
sendProgressToClients(resetMetricsClients, {
status: 'error',
error: error.message || 'Failed to reset metrics'
});
activeImport = null;
res.status(500).json({ error: error.message || 'Failed to reset metrics' });
}
});
// Add calculate-metrics endpoint
router.post('/calculate-metrics', async (req, res) => {
if (activeImport) {
return res.status(409).json({ error: 'Import already in progress' });
}
try {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'calculate-metrics.js');
if (!require('fs').existsSync(scriptPath)) {
return res.status(500).json({ error: 'Calculate metrics script not found' });
}
activeImport = spawn('node', [scriptPath]);
let wasCancelled = false;
activeImport.stdout.on('data', (data) => {
const output = data.toString().trim();
try {
// Try to parse as JSON
const jsonData = JSON.parse(output);
importProgress = {
status: 'running',
...jsonData.progress
};
sendProgressToClients(calculateMetricsClients, importProgress);
} catch (e) {
// If not JSON, send as plain progress
importProgress = {
status: 'running',
progress: output
};
sendProgressToClients(calculateMetricsClients, importProgress);
}
});
activeImport.stderr.on('data', (data) => {
if (wasCancelled) return; // Don't send errors if cancelled
const error = data.toString().trim();
try {
// Try to parse as JSON
const jsonData = JSON.parse(error);
importProgress = {
status: 'error',
...jsonData.progress
};
sendProgressToClients(calculateMetricsClients, importProgress);
} catch {
importProgress = {
status: 'error',
error
};
sendProgressToClients(calculateMetricsClients, importProgress);
}
});
await new Promise((resolve, reject) => {
activeImport.on('close', (code, signal) => {
wasCancelled = signal === 'SIGTERM' || code === 143;
activeImport = null;
if (code === 0 || wasCancelled) {
if (wasCancelled) {
importProgress = {
status: 'cancelled',
operation: 'Operation cancelled'
};
sendProgressToClients(calculateMetricsClients, importProgress);
} else {
importProgress = {
status: 'complete',
operation: 'Metrics calculation complete'
};
sendProgressToClients(calculateMetricsClients, importProgress);
}
resolve();
} else {
importProgress = null;
reject(new Error(`Metrics calculation process exited with code ${code}`));
}
});
});
res.json({ success: true });
} catch (error) {
console.error('Error calculating metrics:', error);
activeImport = null;
importProgress = null;
// Only send error if it wasn't a cancellation
if (!error.message?.includes('code 143') && !error.message?.includes('SIGTERM')) {
sendProgressToClients(calculateMetricsClients, {
status: 'error',
error: error.message
});
res.status(500).json({ error: 'Failed to calculate metrics', details: error.message });
} else {
res.json({ success: true });
}
}
});
// Route to import from production database
router.post('/import-from-prod', async (req, res) => {
if (activeImport) {
return res.status(409).json({ error: 'Import already in progress' });
}
try {
const importFromProd = require('../../scripts/import-from-prod');
// Set up progress handler
const progressHandler = (data) => {
importProgress = data;
sendProgressToClients(importClients, data);
};
// Start the import process
importFromProd.outputProgress = progressHandler;
activeImport = importFromProd; // Store the module for cancellation
// Run the import in the background
importFromProd.main().catch(error => {
console.error('Error in import process:', error);
activeImport = null;
importProgress = {
status: error.message === 'Import cancelled' ? 'cancelled' : 'error',
operation: 'Import process',
error: error.message
};
sendProgressToClients(importClients, importProgress);
}).finally(() => {
activeImport = null;
});
res.json({ message: 'Import from production started' });
} catch (error) {
console.error('Error starting production import:', error);
activeImport = null;
res.status(500).json({ error: error.message || 'Failed to start production import' });
}
});
// POST /csv/full-update - Run full update script
router.post('/full-update', async (req, res) => {
try {
const scriptPath = path.join(__dirname, '../../scripts/full-update.js');
runScript(scriptPath, 'update', fullUpdateClients)
.catch(error => {
console.error('Update failed:', error);
});
res.status(202).json({ message: 'Update started' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /csv/full-reset - Run full reset script
router.post('/full-reset', async (req, res) => {
try {
const scriptPath = path.join(__dirname, '../../scripts/full-reset.js');
runScript(scriptPath, 'reset', fullResetClients)
.catch(error => {
console.error('Reset failed:', error);
});
res.status(202).json({ message: 'Reset started' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /history/import - Get recent import history
router.get('/history/import', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
id,
start_time,
end_time,
status,
error_message,
records_added::integer,
records_updated::integer
FROM import_history
ORDER BY start_time DESC
LIMIT 20
`);
res.json(rows || []);
} catch (error) {
console.error('Error fetching import history:', error);
res.status(500).json({ error: error.message });
}
});
// GET /history/calculate - Get recent calculation history
router.get('/history/calculate', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
id,
start_time,
end_time,
duration_minutes,
status,
error_message,
total_products,
total_orders,
total_purchase_orders,
processed_products,
processed_orders,
processed_purchase_orders,
additional_info
FROM calculate_history
ORDER BY start_time DESC
LIMIT 20
`);
res.json(rows || []);
} catch (error) {
console.error('Error fetching calculate history:', error);
res.status(500).json({ error: error.message });
}
});
// GET /status/modules - Get module calculation status
router.get('/status/modules', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
module_name,
last_calculation_timestamp::timestamp
FROM calculate_status
ORDER BY module_name
`);
res.json(rows || []);
} catch (error) {
console.error('Error fetching module status:', error);
res.status(500).json({ error: error.message });
}
});
// GET /status/tables - Get table sync status
router.get('/status/tables', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
table_name,
last_sync_timestamp::timestamp
FROM sync_status
ORDER BY table_name
`);
res.json(rows || []);
} catch (error) {
console.error('Error fetching table status:', error);
res.status(500).json({ error: error.message });
}
});
// GET /status/table-counts - Get record counts for all tables
router.get('/status/table-counts', async (req, res) => {
try {
const pool = req.app.locals.pool;
const tables = [
// Core tables
'products', 'categories', 'product_categories', 'orders', 'purchase_orders',
// New metrics tables
'product_metrics', 'daily_product_snapshots',
// Config tables
'settings_global', 'settings_vendor', 'settings_product'
];
const counts = await Promise.all(
tables.map(table =>
pool.query(`SELECT COUNT(*) as count FROM ${table}`)
.then(result => ({
table_name: table,
count: parseInt(result.rows[0].count)
}))
.catch(err => ({
table_name: table,
count: null,
error: err.message
}))
)
);
// Group tables by type
const groupedCounts = {
core: counts.filter(c => ['products', 'categories', 'product_categories', 'orders', 'purchase_orders'].includes(c.table_name)),
metrics: counts.filter(c => ['product_metrics', 'daily_product_snapshots'].includes(c.table_name)),
config: counts.filter(c => ['settings_global', 'settings_vendor', 'settings_product'].includes(c.table_name))
};
res.json(groupedCounts);
} catch (error) {
console.error('Error fetching table counts:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+196 -259
View File
@@ -22,11 +22,11 @@ router.get('/stock/metrics', async (req, res) => {
const { rows: [stockMetrics] } = await executeQuery(`
SELECT
COALESCE(COUNT(*), 0)::integer as total_products,
COALESCE(COUNT(CASE WHEN stock_quantity > 0 THEN 1 END), 0)::integer as products_in_stock,
COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity END), 0)::integer as total_units,
ROUND(COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * cost_price END), 0)::numeric, 3) as total_cost,
ROUND(COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * price END), 0)::numeric, 3) as total_retail
FROM products
COALESCE(COUNT(CASE WHEN current_stock > 0 THEN 1 END), 0)::integer as products_in_stock,
COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock END), 0)::integer as total_units,
ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_cost END), 0)::numeric, 3) as total_cost,
ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_retail END), 0)::numeric, 3) as total_retail
FROM product_metrics
`);
console.log('Raw stockMetrics from database:', stockMetrics);
@@ -42,13 +42,13 @@ router.get('/stock/metrics', async (req, res) => {
SELECT
COALESCE(brand, 'Unbranded') as brand,
COUNT(DISTINCT pid)::integer as variant_count,
COALESCE(SUM(stock_quantity), 0)::integer as stock_units,
ROUND(COALESCE(SUM(stock_quantity * cost_price), 0)::numeric, 3) as stock_cost,
ROUND(COALESCE(SUM(stock_quantity * price), 0)::numeric, 3) as stock_retail
FROM products
WHERE stock_quantity > 0
COALESCE(SUM(current_stock), 0)::integer as stock_units,
ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) as stock_cost,
ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 3) as stock_retail
FROM product_metrics
WHERE current_stock > 0
GROUP BY COALESCE(brand, 'Unbranded')
HAVING ROUND(COALESCE(SUM(stock_quantity * cost_price), 0)::numeric, 3) > 0
HAVING ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) > 0
),
other_brands AS (
SELECT
@@ -108,47 +108,52 @@ router.get('/purchase/metrics', async (req, res) => {
`);
const { rows: [poMetrics] } = await executeQuery(`
WITH po_metrics AS (
SELECT
po_id,
status,
date,
expected_date,
pid,
ordered,
po_cost_price
FROM purchase_orders po
WHERE po.status NOT IN ('canceled', 'done')
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
)
SELECT
COALESCE(COUNT(DISTINCT CASE
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
THEN po.po_id
END), 0)::integer as active_pos,
COALESCE(COUNT(DISTINCT CASE
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
AND po.expected_date < CURRENT_DATE
THEN po.po_id
END), 0)::integer as overdue_pos,
COALESCE(SUM(CASE
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
THEN po.ordered
ELSE 0
END), 0)::integer as total_units,
ROUND(COALESCE(SUM(CASE
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
THEN po.ordered * po.cost_price
ELSE 0
END), 0)::numeric, 3) as total_cost,
ROUND(COALESCE(SUM(CASE
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
THEN po.ordered * p.price
ELSE 0
END), 0)::numeric, 3) as total_retail
FROM purchase_orders po
JOIN products p ON po.pid = p.pid
COUNT(DISTINCT po_id)::integer as active_pos,
COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer as overdue_pos,
SUM(ordered)::integer as total_units,
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost,
ROUND(SUM(ordered * pm.current_price)::numeric, 3) as total_retail
FROM po_metrics po
JOIN product_metrics pm ON po.pid = pm.pid
`);
const { rows: vendorOrders } = await executeQuery(`
WITH po_by_vendor AS (
SELECT
vendor,
po_id,
SUM(ordered) as total_ordered,
SUM(ordered * po_cost_price) as total_cost
FROM purchase_orders
WHERE status NOT IN ('canceled', 'done')
AND date >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY vendor, po_id
)
SELECT
po.vendor,
COUNT(DISTINCT po.po_id)::integer as orders,
COALESCE(SUM(po.ordered), 0)::integer as units,
ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) as cost,
ROUND(COALESCE(SUM(po.ordered * p.price), 0)::numeric, 3) as retail
FROM purchase_orders po
JOIN products p ON po.pid = p.pid
WHERE po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
GROUP BY po.vendor
HAVING ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) > 0
pv.vendor,
COUNT(DISTINCT pv.po_id)::integer as orders,
SUM(pv.total_ordered)::integer as units,
ROUND(SUM(pv.total_cost)::numeric, 3) as cost,
ROUND(SUM(pv.total_ordered * pm.current_price)::numeric, 3) as retail
FROM po_by_vendor pv
JOIN purchase_orders po ON pv.po_id = po.po_id
JOIN product_metrics pm ON po.pid = pm.pid
GROUP BY pv.vendor
HAVING ROUND(SUM(pv.total_cost)::numeric, 3) > 0
ORDER BY cost DESC
`);
@@ -223,54 +228,35 @@ router.get('/replenishment/metrics', async (req, res) => {
// Get summary metrics
const { rows: [metrics] } = await executeQuery(`
SELECT
COUNT(DISTINCT p.pid)::integer as products_to_replenish,
COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
ELSE pm.reorder_qty
END), 0)::integer as total_units_needed,
ROUND(COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
ELSE pm.reorder_qty * p.cost_price
END), 0)::numeric, 3) as total_cost,
ROUND(COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
ELSE pm.reorder_qty * p.price
END), 0)::numeric, 3) as total_retail
FROM products p
JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true
AND (pm.stock_status IN ('Critical', 'Reorder')
OR p.stock_quantity < 0)
AND pm.reorder_qty > 0
COUNT(DISTINCT pm.pid)::integer as products_to_replenish,
COALESCE(SUM(pm.replenishment_units), 0)::integer as total_units_needed,
ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 3) as total_cost,
ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 3) as total_retail
FROM product_metrics pm
WHERE pm.is_replenishable = true
AND (pm.status IN ('Critical', 'Reorder')
OR pm.current_stock < 0)
AND pm.replenishment_units > 0
`);
// Get top variants to replenish
const { rows: variants } = await executeQuery(`
SELECT
p.pid,
p.title,
p.stock_quantity::integer as current_stock,
CASE
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
ELSE pm.reorder_qty
END::integer as replenish_qty,
ROUND(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
ELSE pm.reorder_qty * p.cost_price
END::numeric, 3) as replenish_cost,
ROUND(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
ELSE pm.reorder_qty * p.price
END::numeric, 3) as replenish_retail,
pm.stock_status
FROM products p
JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true
AND (pm.stock_status IN ('Critical', 'Reorder')
OR p.stock_quantity < 0)
AND pm.reorder_qty > 0
pm.pid,
pm.title,
pm.current_stock::integer as current_stock,
pm.replenishment_units::integer as replenish_qty,
ROUND(pm.replenishment_cost::numeric, 3) as replenish_cost,
ROUND(pm.replenishment_retail::numeric, 3) as replenish_retail,
pm.status,
pm.planning_period_days::text as planning_period
FROM product_metrics pm
WHERE pm.is_replenishable = true
AND (pm.status IN ('Critical', 'Reorder')
OR pm.current_stock < 0)
AND pm.replenishment_units > 0
ORDER BY
CASE pm.stock_status
CASE pm.status
WHEN 'Critical' THEN 1
WHEN 'Reorder' THEN 2
END,
@@ -280,7 +266,7 @@ router.get('/replenishment/metrics', async (req, res) => {
// If no data, provide dummy data
if (!metrics || variants.length === 0) {
console.log('No replenishment metrics found, returning dummy data');
console.log('No replenishment metrics found in new schema, returning dummy data');
return res.json({
productsToReplenish: 15,
@@ -288,11 +274,11 @@ router.get('/replenishment/metrics', async (req, res) => {
replenishmentCost: 15000.00,
replenishmentRetail: 30000.00,
topVariants: [
{ id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical" },
{ id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical" },
{ id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder" },
{ id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder" },
{ id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder" }
{ id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" },
{ id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" },
{ id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" },
{ id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" },
{ id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" }
]
});
}
@@ -310,7 +296,8 @@ router.get('/replenishment/metrics', async (req, res) => {
replenishQty: parseInt(v.replenish_qty) || 0,
replenishCost: parseFloat(v.replenish_cost) || 0,
replenishRetail: parseFloat(v.replenish_retail) || 0,
status: v.stock_status
status: v.status,
planningPeriod: v.planning_period
}))
};
@@ -325,11 +312,11 @@ router.get('/replenishment/metrics', async (req, res) => {
replenishmentCost: 15000.00,
replenishmentRetail: 30000.00,
topVariants: [
{ id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical" },
{ id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical" },
{ id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder" },
{ id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder" },
{ id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder" }
{ id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" },
{ id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" },
{ id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" },
{ id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" },
{ id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" }
]
});
}
@@ -499,74 +486,15 @@ router.get('/forecast/metrics', async (req, res) => {
// Returns overstock metrics by category
router.get('/overstock/metrics', async (req, res) => {
try {
const { rows } = await executeQuery(`
WITH category_overstock AS (
SELECT
c.cat_id,
c.name as category_name,
COUNT(DISTINCT CASE
WHEN pm.stock_status = 'Overstocked'
THEN p.pid
END) as overstocked_products,
SUM(CASE
WHEN pm.stock_status = 'Overstocked'
THEN pm.overstocked_amt
ELSE 0
END) as total_excess_units,
SUM(CASE
WHEN pm.stock_status = 'Overstocked'
THEN pm.overstocked_amt * p.cost_price
ELSE 0
END) as total_excess_cost,
SUM(CASE
WHEN pm.stock_status = 'Overstocked'
THEN pm.overstocked_amt * p.price
ELSE 0
END) as total_excess_retail
FROM categories c
JOIN product_categories pc ON c.cat_id = pc.cat_id
JOIN products p ON pc.pid = p.pid
JOIN product_metrics pm ON p.pid = pm.pid
GROUP BY c.cat_id, c.name
),
filtered_categories AS (
SELECT *
FROM category_overstock
WHERE overstocked_products > 0
ORDER BY total_excess_cost DESC
LIMIT 8
),
summary AS (
SELECT
SUM(overstocked_products) as total_overstocked,
SUM(total_excess_units) as total_excess_units,
SUM(total_excess_cost) as total_excess_cost,
SUM(total_excess_retail) as total_excess_retail
FROM filtered_categories
)
SELECT
s.total_overstocked,
s.total_excess_units,
s.total_excess_cost,
s.total_excess_retail,
json_agg(
json_build_object(
'category', fc.category_name,
'products', fc.overstocked_products,
'units', fc.total_excess_units,
'cost', fc.total_excess_cost,
'retail', fc.total_excess_retail
)
) as category_data
FROM summary s, filtered_categories fc
GROUP BY
s.total_overstocked,
s.total_excess_units,
s.total_excess_cost,
s.total_excess_retail
// Check if we have any products with Overstock status
const { rows: [countCheck] } = await executeQuery(`
SELECT COUNT(*) as overstock_count FROM product_metrics WHERE status = 'Overstock'
`);
if (rows.length === 0) {
console.log('Overstock count:', countCheck.overstock_count);
// If no overstock products, return empty metrics
if (parseInt(countCheck.overstock_count) === 0) {
return res.json({
overstockedProducts: 0,
total_excess_units: 0,
@@ -575,31 +503,51 @@ router.get('/overstock/metrics', async (req, res) => {
category_data: []
});
}
// Get summary metrics in a simpler, more direct query
const { rows: [summaryMetrics] } = await executeQuery(`
SELECT
COUNT(DISTINCT pid)::integer as total_overstocked,
SUM(overstocked_units)::integer as total_excess_units,
ROUND(SUM(overstocked_cost)::numeric, 3) as total_excess_cost,
ROUND(SUM(overstocked_retail)::numeric, 3) as total_excess_retail
FROM product_metrics
WHERE status = 'Overstock'
`);
// Get category breakdowns separately
const { rows: categoryData } = await executeQuery(`
SELECT
c.name as category_name,
COUNT(DISTINCT pm.pid)::integer as overstocked_products,
SUM(pm.overstocked_units)::integer as total_excess_units,
ROUND(SUM(pm.overstocked_cost)::numeric, 3) as total_excess_cost,
ROUND(SUM(pm.overstocked_retail)::numeric, 3) as total_excess_retail
FROM categories c
JOIN product_categories pc ON c.cat_id = pc.cat_id
JOIN product_metrics pm ON pc.pid = pm.pid
WHERE pm.status = 'Overstock'
GROUP BY c.name
ORDER BY total_excess_cost DESC
LIMIT 8
`);
// Generate dummy data if the query returned empty results
if (rows[0].total_overstocked === null || rows[0].total_excess_units === null) {
console.log('Empty overstock metrics results, returning dummy data');
return res.json({
overstockedProducts: 10,
total_excess_units: 500,
total_excess_cost: 5000,
total_excess_retail: 10000,
category_data: [
{ category: "Electronics", products: 3, units: 150, cost: 1500, retail: 3000 },
{ category: "Clothing", products: 4, units: 200, cost: 2000, retail: 4000 },
{ category: "Home Goods", products: 2, units: 100, cost: 1000, retail: 2000 },
{ category: "Office Supplies", products: 1, units: 50, cost: 500, retail: 1000 }
]
});
}
console.log('Summary metrics:', summaryMetrics);
console.log('Category data count:', categoryData.length);
// Format response with explicit type conversion
const response = {
overstockedProducts: parseInt(rows[0].total_overstocked) || 0,
total_excess_units: parseInt(rows[0].total_excess_units) || 0,
total_excess_cost: parseFloat(rows[0].total_excess_cost) || 0,
total_excess_retail: parseFloat(rows[0].total_excess_retail) || 0,
category_data: rows[0].category_data || []
overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0,
total_excess_units: parseInt(summaryMetrics.total_excess_units) || 0,
total_excess_cost: parseFloat(summaryMetrics.total_excess_cost) || 0,
total_excess_retail: parseFloat(summaryMetrics.total_excess_retail) || 0,
category_data: categoryData.map(cat => ({
category: cat.category_name,
products: parseInt(cat.overstocked_products) || 0,
units: parseInt(cat.total_excess_units) || 0,
cost: parseFloat(cat.total_excess_cost) || 0,
retail: parseFloat(cat.total_excess_retail) || 0
}))
};
res.json(response);
@@ -629,27 +577,26 @@ router.get('/overstock/products', async (req, res) => {
try {
const { rows } = await executeQuery(`
SELECT
p.pid,
p.SKU,
p.title,
p.brand,
p.vendor,
p.stock_quantity,
p.cost_price,
p.price,
pm.daily_sales_avg,
pm.days_of_inventory,
pm.overstocked_amt,
(pm.overstocked_amt * p.cost_price) as excess_cost,
(pm.overstocked_amt * p.price) as excess_retail,
pm.pid,
pm.sku AS SKU,
pm.title,
pm.brand,
pm.vendor,
pm.current_stock as stock_quantity,
pm.current_cost_price as cost_price,
pm.current_price as price,
pm.sales_velocity_daily as daily_sales_avg,
pm.stock_cover_in_days as days_of_inventory,
pm.overstocked_units,
pm.overstocked_cost as excess_cost,
pm.overstocked_retail as excess_retail,
STRING_AGG(c.name, ', ') as categories
FROM products p
JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN product_categories pc ON p.pid = pc.pid
FROM product_metrics pm
LEFT JOIN product_categories pc ON pm.pid = pc.pid
LEFT JOIN categories c ON pc.cat_id = c.cat_id
WHERE pm.stock_status = 'Overstocked'
GROUP BY p.pid, p.SKU, p.title, p.brand, p.vendor, p.stock_quantity, p.cost_price, p.price,
pm.daily_sales_avg, pm.days_of_inventory, pm.overstocked_amt
WHERE pm.status = 'Overstock'
GROUP BY pm.pid, pm.sku, pm.title, pm.brand, pm.vendor, pm.current_stock, pm.current_cost_price, pm.current_price,
pm.sales_velocity_daily, pm.stock_cover_in_days, pm.overstocked_units, pm.overstocked_cost, pm.overstocked_retail
ORDER BY excess_cost DESC
LIMIT $1
`, [limit]);
@@ -827,42 +774,38 @@ router.get('/sales/metrics', async (req, res) => {
const endDate = req.query.endDate || today.toISOString();
try {
// Get daily sales data
// Get daily orders and totals for the specified period
const { rows: dailyRows } = await executeQuery(`
SELECT
DATE(o.date) as sale_date,
COUNT(DISTINCT o.order_number) as total_orders,
SUM(o.quantity) as total_units,
SUM(o.price * o.quantity) as total_revenue,
SUM(p.cost_price * o.quantity) as total_cogs,
SUM((o.price - p.cost_price) * o.quantity) as total_profit
FROM orders o
JOIN products p ON o.pid = p.pid
WHERE o.canceled = false
AND o.date BETWEEN $1 AND $2
GROUP BY DATE(o.date)
DATE(date) as sale_date,
COUNT(DISTINCT order_number) as total_orders,
SUM(quantity) as total_units,
SUM(price * quantity) as total_revenue,
SUM(costeach * quantity) as total_cogs
FROM orders
WHERE date BETWEEN $1 AND $2
AND canceled = false
GROUP BY DATE(date)
ORDER BY sale_date
`, [startDate, endDate]);
// Get summary metrics
const { rows: metrics } = await executeQuery(`
// Get overall metrics for the period
const { rows: [metrics] } = await executeQuery(`
SELECT
COUNT(DISTINCT o.order_number) as total_orders,
SUM(o.quantity) as total_units,
SUM(o.price * o.quantity) as total_revenue,
SUM(p.cost_price * o.quantity) as total_cogs,
SUM((o.price - p.cost_price) * o.quantity) as total_profit
FROM orders o
JOIN products p ON o.pid = p.pid
WHERE o.canceled = false
AND o.date BETWEEN $1 AND $2
COUNT(DISTINCT order_number) as total_orders,
SUM(quantity) as total_units,
SUM(price * quantity) as total_revenue,
SUM(costeach * quantity) as total_cogs
FROM orders
WHERE date BETWEEN $1 AND $2
AND canceled = false
`, [startDate, endDate]);
const response = {
totalOrders: parseInt(metrics[0]?.total_orders) || 0,
totalUnitsSold: parseInt(metrics[0]?.total_units) || 0,
totalCogs: parseFloat(metrics[0]?.total_cogs) || 0,
totalRevenue: parseFloat(metrics[0]?.total_revenue) || 0,
totalOrders: parseInt(metrics?.total_orders) || 0,
totalUnitsSold: parseInt(metrics?.total_units) || 0,
totalCogs: parseFloat(metrics?.total_cogs) || 0,
totalRevenue: parseFloat(metrics?.total_revenue) || 0,
dailySales: dailyRows.map(day => ({
date: day.sale_date,
units: parseInt(day.total_units) || 0,
@@ -1304,39 +1247,33 @@ router.get('/inventory-health', async (req, res) => {
});
// GET /dashboard/replenish/products
// Returns top products that need replenishment
// Returns list of products to replenish
router.get('/replenish/products', async (req, res) => {
const limit = Math.max(1, Math.min(100, parseInt(req.query.limit) || 50));
const limit = parseInt(req.query.limit) || 50;
try {
const { rows: products } = await executeQuery(`
const { rows } = await executeQuery(`
SELECT
p.pid,
p.SKU as sku,
p.title,
p.stock_quantity,
pm.daily_sales_avg,
pm.reorder_qty,
pm.last_purchase_date
FROM products p
JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true
AND pm.stock_status IN ('Critical', 'Reorder')
AND pm.reorder_qty > 0
pm.pid,
pm.sku,
pm.title,
pm.current_stock AS stock_quantity,
pm.sales_velocity_daily AS daily_sales_avg,
pm.replenishment_units AS reorder_qty,
pm.date_last_received AS last_purchase_date
FROM product_metrics pm
WHERE pm.is_replenishable = true
AND (pm.status IN ('Critical', 'Reorder')
OR pm.current_stock < 0)
AND pm.replenishment_units > 0
ORDER BY
CASE pm.stock_status
CASE pm.status
WHEN 'Critical' THEN 1
WHEN 'Reorder' THEN 2
END,
pm.reorder_qty * p.cost_price DESC
pm.replenishment_cost DESC
LIMIT $1
`, [limit]);
res.json(products.map(p => ({
...p,
stock_quantity: parseInt(p.stock_quantity) || 0,
daily_sales_avg: parseFloat(p.daily_sales_avg) || 0,
reorder_qty: parseInt(p.reorder_qty) || 0
})));
res.json(rows);
} catch (err) {
console.error('Error fetching products to replenish:', err);
res.status(500).json({ error: 'Failed to fetch products to replenish' });
@@ -0,0 +1,440 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const path = require('path');
const db = require('../utils/db');
// Debug middleware MUST be first
router.use((req, res, next) => {
console.log(`[CSV Route Debug] ${req.method} ${req.path}`);
next();
});
// Store active processes and their progress
let activeImport = null;
let importProgress = null;
let activeFullUpdate = null;
let activeFullReset = null;
// SSE clients for progress updates
const updateClients = new Set();
const importClients = new Set();
const resetClients = new Set();
const resetMetricsClients = new Set();
const calculateMetricsClients = new Set();
const fullUpdateClients = new Set();
const fullResetClients = new Set();
// Helper to send progress to specific clients
function sendProgressToClients(clients, data) {
// If data is a string, send it directly
// If it's an object, convert it to JSON
const message = typeof data === 'string'
? `data: ${data}\n\n`
: `data: ${JSON.stringify(data)}\n\n`;
clients.forEach(client => {
try {
client.write(message);
// Immediately flush the response
if (typeof client.flush === 'function') {
client.flush();
}
} catch (error) {
// Silently remove failed client
clients.delete(client);
}
});
}
// Helper to run a script and stream progress
function runScript(scriptPath, type, clients) {
return new Promise((resolve, reject) => {
// Kill any existing process of this type
let activeProcess;
switch (type) {
case 'update':
if (activeFullUpdate) {
try { activeFullUpdate.kill(); } catch (e) { }
}
activeProcess = activeFullUpdate;
break;
case 'reset':
if (activeFullReset) {
try { activeFullReset.kill(); } catch (e) { }
}
activeProcess = activeFullReset;
break;
}
const child = spawn('node', [scriptPath], {
stdio: ['inherit', 'pipe', 'pipe']
});
switch (type) {
case 'update':
activeFullUpdate = child;
break;
case 'reset':
activeFullReset = child;
break;
}
let output = '';
child.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Split by lines to handle multiple JSON outputs
const lines = text.split('\n');
lines.filter(line => line.trim()).forEach(line => {
try {
// Try to parse as JSON but don't let it affect the display
const jsonData = JSON.parse(line);
// Only end the process if we get a final status
if (jsonData.status === 'complete' || jsonData.status === 'error' || jsonData.status === 'cancelled') {
if (jsonData.status === 'complete' && !jsonData.operation?.includes('complete')) {
// Don't close for intermediate completion messages
sendProgressToClients(clients, line);
return;
}
// Close only on final completion/error/cancellation
switch (type) {
case 'update':
activeFullUpdate = null;
break;
case 'reset':
activeFullReset = null;
break;
}
if (jsonData.status === 'error') {
reject(new Error(jsonData.error || 'Unknown error'));
} else {
resolve({ output });
}
}
} catch (e) {
// Not JSON, just display as is
}
// Always send the raw line
sendProgressToClients(clients, line);
});
});
child.stderr.on('data', (data) => {
const text = data.toString();
console.error(text);
// Send stderr output directly too
sendProgressToClients(clients, text);
});
child.on('close', (code) => {
switch (type) {
case 'update':
activeFullUpdate = null;
break;
case 'reset':
activeFullReset = null;
break;
}
if (code !== 0) {
const error = `Script ${scriptPath} exited with code ${code}`;
sendProgressToClients(clients, error);
reject(new Error(error));
}
// Don't resolve here - let the completion message from the script trigger the resolve
});
child.on('error', (err) => {
switch (type) {
case 'update':
activeFullUpdate = null;
break;
case 'reset':
activeFullReset = null;
break;
}
sendProgressToClients(clients, err.message);
reject(err);
});
});
}
// Progress endpoints
router.get('/:type/progress', (req, res) => {
const { type } = req.params;
if (!['update', 'reset'].includes(type)) {
return res.status(400).json({ error: 'Invalid operation type' });
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': req.headers.origin || '*',
'Access-Control-Allow-Credentials': 'true'
});
// Add this client to the correct set
const clients = type === 'update' ? fullUpdateClients : fullResetClients;
clients.add(res);
// Send initial connection message
sendProgressToClients(new Set([res]), JSON.stringify({
status: 'running',
operation: 'Initializing connection...'
}));
// Handle client disconnect
req.on('close', () => {
clients.delete(res);
});
});
// GET /status - Check for active processes
router.get('/status', (req, res) => {
try {
const hasActiveUpdate = activeFullUpdate !== null;
const hasActiveReset = activeFullReset !== null;
if (hasActiveUpdate || hasActiveReset) {
res.json({
active: true,
progress: {
status: 'running',
operation: hasActiveUpdate ? 'Full update in progress' : 'Full reset in progress',
type: hasActiveUpdate ? 'update' : 'reset'
}
});
} else {
res.json({
active: false,
progress: null
});
}
} catch (error) {
console.error('Error checking status:', error);
res.status(500).json({ error: error.message });
}
});
// Route to cancel active process
router.post('/cancel', (req, res) => {
let killed = false;
// Get the operation type from the request
const { type } = req.query;
const clients = type === 'update' ? fullUpdateClients : fullResetClients;
const activeProcess = type === 'update' ? activeFullUpdate : activeFullReset;
if (activeProcess) {
try {
activeProcess.kill('SIGTERM');
if (type === 'update') {
activeFullUpdate = null;
} else {
activeFullReset = null;
}
killed = true;
sendProgressToClients(clients, JSON.stringify({
status: 'cancelled',
operation: 'Operation cancelled'
}));
} catch (err) {
console.error(`Error killing ${type} process:`, err);
}
}
if (killed) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'No active process to cancel' });
}
});
// POST /csv/full-update - Run full update script
router.post('/full-update', async (req, res) => {
try {
const scriptPath = path.join(__dirname, '../../scripts/full-update.js');
runScript(scriptPath, 'update', fullUpdateClients)
.catch(error => {
console.error('Update failed:', error);
});
res.status(202).json({ message: 'Update started' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /csv/full-reset - Run full reset script
router.post('/full-reset', async (req, res) => {
try {
const scriptPath = path.join(__dirname, '../../scripts/full-reset.js');
runScript(scriptPath, 'reset', fullResetClients)
.catch(error => {
console.error('Reset failed:', error);
});
res.status(202).json({ message: 'Reset started' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /history/import - Get recent import history
router.get('/history/import', async (req, res) => {
try {
const pool = req.app.locals.pool;
// First check which columns exist
const { rows: columns } = await pool.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'import_history'
AND column_name IN ('records_deleted', 'records_skipped', 'total_processed')
`);
const hasDeletedColumn = columns.some(col => col.column_name === 'records_deleted');
const hasSkippedColumn = columns.some(col => col.column_name === 'records_skipped');
const hasTotalProcessedColumn = columns.some(col => col.column_name === 'total_processed');
// Build query dynamically based on available columns
const query = `
SELECT
id,
start_time,
end_time,
status,
error_message,
records_added::integer,
records_updated::integer,
${hasDeletedColumn ? 'records_deleted::integer,' : '0 as records_deleted,'}
${hasSkippedColumn ? 'records_skipped::integer,' : '0 as records_skipped,'}
${hasTotalProcessedColumn ? 'total_processed::integer,' : '0 as total_processed,'}
is_incremental,
additional_info,
EXTRACT(EPOCH FROM (COALESCE(end_time, NOW()) - start_time)) / 60 as duration_minutes
FROM import_history
ORDER BY start_time DESC
LIMIT 20
`;
const { rows } = await pool.query(query);
res.json(rows || []);
} catch (error) {
console.error('Error fetching import history:', error);
res.status(500).json({ error: error.message });
}
});
// GET /history/calculate - Get recent calculation history
router.get('/history/calculate', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
id,
start_time,
end_time,
EXTRACT(EPOCH FROM (COALESCE(end_time, NOW()) - start_time)) / 60 as duration_minutes,
duration_seconds,
status,
error_message,
total_products,
total_orders,
total_purchase_orders,
processed_products,
processed_orders,
processed_purchase_orders,
additional_info
FROM calculate_history
ORDER BY start_time DESC
LIMIT 20
`);
res.json(rows || []);
} catch (error) {
console.error('Error fetching calculate history:', error);
res.status(500).json({ error: error.message });
}
});
// GET /status/modules - Get module calculation status
router.get('/status/modules', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
module_name,
last_calculation_timestamp::timestamp
FROM calculate_status
ORDER BY module_name
`);
res.json(rows || []);
} catch (error) {
console.error('Error fetching module status:', error);
res.status(500).json({ error: error.message });
}
});
// GET /status/tables - Get table sync status
router.get('/status/tables', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
SELECT
table_name,
last_sync_timestamp::timestamp
FROM sync_status
ORDER BY table_name
`);
res.json(rows || []);
} catch (error) {
console.error('Error fetching table status:', error);
res.status(500).json({ error: error.message });
}
});
// GET /status/table-counts - Get record counts for all tables
router.get('/status/table-counts', async (req, res) => {
try {
const pool = req.app.locals.pool;
const tables = [
// Core tables
'products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'receivings',
// New metrics tables
'product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics',
// Config tables
'settings_global', 'settings_vendor', 'settings_product'
];
const counts = await Promise.all(
tables.map(table =>
pool.query(`SELECT COUNT(*) as count FROM ${table}`)
.then(result => ({
table_name: table,
count: parseInt(result.rows[0].count)
}))
.catch(err => ({
table_name: table,
count: null,
error: err.message
}))
)
);
// Group tables by type
const groupedCounts = {
core: counts.filter(c => ['products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'receivings'].includes(c.table_name)),
metrics: counts.filter(c => ['product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics'].includes(c.table_name)),
config: counts.filter(c => ['settings_global', 'settings_vendor', 'settings_product'].includes(c.table_name))
};
res.json(groupedCounts);
} catch (error) {
console.error('Error fetching table counts:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+329 -84
View File
@@ -7,90 +7,272 @@ const { Pool } = require('pg'); // Assuming pg driver
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200; // Prevent excessive data requests
/**
* Maps user-friendly query parameter keys (camelCase) to database column names.
* Also validates if the column is safe for sorting or filtering.
* Add ALL columns from product_metrics that should be filterable/sortable.
*/
// Define direct mapping from frontend column names to database columns
// This simplifies the code by eliminating conversion logic
const COLUMN_MAP = {
// Product Info
pid: { dbCol: 'pm.pid', type: 'number' },
sku: { dbCol: 'pm.sku', type: 'string' },
title: { dbCol: 'pm.title', type: 'string' },
brand: { dbCol: 'pm.brand', type: 'string' },
vendor: { dbCol: 'pm.vendor', type: 'string' },
imageUrl: { dbCol: 'pm.image_url', type: 'string' },
isVisible: { dbCol: 'pm.is_visible', type: 'boolean' },
isReplenishable: { dbCol: 'pm.is_replenishable', type: 'boolean' },
pid: 'pm.pid',
sku: 'pm.sku',
title: 'pm.title',
brand: 'pm.brand',
vendor: 'pm.vendor',
imageUrl: 'pm.image_url',
isVisible: 'pm.is_visible',
isReplenishable: 'pm.is_replenishable',
// Additional Product Fields
barcode: 'pm.barcode',
harmonizedTariffCode: 'pm.harmonized_tariff_code',
vendorReference: 'pm.vendor_reference',
notionsReference: 'pm.notions_reference',
line: 'pm.line',
subline: 'pm.subline',
artist: 'pm.artist',
moq: 'pm.moq',
rating: 'pm.rating',
reviews: 'pm.reviews',
weight: 'pm.weight',
length: 'pm.length',
width: 'pm.width',
height: 'pm.height',
countryOfOrigin: 'pm.country_of_origin',
location: 'pm.location',
baskets: 'pm.baskets',
notifies: 'pm.notifies',
preorderCount: 'pm.preorder_count',
notionsInvCount: 'pm.notions_inv_count',
// Current Status
currentPrice: { dbCol: 'pm.current_price', type: 'number' },
currentRegularPrice: { dbCol: 'pm.current_regular_price', type: 'number' },
currentCostPrice: { dbCol: 'pm.current_cost_price', type: 'number' },
currentLandingCostPrice: { dbCol: 'pm.current_landing_cost_price', type: 'number' },
currentStock: { dbCol: 'pm.current_stock', type: 'number' },
currentStockCost: { dbCol: 'pm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'pm.current_stock_retail', type: 'number' },
currentStockGross: { dbCol: 'pm.current_stock_gross', type: 'number' },
onOrderQty: { dbCol: 'pm.on_order_qty', type: 'number' },
onOrderCost: { dbCol: 'pm.on_order_cost', type: 'number' },
onOrderRetail: { dbCol: 'pm.on_order_retail', type: 'number' },
earliestExpectedDate: { dbCol: 'pm.earliest_expected_date', type: 'date' },
currentPrice: 'pm.current_price',
currentRegularPrice: 'pm.current_regular_price',
currentCostPrice: 'pm.current_cost_price',
currentLandingCostPrice: 'pm.current_landing_cost_price',
currentStock: 'pm.current_stock',
currentStockCost: 'pm.current_stock_cost',
currentStockRetail: 'pm.current_stock_retail',
currentStockGross: 'pm.current_stock_gross',
onOrderQty: 'pm.on_order_qty',
onOrderCost: 'pm.on_order_cost',
onOrderRetail: 'pm.on_order_retail',
earliestExpectedDate: 'pm.earliest_expected_date',
// Historical Dates
dateCreated: { dbCol: 'pm.date_created', type: 'date' },
dateFirstReceived: { dbCol: 'pm.date_first_received', type: 'date' },
dateLastReceived: { dbCol: 'pm.date_last_received', type: 'date' },
dateFirstSold: { dbCol: 'pm.date_first_sold', type: 'date' },
dateLastSold: { dbCol: 'pm.date_last_sold', type: 'date' },
ageDays: { dbCol: 'pm.age_days', type: 'number' },
dateCreated: 'pm.date_created',
dateFirstReceived: 'pm.date_first_received',
dateLastReceived: 'pm.date_last_received',
dateFirstSold: 'pm.date_first_sold',
dateLastSold: 'pm.date_last_sold',
ageDays: 'pm.age_days',
// Rolling Period Metrics
sales7d: { dbCol: 'pm.sales_7d', type: 'number' }, revenue7d: { dbCol: 'pm.revenue_7d', type: 'number' },
sales14d: { dbCol: 'pm.sales_14d', type: 'number' }, revenue14d: { dbCol: 'pm.revenue_14d', type: 'number' },
sales30d: { dbCol: 'pm.sales_30d', type: 'number' }, revenue30d: { dbCol: 'pm.revenue_30d', type: 'number' },
cogs30d: { dbCol: 'pm.cogs_30d', type: 'number' }, profit30d: { dbCol: 'pm.profit_30d', type: 'number' },
returnsUnits30d: { dbCol: 'pm.returns_units_30d', type: 'number' }, returnsRevenue30d: { dbCol: 'pm.returns_revenue_30d', type: 'number' },
discounts30d: { dbCol: 'pm.discounts_30d', type: 'number' }, grossRevenue30d: { dbCol: 'pm.gross_revenue_30d', type: 'number' },
grossRegularRevenue30d: { dbCol: 'pm.gross_regular_revenue_30d', type: 'number' },
stockoutDays30d: { dbCol: 'pm.stockout_days_30d', type: 'number' },
sales365d: { dbCol: 'pm.sales_365d', type: 'number' }, revenue365d: { dbCol: 'pm.revenue_365d', type: 'number' },
avgStockUnits30d: { dbCol: 'pm.avg_stock_units_30d', type: 'number' }, avgStockCost30d: { dbCol: 'pm.avg_stock_cost_30d', type: 'number' },
avgStockRetail30d: { dbCol: 'pm.avg_stock_retail_30d', type: 'number' }, avgStockGross30d: { dbCol: 'pm.avg_stock_gross_30d', type: 'number' },
receivedQty30d: { dbCol: 'pm.received_qty_30d', type: 'number' }, receivedCost30d: { dbCol: 'pm.received_cost_30d', type: 'number' },
sales7d: 'pm.sales_7d',
revenue7d: 'pm.revenue_7d',
sales14d: 'pm.sales_14d',
revenue14d: 'pm.revenue_14d',
sales30d: 'pm.sales_30d',
revenue30d: 'pm.revenue_30d',
cogs30d: 'pm.cogs_30d',
profit30d: 'pm.profit_30d',
returnsUnits30d: 'pm.returns_units_30d',
returnsRevenue30d: 'pm.returns_revenue_30d',
discounts30d: 'pm.discounts_30d',
grossRevenue30d: 'pm.gross_revenue_30d',
grossRegularRevenue30d: 'pm.gross_regular_revenue_30d',
stockoutDays30d: 'pm.stockout_days_30d',
sales365d: 'pm.sales_365d',
revenue365d: 'pm.revenue_365d',
avgStockUnits30d: 'pm.avg_stock_units_30d',
avgStockCost30d: 'pm.avg_stock_cost_30d',
avgStockRetail30d: 'pm.avg_stock_retail_30d',
avgStockGross30d: 'pm.avg_stock_gross_30d',
receivedQty30d: 'pm.received_qty_30d',
receivedCost30d: 'pm.received_cost_30d',
// Lifetime Metrics
lifetimeSales: { dbCol: 'pm.lifetime_sales', type: 'number' }, lifetimeRevenue: { dbCol: 'pm.lifetime_revenue', type: 'number' },
lifetimeSales: 'pm.lifetime_sales',
lifetimeRevenue: 'pm.lifetime_revenue',
// First Period Metrics
first7DaysSales: { dbCol: 'pm.first_7_days_sales', type: 'number' }, first7DaysRevenue: { dbCol: 'pm.first_7_days_revenue', type: 'number' },
first30DaysSales: { dbCol: 'pm.first_30_days_sales', type: 'number' }, first30DaysRevenue: { dbCol: 'pm.first_30_days_revenue', type: 'number' },
first60DaysSales: { dbCol: 'pm.first_60_days_sales', type: 'number' }, first60DaysRevenue: { dbCol: 'pm.first_60_days_revenue', type: 'number' },
first90DaysSales: { dbCol: 'pm.first_90_days_sales', type: 'number' }, first90DaysRevenue: { dbCol: 'pm.first_90_days_revenue', type: 'number' },
first7DaysSales: 'pm.first_7_days_sales',
first7DaysRevenue: 'pm.first_7_days_revenue',
first30DaysSales: 'pm.first_30_days_sales',
first30DaysRevenue: 'pm.first_30_days_revenue',
first60DaysSales: 'pm.first_60_days_sales',
first60DaysRevenue: 'pm.first_60_days_revenue',
first90DaysSales: 'pm.first_90_days_sales',
first90DaysRevenue: 'pm.first_90_days_revenue',
// Calculated KPIs
asp30d: { dbCol: 'pm.asp_30d', type: 'number' }, acp30d: { dbCol: 'pm.acp_30d', type: 'number' }, avgRos30d: { dbCol: 'pm.avg_ros_30d', type: 'number' },
avgSalesPerDay30d: { dbCol: 'pm.avg_sales_per_day_30d', type: 'number' }, avgSalesPerMonth30d: { dbCol: 'pm.avg_sales_per_month_30d', type: 'number' },
margin30d: { dbCol: 'pm.margin_30d', type: 'number' }, markup30d: { dbCol: 'pm.markup_30d', type: 'number' }, gmroi30d: { dbCol: 'pm.gmroi_30d', type: 'number' },
stockturn30d: { dbCol: 'pm.stockturn_30d', type: 'number' }, returnRate30d: { dbCol: 'pm.return_rate_30d', type: 'number' },
discountRate30d: { dbCol: 'pm.discount_rate_30d', type: 'number' }, stockoutRate30d: { dbCol: 'pm.stockout_rate_30d', type: 'number' },
markdown30d: { dbCol: 'pm.markdown_30d', type: 'number' }, markdownRate30d: { dbCol: 'pm.markdown_rate_30d', type: 'number' },
sellThrough30d: { dbCol: 'pm.sell_through_30d', type: 'number' }, avgLeadTimeDays: { dbCol: 'pm.avg_lead_time_days', type: 'number' },
asp30d: 'pm.asp_30d',
acp30d: 'pm.acp_30d',
avgRos30d: 'pm.avg_ros_30d',
avgSalesPerDay30d: 'pm.avg_sales_per_day_30d',
avgSalesPerMonth30d: 'pm.avg_sales_per_month_30d',
margin30d: 'pm.margin_30d',
markup30d: 'pm.markup_30d',
gmroi30d: 'pm.gmroi_30d',
stockturn30d: 'pm.stockturn_30d',
returnRate30d: 'pm.return_rate_30d',
discountRate30d: 'pm.discount_rate_30d',
stockoutRate30d: 'pm.stockout_rate_30d',
markdown30d: 'pm.markdown_30d',
markdownRate30d: 'pm.markdown_rate_30d',
sellThrough30d: 'pm.sell_through_30d',
avgLeadTimeDays: 'pm.avg_lead_time_days',
// Forecasting & Replenishment
abcClass: { dbCol: 'pm.abc_class', type: 'string' }, salesVelocityDaily: { dbCol: 'pm.sales_velocity_daily', type: 'number' },
configLeadTime: { dbCol: 'pm.config_lead_time', type: 'number' }, configDaysOfStock: { dbCol: 'pm.config_days_of_stock', type: 'number' },
configSafetyStock: { dbCol: 'pm.config_safety_stock', type: 'number' }, planningPeriodDays: { dbCol: 'pm.planning_period_days', type: 'number' },
leadTimeForecastUnits: { dbCol: 'pm.lead_time_forecast_units', type: 'number' }, daysOfStockForecastUnits: { dbCol: 'pm.days_of_stock_forecast_units', type: 'number' },
planningPeriodForecastUnits: { dbCol: 'pm.planning_period_forecast_units', type: 'number' }, leadTimeClosingStock: { dbCol: 'pm.lead_time_closing_stock', type: 'number' },
daysOfStockClosingStock: { dbCol: 'pm.days_of_stock_closing_stock', type: 'number' }, replenishmentNeededRaw: { dbCol: 'pm.replenishment_needed_raw', type: 'number' },
replenishmentUnits: { dbCol: 'pm.replenishment_units', type: 'number' }, replenishmentCost: { dbCol: 'pm.replenishment_cost', type: 'number' },
replenishmentRetail: { dbCol: 'pm.replenishment_retail', type: 'number' }, replenishmentProfit: { dbCol: 'pm.replenishment_profit', type: 'number' },
toOrderUnits: { dbCol: 'pm.to_order_units', type: 'number' }, forecastLostSalesUnits: { dbCol: 'pm.forecast_lost_sales_units', type: 'number' },
forecastLostRevenue: { dbCol: 'pm.forecast_lost_revenue', type: 'number' }, stockCoverInDays: { dbCol: 'pm.stock_cover_in_days', type: 'number' },
poCoverInDays: { dbCol: 'pm.po_cover_in_days', type: 'number' }, sellsOutInDays: { dbCol: 'pm.sells_out_in_days', type: 'number' },
replenishDate: { dbCol: 'pm.replenish_date', type: 'date' }, overstockedUnits: { dbCol: 'pm.overstocked_units', type: 'number' },
overstockedCost: { dbCol: 'pm.overstocked_cost', type: 'number' }, overstockedRetail: { dbCol: 'pm.overstocked_retail', type: 'number' },
isOldStock: { dbCol: 'pm.is_old_stock', type: 'boolean' },
abcClass: 'pm.abc_class',
salesVelocityDaily: 'pm.sales_velocity_daily',
configLeadTime: 'pm.config_lead_time',
configDaysOfStock: 'pm.config_days_of_stock',
configSafetyStock: 'pm.config_safety_stock',
planningPeriodDays: 'pm.planning_period_days',
leadTimeForecastUnits: 'pm.lead_time_forecast_units',
daysOfStockForecastUnits: 'pm.days_of_stock_forecast_units',
planningPeriodForecastUnits: 'pm.planning_period_forecast_units',
leadTimeClosingStock: 'pm.lead_time_closing_stock',
daysOfStockClosingStock: 'pm.days_of_stock_closing_stock',
replenishmentNeededRaw: 'pm.replenishment_needed_raw',
replenishmentUnits: 'pm.replenishment_units',
replenishmentCost: 'pm.replenishment_cost',
replenishmentRetail: 'pm.replenishment_retail',
replenishmentProfit: 'pm.replenishment_profit',
toOrderUnits: 'pm.to_order_units',
forecastLostSalesUnits: 'pm.forecast_lost_sales_units',
forecastLostRevenue: 'pm.forecast_lost_revenue',
stockCoverInDays: 'pm.stock_cover_in_days',
poCoverInDays: 'pm.po_cover_in_days',
sellsOutInDays: 'pm.sells_out_in_days',
replenishDate: 'pm.replenish_date',
overstockedUnits: 'pm.overstocked_units',
overstockedCost: 'pm.overstocked_cost',
overstockedRetail: 'pm.overstocked_retail',
isOldStock: 'pm.is_old_stock',
// Yesterday
yesterdaySales: { dbCol: 'pm.yesterday_sales', type: 'number' },
yesterdaySales: 'pm.yesterday_sales',
// Map status column - directly mapped now instead of calculated on frontend
status: 'pm.status',
// Growth Metrics (P3)
salesGrowth30dVsPrev: 'pm.sales_growth_30d_vs_prev',
revenueGrowth30dVsPrev: 'pm.revenue_growth_30d_vs_prev',
salesGrowthYoy: 'pm.sales_growth_yoy',
revenueGrowthYoy: 'pm.revenue_growth_yoy',
// Demand Variability Metrics (P3)
salesVariance30d: 'pm.sales_variance_30d',
salesStdDev30d: 'pm.sales_std_dev_30d',
salesCv30d: 'pm.sales_cv_30d',
demandPattern: 'pm.demand_pattern',
// Service Level Metrics (P5)
fillRate30d: 'pm.fill_rate_30d',
stockoutIncidents30d: 'pm.stockout_incidents_30d',
serviceLevel30d: 'pm.service_level_30d',
lostSalesIncidents30d: 'pm.lost_sales_incidents_30d',
// Seasonality Metrics (P5)
seasonalityIndex: 'pm.seasonality_index',
seasonalPattern: 'pm.seasonal_pattern',
peakSeason: 'pm.peak_season',
// Lifetime Revenue Quality
lifetimeRevenueQuality: 'pm.lifetime_revenue_quality'
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
// Define column types for use in sorting/filtering
// This helps apply correct comparison operators and sorting logic
const COLUMN_TYPES = {
// Numeric columns (use numeric operators and sorting)
numeric: [
'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentLandingCostPrice',
'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross',
'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays',
'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d',
'cogs30d', 'profit30d', 'returnsUnits30d', 'returnsRevenue30d', 'discounts30d',
'grossRevenue30d', 'grossRegularRevenue30d', 'stockoutDays30d', 'sales365d', 'revenue365d',
'avgStockUnits30d', 'avgStockCost30d', 'avgStockRetail30d', 'avgStockGross30d',
'receivedQty30d', 'receivedCost30d', 'lifetimeSales', 'lifetimeRevenue',
'first7DaysSales', 'first7DaysRevenue', 'first30DaysSales', 'first30DaysRevenue',
'first60DaysSales', 'first60DaysRevenue', 'first90DaysSales', 'first90DaysRevenue',
'asp30d', 'acp30d', 'avgRos30d', 'avgSalesPerDay30d', 'avgSalesPerMonth30d',
'margin30d', 'markup30d', 'gmroi30d', 'stockturn30d', 'returnRate30d', 'discountRate30d',
'stockoutRate30d', 'markdown30d', 'markdownRate30d', 'sellThrough30d', 'avgLeadTimeDays',
'salesVelocityDaily', 'configLeadTime', 'configDaysOfStock', 'configSafetyStock',
'planningPeriodDays', 'leadTimeForecastUnits', 'daysOfStockForecastUnits',
'planningPeriodForecastUnits', 'leadTimeClosingStock', 'daysOfStockClosingStock',
'replenishmentNeededRaw', 'replenishmentUnits', 'replenishmentCost', 'replenishmentRetail',
'replenishmentProfit', 'toOrderUnits', 'forecastLostSalesUnits', 'forecastLostRevenue',
'stockCoverInDays', 'poCoverInDays', 'sellsOutInDays', 'overstockedUnits',
'overstockedCost', 'overstockedRetail', 'yesterdaySales',
// New numeric columns
'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height',
'baskets', 'notifies', 'preorderCount', 'notionsInvCount',
// Growth metrics
'salesGrowth30dVsPrev', 'revenueGrowth30dVsPrev', 'salesGrowthYoy', 'revenueGrowthYoy',
// Demand variability metrics
'salesVariance30d', 'salesStdDev30d', 'salesCv30d',
// Service level metrics
'fillRate30d', 'stockoutIncidents30d', 'serviceLevel30d', 'lostSalesIncidents30d',
// Seasonality metrics
'seasonalityIndex'
],
// Date columns (use date operators and sorting)
date: [
'dateCreated', 'dateFirstReceived', 'dateLastReceived', 'dateFirstSold', 'dateLastSold',
'earliestExpectedDate', 'replenishDate', 'forecastedOutOfStockDate'
],
// String columns (use string operators and sorting)
string: [
'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status',
// New string columns
'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference',
'line', 'subline', 'artist', 'countryOfOrigin', 'location',
// New string columns for patterns
'demandPattern', 'seasonalPattern', 'peakSeason', 'lifetimeRevenueQuality'
],
// Boolean columns (use boolean operators and sorting)
boolean: ['isVisible', 'isReplenishable', 'isOldStock']
};
// Special sort handling for certain columns
const SPECIAL_SORT_COLUMNS = {
// Percentage columns where we want to sort by the numeric value
margin30d: true,
markup30d: true,
sellThrough30d: true,
discountRate30d: true,
stockoutRate30d: true,
returnRate30d: true,
markdownRate30d: true,
// Columns where we may want to sort by absolute value
profit30d: 'abs',
// Velocity columns
salesVelocityDaily: true,
// Growth rate columns
salesGrowth30dVsPrev: 'abs',
revenueGrowth30dVsPrev: 'abs',
salesGrowthYoy: 'abs',
revenueGrowthYoy: 'abs',
// Status column needs special ordering
status: 'priority'
};
// Status priority for sorting (lower number = higher priority)
const STATUS_PRIORITY = {
'Critical': 1,
'At Risk': 2,
'Reorder': 3,
'Overstocked': 4,
'Healthy': 5,
'New': 6
// Any other status will be sorted alphabetically after these
};
// Get database column name from frontend column name
function getDbColumn(frontendColumn) {
return COLUMN_MAP[frontendColumn] || 'pm.title'; // Default to title if not found
}
// Get column type for proper sorting
function getColumnType(frontendColumn) {
return COLUMN_TYPES[frontendColumn] || 'string';
}
// --- Route Handlers ---
@@ -121,7 +303,7 @@ router.get('/filter-options', async (req, res) => {
// GET /metrics/ - List all product metrics with filtering, sorting, pagination
router.get('/', async (req, res) => {
const pool = req.app.locals.pool; // Get pool from app instance
const pool = req.app.locals.pool;
console.log('GET /metrics received query:', req.query);
try {
@@ -135,10 +317,45 @@ router.get('/', async (req, res) => {
// --- Sorting ---
const sortQueryKey = req.query.sort || 'title'; // Default sort field key
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'pm.title'; // Default DB column
const dbColumn = getDbColumn(sortQueryKey);
const columnType = getColumnType(sortQueryKey);
console.log(`Sorting request: ${sortQueryKey} -> ${dbColumn} (${columnType})`);
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST'); // Consistent null handling
// Always put nulls last regardless of sort direction or column type
const nullsOrder = 'NULLS LAST';
// Build the ORDER BY clause based on column type and special handling
let orderByClause;
if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'abs') {
// Sort by absolute value for columns where negative values matter
orderByClause = `ABS(${dbColumn}::numeric) ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'number' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) {
// For numeric columns, cast to numeric to ensure proper sorting
orderByClause = `${dbColumn}::numeric ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'date') {
// For date columns, cast to timestamp to ensure proper sorting
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn}::timestamp ${sortDirection}`;
} else if (columnType === 'status' || SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') {
// Special handling for status column, using priority for known statuses
orderByClause = `
CASE WHEN ${dbColumn} IS NULL THEN 999
WHEN ${dbColumn} = 'Critical' THEN 1
WHEN ${dbColumn} = 'At Risk' THEN 2
WHEN ${dbColumn} = 'Reorder' THEN 3
WHEN ${dbColumn} = 'Overstocked' THEN 4
WHEN ${dbColumn} = 'Healthy' THEN 5
WHEN ${dbColumn} = 'New' THEN 6
ELSE 100
END ${sortDirection} ${nullsOrder},
${dbColumn} ${sortDirection}`;
} else {
// For string and boolean columns, no special casting needed
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn} ${sortDirection}`;
}
// --- Filtering ---
const conditions = [];
@@ -149,9 +366,24 @@ router.get('/', async (req, res) => {
if (req.query.showInvisible !== 'true') conditions.push(`pm.is_visible = true`);
if (req.query.showNonReplenishable !== 'true') conditions.push(`pm.is_replenishable = true`);
// Special handling for stock_status
if (req.query.stock_status) {
const status = req.query.stock_status;
// Handle special case for "at-risk" which is stored as "At Risk" in the database
if (status.toLowerCase() === 'at-risk') {
conditions.push(`pm.status = $${paramCounter++}`);
params.push('At Risk');
} else {
// Capitalize first letter to match database values
conditions.push(`pm.status = $${paramCounter++}`);
params.push(status.charAt(0).toUpperCase() + status.slice(1));
}
}
// Process other filters from query parameters
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order', 'showInvisible', 'showNonReplenishable'].includes(key)) continue; // Skip control params
// Skip control params
if (['page', 'limit', 'sort', 'order', 'showInvisible', 'showNonReplenishable', 'stock_status'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
@@ -164,15 +396,15 @@ router.get('/', async (req, res) => {
operator = operatorMatch[2]; // e.g., "gt"
}
const columnInfo = getSafeColumnInfo(filterKey);
if (!columnInfo) {
// Get the database column for this filter key
const dbColumn = getDbColumn(filterKey);
const valueType = getColumnType(filterKey);
if (!dbColumn) {
console.warn(`Invalid filter key ignored: ${key}`);
continue; // Skip if the key doesn't map to a known column
}
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
// --- Build WHERE clause fragment ---
try {
let conditionFragment = '';
@@ -234,6 +466,10 @@ router.get('/', async (req, res) => {
// --- Construct and Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Debug log of conditions and parameters
console.log('Constructed WHERE conditions:', conditions);
console.log('Parameters:', params);
// Count Query
const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`;
console.log('Executing Count Query:', countSql, params);
@@ -244,11 +480,20 @@ router.get('/', async (req, res) => {
SELECT pm.*
FROM public.product_metrics pm
${whereClause}
ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}
ORDER BY ${orderByClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
console.log('Executing Data Query:', dataSql, dataParams);
// Log detailed query information for debugging
console.log('Executing Data Query:');
console.log(' - Sort Column:', dbColumn);
console.log(' - Column Type:', columnType);
console.log(' - Sort Direction:', sortDirection);
console.log(' - Order By Clause:', orderByClause);
console.log(' - Full SQL:', dataSql);
console.log(' - Parameters:', dataParams);
const dataPromise = pool.query(dataSql, dataParams);
// Execute queries in parallel
-160
View File
@@ -23,10 +23,7 @@ router.get('/brands', async (req, res) => {
const { rows } = await pool.query(`
SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand
FROM products p
JOIN purchase_orders po ON p.pid = po.pid
WHERE p.visible = true
GROUP BY COALESCE(p.brand, 'Unbranded')
HAVING SUM(po.cost_price * po.received) >= 500
ORDER BY COALESCE(p.brand, 'Unbranded')
`);
@@ -629,163 +626,6 @@ router.get('/:id', async (req, res) => {
}
});
// Import products from CSV
router.post('/import', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
try {
const result = await importProductsFromCSV(req.file.path, req.app.locals.pool);
// Clean up the uploaded file
require('fs').unlinkSync(req.file.path);
res.json(result);
} catch (error) {
console.error('Error importing products:', error);
res.status(500).json({ error: 'Failed to import products' });
}
});
// Update a product
router.put('/:id', async (req, res) => {
const pool = req.app.locals.pool;
try {
const {
title,
sku,
stock_quantity,
price,
regular_price,
cost_price,
vendor,
brand,
categories,
visible,
managing_stock
} = req.body;
const { rowCount } = await pool.query(
`UPDATE products
SET title = $1,
sku = $2,
stock_quantity = $3,
price = $4,
regular_price = $5,
cost_price = $6,
vendor = $7,
brand = $8,
categories = $9,
visible = $10,
managing_stock = $11
WHERE pid = $12`,
[
title,
sku,
stock_quantity,
price,
regular_price,
cost_price,
vendor,
brand,
categories,
visible,
managing_stock,
req.params.id
]
);
if (rowCount === 0) {
return res.status(404).json({ error: 'Product not found' });
}
res.json({ message: 'Product updated successfully' });
} catch (error) {
console.error('Error updating product:', error);
res.status(500).json({ error: 'Failed to update product' });
}
});
// Get product metrics
router.get('/:id/metrics', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { id } = req.params;
// Get metrics from product_metrics table with inventory health data
const { rows: metrics } = await pool.query(`
WITH inventory_status AS (
SELECT
p.pid,
CASE
WHEN pm.daily_sales_avg = 0 THEN 'New'
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 7) THEN 'Critical'
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 14) THEN 'Reorder'
WHEN p.stock_quantity > (pm.daily_sales_avg * 90) THEN 'Overstocked'
ELSE 'Healthy'
END as calculated_status
FROM products p
LEFT JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.pid = $1
)
SELECT
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg,
COALESCE(pm.monthly_sales_avg, 0) as monthly_sales_avg,
COALESCE(pm.days_of_inventory, 0) as days_of_inventory,
COALESCE(pm.reorder_point, CEIL(COALESCE(pm.daily_sales_avg, 0) * 14)) as reorder_point,
COALESCE(pm.safety_stock, CEIL(COALESCE(pm.daily_sales_avg, 0) * 7)) as safety_stock,
COALESCE(pm.avg_margin_percent,
((p.price - COALESCE(p.cost_price, 0)) / NULLIF(p.price, 0)) * 100
) as avg_margin_percent,
COALESCE(pm.total_revenue, 0) as total_revenue,
COALESCE(pm.inventory_value, p.stock_quantity * COALESCE(p.cost_price, 0)) as inventory_value,
COALESCE(pm.turnover_rate, 0) as turnover_rate,
COALESCE(pm.abc_class, 'C') as abc_class,
COALESCE(pm.stock_status, is.calculated_status) as stock_status,
COALESCE(pm.avg_lead_time_days, 0) as avg_lead_time_days,
COALESCE(pm.current_lead_time, 0) as current_lead_time,
COALESCE(pm.target_lead_time, 14) as target_lead_time,
COALESCE(pm.lead_time_status, 'Unknown') as lead_time_status,
COALESCE(pm.reorder_qty, 0) as reorder_qty,
COALESCE(pm.overstocked_amt, 0) as overstocked_amt
FROM products p
LEFT JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN inventory_status is ON p.pid = is.pid
WHERE p.pid = $2
`, [id, id]);
if (!metrics.length) {
// Return default metrics structure if no data found
res.json({
daily_sales_avg: 0,
weekly_sales_avg: 0,
monthly_sales_avg: 0,
days_of_inventory: 0,
reorder_point: 0,
safety_stock: 0,
avg_margin_percent: 0,
total_revenue: 0,
inventory_value: 0,
turnover_rate: 0,
abc_class: 'C',
stock_status: 'New',
avg_lead_time_days: 0,
current_lead_time: 0,
target_lead_time: 14,
lead_time_status: 'Unknown',
reorder_qty: 0,
overstocked_amt: 0
});
return;
}
res.json(metrics[0]);
} catch (error) {
console.error('Error fetching product metrics:', error);
res.status(500).json({ error: 'Failed to fetch product metrics' });
}
});
// Get product time series data
router.get('/:id/time-series', async (req, res) => {
const { id } = req.params;
File diff suppressed because it is too large Load Diff
-108
View File
@@ -1,108 +0,0 @@
const express = require('express');
const router = express.Router();
// Get vendors with pagination, filtering, and sorting
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
try {
// Get all vendors with metrics
const { rows: vendors } = await pool.query(`
SELECT DISTINCT
p.vendor as name,
COALESCE(vm.active_products, 0) as active_products,
COALESCE(vm.total_orders, 0) as total_orders,
COALESCE(vm.avg_lead_time_days, 0) as avg_lead_time_days,
COALESCE(vm.on_time_delivery_rate, 0) as on_time_delivery_rate,
COALESCE(vm.order_fill_rate, 0) as order_fill_rate,
CASE
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 THEN 'active'
WHEN COALESCE(vm.total_orders, 0) > 0 THEN 'inactive'
ELSE 'pending'
END as status
FROM products p
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
WHERE p.vendor IS NOT NULL AND p.vendor != ''
`);
// Get cost metrics for all vendors
const vendorNames = vendors.map(v => v.name);
const { rows: costMetrics } = await pool.query(`
SELECT
vendor,
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders
WHERE status = 2
AND cost_price IS NOT NULL
AND ordered > 0
AND vendor = ANY($1)
GROUP BY vendor
`, [vendorNames]);
// Create a map of cost metrics by vendor
const costMetricsMap = costMetrics.reduce((acc, curr) => {
acc[curr.vendor] = {
avg_unit_cost: curr.avg_unit_cost,
total_spend: curr.total_spend
};
return acc;
}, {});
// Get overall stats
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(DISTINCT p.vendor) as totalVendors,
COUNT(DISTINCT CASE
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
THEN p.vendor
END) as activeVendors,
COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0))::numeric, 1), 0) as avgLeadTime,
COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0))::numeric, 1), 0) as avgFillRate,
COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0))::numeric, 1), 0) as avgOnTimeDelivery
FROM products p
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
WHERE p.vendor IS NOT NULL AND p.vendor != ''
`);
// Get overall cost metrics
const { rows: [overallCostMetrics] } = await pool.query(`
SELECT
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders
WHERE status = 2
AND cost_price IS NOT NULL
AND ordered > 0
AND vendor IS NOT NULL AND vendor != ''
`);
res.json({
vendors: vendors.map(vendor => ({
vendor_id: vendor.name,
name: vendor.name,
status: vendor.status,
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days),
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate),
order_fill_rate: parseFloat(vendor.order_fill_rate),
total_orders: parseInt(vendor.total_orders),
active_products: parseInt(vendor.active_products),
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
})),
stats: {
totalVendors: parseInt(stats.totalvendors),
activeVendors: parseInt(stats.activevendors),
avgLeadTime: parseFloat(stats.avgleadtime),
avgFillRate: parseFloat(stats.avgfillrate),
avgOnTimeDelivery: parseFloat(stats.avgontimedelivery),
avgUnitCost: parseFloat(overallCostMetrics.avg_unit_cost),
totalSpend: parseFloat(overallCostMetrics.total_spend)
}
});
} catch (error) {
console.error('Error fetching vendors:', error);
res.status(500).json({ error: 'Failed to fetch vendors' });
}
});
module.exports = router;
@@ -0,0 +1,323 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
// Maps query keys to DB columns in vendor_metrics
const COLUMN_MAP = {
vendorName: { dbCol: 'vm.vendor_name', type: 'string' },
productCount: { dbCol: 'vm.product_count', type: 'number' },
activeProductCount: { dbCol: 'vm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'vm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'vm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'vm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'vm.current_stock_retail', type: 'number' },
onOrderUnits: { dbCol: 'vm.on_order_units', type: 'number' },
onOrderCost: { dbCol: 'vm.on_order_cost', type: 'number' },
poCount365d: { dbCol: 'vm.po_count_365d', type: 'number' },
avgLeadTimeDays: { dbCol: 'vm.avg_lead_time_days', type: 'number' },
sales7d: { dbCol: 'vm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'vm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'vm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'vm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'vm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'vm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'vm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'vm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'vm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'vm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'vm.avg_margin_30d', type: 'number' },
// Growth metrics
salesGrowth30dVsPrev: { dbCol: 'vm.sales_growth_30d_vs_prev', type: 'number' },
revenueGrowth30dVsPrev: { dbCol: 'vm.revenue_growth_30d_vs_prev', type: 'number' },
// Add aliases if needed for frontend compatibility
name: { dbCol: 'vm.vendor_name', type: 'string' },
leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' },
// Add status for filtering
status: { dbCol: 'vendor_status', type: 'string' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// --- Route Handlers ---
// GET /vendors-aggregate/filter-options (Just vendors list for now)
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate/filter-options');
try {
// Get vendor names
const { rows: vendorRows } = await pool.query(`
SELECT DISTINCT vendor_name FROM public.vendor_metrics ORDER BY vendor_name
`);
// Get status values - calculate them since they're derived
const { rows: statusRows } = await pool.query(`
SELECT DISTINCT
CASE
WHEN po_count_365d > 0 AND sales_30d > 0 THEN 'active'
WHEN po_count_365d > 0 THEN 'inactive'
ELSE 'pending'
END as status
FROM public.vendor_metrics
ORDER BY status
`);
res.json({
vendors: vendorRows.map(r => r.vendor_name),
statuses: statusRows.map(r => r.status)
});
} catch(error) {
console.error('Error fetching vendor filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /vendors-aggregate/stats (Overall vendor stats)
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate/stats');
try {
// Get basic vendor stats from aggregate table
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_vendors,
SUM(active_product_count) AS total_active_products,
SUM(current_stock_cost) AS total_stock_value,
SUM(on_order_cost) AS total_on_order_value,
AVG(NULLIF(avg_lead_time_days, 0)) AS overall_avg_lead_time
FROM public.vendor_metrics vm
`);
// Count active vendors based on criteria (from old vendors.js)
const { rows: [activeStats] } = await pool.query(`
SELECT
COUNT(DISTINCT CASE
WHEN po_count_365d > 0
THEN vendor_name
END) as active_vendors
FROM public.vendor_metrics
`);
// Get overall cost metrics from purchase orders
const { rows: [overallCostMetrics] } = await pool.query(`
SELECT
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders
WHERE cost_price IS NOT NULL
AND ordered > 0
AND vendor IS NOT NULL AND vendor != ''
`);
res.json({
totalVendors: parseInt(stats?.total_vendors || 0),
activeVendors: parseInt(activeStats?.active_vendors || 0),
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
totalOnOrderValue: parseFloat(stats?.total_on_order_value || 0),
avgLeadTime: parseFloat(stats?.overall_avg_lead_time || 0),
avgUnitCost: parseFloat(overallCostMetrics?.avg_unit_cost || 0),
totalSpend: parseFloat(overallCostMetrics?.total_spend || 0)
});
} catch (error) {
console.error('Error fetching vendor stats:', error);
res.status(500).json({ error: 'Failed to fetch vendor stats.' });
}
});
// GET /vendors-aggregate/ (List vendors)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'vendorName'; // Default sort
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'vm.vendor_name';
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
// Build conditions based on req.query, using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
}
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) { // Normalize operator
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
} else if (!conditionFragment) { // For LIKE/ILIKE
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
if (needsParam) paramCounter--;
}
} else {
console.warn(`Invalid filter key ignored: ${key}`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Status calculation from vendors.js
const statusCase = `
CASE
WHEN po_count_365d > 0 AND sales_30d > 0 THEN 'active'
WHEN po_count_365d > 0 THEN 'inactive'
ELSE 'pending'
END as vendor_status
`;
const baseSql = `
FROM (
SELECT
vm.*,
${statusCase}
FROM public.vendor_metrics vm
) vm
${whereClause}
`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `
WITH vendor_data AS (
SELECT
vm.*,
${statusCase}
FROM public.vendor_metrics vm
)
SELECT
vm.*,
COALESCE(po.avg_unit_cost, 0) as avg_unit_cost,
COALESCE(po.total_spend, 0) as total_spend
FROM vendor_data vm
LEFT JOIN (
SELECT
vendor,
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM purchase_orders
WHERE cost_price IS NOT NULL AND ordered > 0
GROUP BY vendor
) po ON vm.vendor_name = po.vendor
${whereClause}
${sortClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const vendors = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({
vendors,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching vendor metrics list:', error);
res.status(500).json({ error: 'Failed to fetch vendor metrics.' });
}
});
// GET /vendors-aggregate/:name (Get single vendor metric)
// Implement if needed, remember to URL-decode the name parameter
module.exports = router;
+11 -5
View File
@@ -8,18 +8,19 @@ const { initPool } = require('./utils/db');
const productsRouter = require('./routes/products');
const dashboardRouter = require('./routes/dashboard');
const ordersRouter = require('./routes/orders');
const csvRouter = require('./routes/csv');
const csvRouter = require('./routes/data-management');
const analyticsRouter = require('./routes/analytics');
const purchaseOrdersRouter = require('./routes/purchase-orders');
const configRouter = require('./routes/config');
const metricsRouter = require('./routes/metrics');
const vendorsRouter = require('./routes/vendors');
const categoriesRouter = require('./routes/categories');
const importRouter = require('./routes/import');
const aiValidationRouter = require('./routes/ai-validation');
const templatesRouter = require('./routes/templates');
const aiPromptsRouter = require('./routes/ai-prompts');
const reusableImagesRouter = require('./routes/reusable-images');
const categoriesAggregateRouter = require('./routes/categoriesAggregate');
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate');
// Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env';
@@ -100,8 +101,13 @@ async function startServer() {
app.use('/api/purchase-orders', purchaseOrdersRouter);
app.use('/api/config', configRouter);
app.use('/api/metrics', metricsRouter);
app.use('/api/vendors', vendorsRouter);
app.use('/api/categories', categoriesRouter);
// Use only the aggregate routes for vendors and categories
app.use('/api/vendors', vendorsAggregateRouter);
app.use('/api/categories', categoriesAggregateRouter);
// Keep the aggregate-specific endpoints for backward compatibility
app.use('/api/categories-aggregate', categoriesAggregateRouter);
app.use('/api/vendors-aggregate', vendorsAggregateRouter);
app.use('/api/brands-aggregate', brandsAggregateRouter);
app.use('/api/import', importRouter);
app.use('/api/ai-validation', aiValidationRouter);
app.use('/api/templates', templatesRouter);
+45
View File
@@ -0,0 +1,45 @@
/**
* Parses a query parameter value based on its expected type.
* Throws error for invalid formats. Adjust date handling as needed.
*/
function parseValue(value, type) {
if (value === null || value === undefined || value === '') return null;
console.log(`Parsing value: "${value}" as type: "${type}"`);
switch (type) {
case 'number':
const num = parseFloat(value);
if (isNaN(num)) {
console.error(`Invalid number format: "${value}"`);
throw new Error(`Invalid number format: "${value}"`);
}
return num;
case 'integer': // Specific type for integer IDs etc.
const int = parseInt(value, 10);
if (isNaN(int)) {
console.error(`Invalid integer format: "${value}"`);
throw new Error(`Invalid integer format: "${value}"`);
}
console.log(`Successfully parsed integer: ${int}`);
return int;
case 'boolean':
if (String(value).toLowerCase() === 'true') return true;
if (String(value).toLowerCase() === 'false') return false;
console.error(`Invalid boolean format: "${value}"`);
throw new Error(`Invalid boolean format: "${value}"`);
case 'date':
// Basic ISO date format validation (YYYY-MM-DD)
if (!String(value).match(/^\d{4}-\d{2}-\d{2}$/)) {
console.warn(`Potentially invalid date format passed: "${value}"`);
// Optionally throw an error or return null depending on strictness
// throw new Error(`Invalid date format (YYYY-MM-DD expected): "${value}"`);
}
return String(value); // Send as string, let DB handle casting/comparison
case 'string':
default:
return String(value);
}
}
module.exports = { parseValue };
+239
View File
@@ -0,0 +1,239 @@
const { Client } = require('ssh2');
const mysql = require('mysql2/promise');
const fs = require('fs');
// Connection pooling and cache configuration
const connectionCache = {
ssh: null,
dbConnection: null,
lastUsed: 0,
isConnecting: false,
connectionPromise: null,
// Cache expiration time in milliseconds (5 minutes)
expirationTime: 5 * 60 * 1000,
// Cache for query results (key: query string, value: {data, timestamp})
queryCache: new Map(),
// Cache duration for different query types in milliseconds
cacheDuration: {
'field-options': 30 * 60 * 1000, // 30 minutes for field options
'product-lines': 10 * 60 * 1000, // 10 minutes for product lines
'sublines': 10 * 60 * 1000, // 10 minutes for sublines
'taxonomy': 30 * 60 * 1000, // 30 minutes for taxonomy data
'default': 60 * 1000 // 1 minute default
}
};
/**
* Get a database connection with connection pooling
* @returns {Promise<{ssh: object, connection: object}>} The SSH and database connection
*/
async function getDbConnection() {
const now = Date.now();
// Check if we need to refresh the connection due to inactivity
const needsRefresh = !connectionCache.ssh ||
!connectionCache.dbConnection ||
(now - connectionCache.lastUsed > connectionCache.expirationTime);
// If connection is still valid, update last used time and return existing connection
if (!needsRefresh) {
connectionCache.lastUsed = now;
return {
ssh: connectionCache.ssh,
connection: connectionCache.dbConnection
};
}
// If another request is already establishing a connection, wait for that promise
if (connectionCache.isConnecting && connectionCache.connectionPromise) {
try {
await connectionCache.connectionPromise;
return {
ssh: connectionCache.ssh,
connection: connectionCache.dbConnection
};
} catch (error) {
// If that connection attempt failed, we'll try again below
console.error('Error waiting for existing connection:', error);
}
}
// Close existing connections if they exist
if (connectionCache.dbConnection) {
try {
await connectionCache.dbConnection.end();
} catch (error) {
console.error('Error closing existing database connection:', error);
}
}
if (connectionCache.ssh) {
try {
connectionCache.ssh.end();
} catch (error) {
console.error('Error closing existing SSH connection:', error);
}
}
// Mark that we're establishing a new connection
connectionCache.isConnecting = true;
// Create a new promise for this connection attempt
connectionCache.connectionPromise = setupSshTunnel().then(tunnel => {
const { ssh, stream, dbConfig } = tunnel;
return mysql.createConnection({
...dbConfig,
stream
}).then(connection => {
// Store the new connections
connectionCache.ssh = ssh;
connectionCache.dbConnection = connection;
connectionCache.lastUsed = Date.now();
connectionCache.isConnecting = false;
return {
ssh,
connection
};
});
}).catch(error => {
connectionCache.isConnecting = false;
throw error;
});
// Wait for the connection to be established
return connectionCache.connectionPromise;
}
/**
* Get cached query results or execute query if not cached
* @param {string} cacheKey - Unique key to identify the query
* @param {string} queryType - Type of query (field-options, product-lines, etc.)
* @param {Function} queryFn - Function to execute if cache miss
* @returns {Promise<any>} The query result
*/
async function getCachedQuery(cacheKey, queryType, queryFn) {
// Get cache duration based on query type
const cacheDuration = connectionCache.cacheDuration[queryType] || connectionCache.cacheDuration.default;
// Check if we have a valid cached result
const cachedResult = connectionCache.queryCache.get(cacheKey);
const now = Date.now();
if (cachedResult && (now - cachedResult.timestamp < cacheDuration)) {
console.log(`Cache hit for ${queryType} query: ${cacheKey}`);
return cachedResult.data;
}
// No valid cache found, execute the query
console.log(`Cache miss for ${queryType} query: ${cacheKey}`);
const result = await queryFn();
// Cache the result
connectionCache.queryCache.set(cacheKey, {
data: result,
timestamp: now
});
return result;
}
/**
* Setup SSH tunnel to production database
* @private - Should only be used by getDbConnection
* @returns {Promise<{ssh: object, stream: object, dbConfig: object}>}
*/
async function setupSshTunnel() {
const sshConfig = {
host: process.env.PROD_SSH_HOST,
port: process.env.PROD_SSH_PORT || 22,
username: process.env.PROD_SSH_USER,
privateKey: process.env.PROD_SSH_KEY_PATH
? fs.readFileSync(process.env.PROD_SSH_KEY_PATH)
: undefined,
compress: true
};
const dbConfig = {
host: process.env.PROD_DB_HOST || 'localhost',
user: process.env.PROD_DB_USER,
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
timezone: 'Z'
};
return new Promise((resolve, reject) => {
const ssh = new Client();
ssh.on('error', (err) => {
console.error('SSH connection error:', err);
reject(err);
});
ssh.on('ready', () => {
ssh.forwardOut(
'127.0.0.1',
0,
dbConfig.host,
dbConfig.port,
(err, stream) => {
if (err) reject(err);
resolve({ ssh, stream, dbConfig });
}
);
}).connect(sshConfig);
});
}
/**
* Clear cached query results
* @param {string} [cacheKey] - Specific cache key to clear (clears all if not provided)
*/
function clearQueryCache(cacheKey) {
if (cacheKey) {
connectionCache.queryCache.delete(cacheKey);
console.log(`Cleared cache for key: ${cacheKey}`);
} else {
connectionCache.queryCache.clear();
console.log('Cleared all query cache');
}
}
/**
* Force close all active connections
* Useful for server shutdown or manual connection reset
*/
async function closeAllConnections() {
if (connectionCache.dbConnection) {
try {
await connectionCache.dbConnection.end();
console.log('Closed database connection');
} catch (error) {
console.error('Error closing database connection:', error);
}
connectionCache.dbConnection = null;
}
if (connectionCache.ssh) {
try {
connectionCache.ssh.end();
console.log('Closed SSH connection');
} catch (error) {
console.error('Error closing SSH connection:', error);
}
connectionCache.ssh = null;
}
connectionCache.lastUsed = 0;
connectionCache.isConnecting = false;
connectionCache.connectionPromise = null;
}
module.exports = {
getDbConnection,
getCachedQuery,
clearQueryCache,
closeAllConnections
};
+72 -1
View File
@@ -52,15 +52,18 @@
"date-fns": "^3.6.0",
"diff": "^7.0.0",
"framer-motion": "^12.4.4",
"input-otp": "^1.4.1",
"js-levenshtein": "^1.1.6",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"motion": "^11.18.0",
"next-themes": "^0.4.4",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1",
"react-debounce-input": "^3.3.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",
@@ -74,7 +77,8 @@
"uuid": "^11.0.5",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^3.24.2"
"zod": "^3.24.2",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
@@ -5731,6 +5735,16 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
"integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -6043,6 +6057,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6097,6 +6117,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6919,6 +6948,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-debounce-input": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-debounce-input/-/react-debounce-input-3.3.0.tgz",
"integrity": "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^15.3.0 || 16 || 17 || 18"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -8296,6 +8338,35 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz",
"integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+5 -1
View File
@@ -54,15 +54,18 @@
"date-fns": "^3.6.0",
"diff": "^7.0.0",
"framer-motion": "^12.4.4",
"input-otp": "^1.4.1",
"js-levenshtein": "^1.1.6",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"luxon": "^3.5.0",
"motion": "^11.18.0",
"next-themes": "^0.4.4",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1",
"react-debounce-input": "^3.3.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",
@@ -76,7 +79,8 @@
"uuid": "^11.0.5",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^3.24.2"
"zod": "^3.24.2",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
+98 -26
View File
@@ -1,23 +1,40 @@
import { Routes, Route, useNavigate, Navigate, useLocation } from 'react-router-dom';
import { MainLayout } from './components/layout/MainLayout';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Products } from './pages/Products';
import { Dashboard } from './pages/Dashboard';
import { Settings } from './pages/Settings';
import { Analytics } from './pages/Analytics';
import { Toaster } from '@/components/ui/sonner';
import PurchaseOrders from './pages/PurchaseOrders';
import { Login } from './pages/Login';
import { useEffect } from 'react';
import { useEffect, Suspense, lazy } from 'react';
import config from './config';
import { RequireAuth } from './components/auth/RequireAuth';
import Forecasting from "@/pages/Forecasting";
import { Vendors } from '@/pages/Vendors';
import { Categories } from '@/pages/Categories';
import { Import } from '@/pages/Import';
import { AuthProvider } from './contexts/AuthContext';
import { Protected } from './components/auth/Protected';
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
import { PageLoading } from '@/components/ui/page-loading';
// Always loaded components (Login and Settings stay in main bundle)
import { Settings } from './pages/Settings';
import { Login } from './pages/Login';
// Lazy load the 4 main chunks you wanted:
// 1. Core inventory app - loaded as one chunk when any inventory page is accessed
const Overview = lazy(() => import('./pages/Overview'));
const Products = lazy(() => import('./pages/Products').then(module => ({ default: module.Products })));
const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
const Forecasting = lazy(() => import('./pages/Forecasting'));
const Vendors = lazy(() => import('./pages/Vendors'));
const Categories = lazy(() => import('./pages/Categories'));
const Brands = lazy(() => import('./pages/Brands'));
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
// 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
// 3. Product import - separate chunk
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
// 4. Chat archive - separate chunk
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
const queryClient = new QueryClient();
@@ -72,62 +89,117 @@ function App() {
<AuthProvider>
<Toaster richColors position="top-center" />
<Routes>
{/* Always loaded routes */}
<Route path="/login" element={<Login />} />
<Route path="/small" element={
<Suspense fallback={<PageLoading />}>
<SmallDashboard />
</Suspense>
} />
<Route element={
<RequireAuth>
<MainLayout />
</RequireAuth>
}>
{/* Core inventory app routes - will be lazy loaded */}
<Route index element={
<Protected page="dashboard" fallback={<FirstAccessiblePage />}>
<Dashboard />
<Suspense fallback={<PageLoading />}>
<Overview />
</Suspense>
</Protected>
} />
<Route path="/" element={
<Protected page="dashboard">
<Dashboard />
<Suspense fallback={<PageLoading />}>
<Overview />
</Suspense>
</Protected>
} />
<Route path="/products" element={
<Protected page="products">
<Products />
</Protected>
} />
<Route path="/import" element={
<Protected page="import">
<Import />
<Suspense fallback={<PageLoading />}>
<Products />
</Suspense>
</Protected>
} />
<Route path="/categories" element={
<Protected page="categories">
<Categories />
<Suspense fallback={<PageLoading />}>
<Categories />
</Suspense>
</Protected>
} />
<Route path="/vendors" element={
<Protected page="vendors">
<Vendors />
<Suspense fallback={<PageLoading />}>
<Vendors />
</Suspense>
</Protected>
} />
<Route path="/brands" element={
<Protected page="brands">
<Suspense fallback={<PageLoading />}>
<Brands />
</Suspense>
</Protected>
} />
<Route path="/purchase-orders" element={
<Protected page="purchase_orders">
<PurchaseOrders />
<Suspense fallback={<PageLoading />}>
<PurchaseOrders />
</Suspense>
</Protected>
} />
<Route path="/analytics" element={
<Protected page="analytics">
<Analytics />
<Suspense fallback={<PageLoading />}>
<Analytics />
</Suspense>
</Protected>
} />
<Route path="/forecasting" element={
<Protected page="forecasting">
<Suspense fallback={<PageLoading />}>
<Forecasting />
</Suspense>
</Protected>
} />
{/* Always loaded settings */}
<Route path="/settings" element={
<Protected page="settings">
<Settings />
</Protected>
} />
<Route path="/forecasting" element={
<Protected page="forecasting">
<Forecasting />
{/* Product import - separate chunk */}
<Route path="/import" element={
<Protected page="import">
<Suspense fallback={<PageLoading />}>
<Import />
</Suspense>
</Protected>
} />
{/* Chat archive - separate chunk */}
<Route path="/chat" element={
<Protected page="chat">
<Suspense fallback={<PageLoading />}>
<Chat />
</Suspense>
</Protected>
} />
{/* Dashboard app - separate chunk */}
<Route path="/dashboard" element={
<Protected page="dashboard">
<Suspense fallback={<PageLoading />}>
<Dashboard />
</Suspense>
</Protected>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
@@ -38,21 +38,22 @@ export function CategoryPerformance() {
const rawData = await response.json();
return {
performance: rawData.performance.map((item: any) => ({
...item,
categoryPath: item.categoryPath || item.category,
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
revenue: Number(item.revenue) || 0,
profit: Number(item.profit) || 0,
growth: Number(item.growth) || 0,
productCount: Number(item.productCount) || 0
productCount: Number(item.productCount) || Number(item.productcount) || 0
})),
distribution: rawData.distribution.map((item: any) => ({
...item,
categoryPath: item.categoryPath || item.category,
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
value: Number(item.value) || 0
})),
trends: rawData.trends.map((item: any) => ({
...item,
categoryPath: item.categoryPath || item.category,
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
month: item.month || '',
sales: Number(item.sales) || 0
}))
};
@@ -25,41 +25,91 @@ interface PriceData {
}
export function PriceAnalysis() {
const { data, isLoading } = useQuery<PriceData>({
const { data, isLoading, error } = useQuery<PriceData>({
queryKey: ['price-analysis'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
if (!response.ok) {
throw new Error('Failed to fetch price analysis');
try {
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.pricePoints) {
return {
pricePoints: [],
elasticity: [],
recommendations: []
};
}
return {
pricePoints: (rawData.pricePoints || []).map((item: any) => ({
price: Number(item.price) || 0,
salesVolume: Number(item.salesVolume || item.salesvolume) || 0,
revenue: Number(item.revenue) || 0,
category: item.category || ''
})),
elasticity: (rawData.elasticity || []).map((item: any) => ({
date: item.date || '',
price: Number(item.price) || 0,
demand: Number(item.demand) || 0
})),
recommendations: (rawData.recommendations || []).map((item: any) => ({
product: item.product || '',
currentPrice: Number(item.currentPrice || item.currentprice) || 0,
recommendedPrice: Number(item.recommendedPrice || item.recommendedprice) || 0,
potentialRevenue: Number(item.potentialRevenue || item.potentialrevenue) || 0,
confidence: Number(item.confidence) || 0
}))
};
} catch (err) {
console.error('Error fetching price data:', err);
throw err;
}
const rawData = await response.json();
return {
pricePoints: rawData.pricePoints.map((item: any) => ({
...item,
price: Number(item.price) || 0,
salesVolume: Number(item.salesVolume) || 0,
revenue: Number(item.revenue) || 0
})),
elasticity: rawData.elasticity.map((item: any) => ({
...item,
price: Number(item.price) || 0,
demand: Number(item.demand) || 0
})),
recommendations: rawData.recommendations.map((item: any) => ({
...item,
currentPrice: Number(item.currentPrice) || 0,
recommendedPrice: Number(item.recommendedPrice) || 0,
potentialRevenue: Number(item.potentialRevenue) || 0,
confidence: Number(item.confidence) || 0
}))
};
},
retry: 1
});
if (isLoading || !data) {
if (isLoading) {
return <div>Loading price analysis...</div>;
}
if (error || !data) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Price Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">
Unable to load price analysis. The price metrics may need to be set up in the database.
</p>
</CardContent>
</Card>
);
}
// Early return if no data to display
if (
data.pricePoints.length === 0 &&
data.elasticity.length === 0 &&
data.recommendations.length === 0
) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Price Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
No price data available. This may be because the price metrics haven't been calculated yet.
</p>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
@@ -38,22 +38,23 @@ export function ProfitAnalysis() {
const rawData = await response.json();
return {
byCategory: rawData.byCategory.map((item: any) => ({
...item,
categoryPath: item.categoryPath || item.category,
profitMargin: Number(item.profitMargin) || 0,
category: item.category || '',
categoryPath: item.categorypath || item.category || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
})),
overTime: rawData.overTime.map((item: any) => ({
...item,
profitMargin: Number(item.profitMargin) || 0,
date: item.date || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
})),
topProducts: rawData.topProducts.map((item: any) => ({
...item,
categoryPath: item.categoryPath || item.category,
profitMargin: Number(item.profitMargin) || 0,
product: item.product || '',
category: item.category || '',
categoryPath: item.categorypath || item.category || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
}))
@@ -28,42 +28,93 @@ interface StockData {
}
export function StockAnalysis() {
const { data, isLoading } = useQuery<StockData>({
const { data, isLoading, error } = useQuery<StockData>({
queryKey: ['stock-analysis'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stock`);
if (!response.ok) {
throw new Error('Failed to fetch stock analysis');
try {
const response = await fetch(`${config.apiUrl}/analytics/stock`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.turnoverByCategory) {
return {
turnoverByCategory: [],
stockLevels: [],
criticalItems: []
};
}
return {
turnoverByCategory: (rawData.turnoverByCategory || []).map((item: any) => ({
category: item.category || '',
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
averageStock: Number(item.averageStock || item.averagestock) || 0,
totalSales: Number(item.totalSales || item.totalsales) || 0
})),
stockLevels: (rawData.stockLevels || []).map((item: any) => ({
date: item.date || '',
inStock: Number(item.inStock || item.instock) || 0,
lowStock: Number(item.lowStock || item.lowstock) || 0,
outOfStock: Number(item.outOfStock || item.outofstock) || 0
})),
criticalItems: (rawData.criticalItems || []).map((item: any) => ({
product: item.product || '',
sku: item.sku || '',
stockQuantity: Number(item.stockQuantity || item.stockquantity) || 0,
reorderPoint: Number(item.reorderPoint || item.reorderpoint) || 0,
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
daysUntilStockout: Number(item.daysUntilStockout || item.daysuntilstockout) || 0
}))
};
} catch (err) {
console.error('Error fetching stock data:', err);
throw err;
}
const rawData = await response.json();
return {
turnoverByCategory: rawData.turnoverByCategory.map((item: any) => ({
...item,
turnoverRate: Number(item.turnoverRate) || 0,
averageStock: Number(item.averageStock) || 0,
totalSales: Number(item.totalSales) || 0
})),
stockLevels: rawData.stockLevels.map((item: any) => ({
...item,
inStock: Number(item.inStock) || 0,
lowStock: Number(item.lowStock) || 0,
outOfStock: Number(item.outOfStock) || 0
})),
criticalItems: rawData.criticalItems.map((item: any) => ({
...item,
stockQuantity: Number(item.stockQuantity) || 0,
reorderPoint: Number(item.reorderPoint) || 0,
turnoverRate: Number(item.turnoverRate) || 0,
daysUntilStockout: Number(item.daysUntilStockout) || 0
}))
};
},
retry: 1
});
if (isLoading || !data) {
if (isLoading) {
return <div>Loading stock analysis...</div>;
}
if (error || !data) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Stock Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">
Unable to load stock analysis. The stock metrics may need to be set up in the database.
</p>
</CardContent>
</Card>
);
}
// Early return if no data to display
if (
data.turnoverByCategory.length === 0 &&
data.stockLevels.length === 0 &&
data.criticalItems.length === 0
) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Stock Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
No stock data available. This may be because the stock metrics haven't been calculated yet.
</p>
</CardContent>
</Card>
);
}
const getStockStatus = (daysUntilStockout: number) => {
if (daysUntilStockout <= 7) {
return <Badge variant="destructive">Critical</Badge>;
@@ -58,22 +58,22 @@ export function VendorPerformance() {
// Create a complete structure even if some parts are missing
const data: VendorData = {
performance: rawData.performance.map((vendor: any) => ({
vendor: vendor.vendor,
salesVolume: Number(vendor.salesVolume) || 0,
profitMargin: Number(vendor.profitMargin) || 0,
stockTurnover: Number(vendor.stockTurnover) || 0,
vendor: vendor.vendor || '',
salesVolume: vendor.salesVolume !== null ? Number(vendor.salesVolume) : 0,
profitMargin: vendor.profitMargin !== null ? Number(vendor.profitMargin) : 0,
stockTurnover: vendor.stockTurnover !== null ? Number(vendor.stockTurnover) : 0,
productCount: Number(vendor.productCount) || 0,
growth: Number(vendor.growth) || 0
growth: vendor.growth !== null ? Number(vendor.growth) : 0
})),
comparison: rawData.comparison?.map((vendor: any) => ({
vendor: vendor.vendor,
salesPerProduct: Number(vendor.salesPerProduct) || 0,
averageMargin: Number(vendor.averageMargin) || 0,
vendor: vendor.vendor || '',
salesPerProduct: vendor.salesPerProduct !== null ? Number(vendor.salesPerProduct) : 0,
averageMargin: vendor.averageMargin !== null ? Number(vendor.averageMargin) : 0,
size: Number(vendor.size) || 0
})) || [],
trends: rawData.trends?.map((vendor: any) => ({
vendor: vendor.vendor,
month: vendor.month,
vendor: vendor.vendor || '',
month: vendor.month || '',
sales: Number(vendor.sales) || 0
})) || []
};
+557
View File
@@ -0,0 +1,557 @@
import { useState, useEffect, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Loader2, Hash, MessageSquare, ChevronUp, Search, ExternalLink, FileText, Image, Download, MessageCircle, Users2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import config from '@/config';
import { convertEmojiShortcodes } from '@/utils/emojiUtils';
interface Message {
id: number;
msg: string;
ts: string;
u: {
_id: string;
username: string;
name?: string;
};
_updatedat: string;
urls?: any[];
mentions?: any[];
md?: any[];
attachments?: {
id: number;
mongo_id: string;
name: string;
size: number;
type: string;
url: string;
path: string;
typegroup: string;
identify?: {
size?: { width: number; height: number };
format?: string;
};
}[];
}
interface Room {
id: number;
name: string;
fname: string;
type: string;
msgs: number;
last_message_date: string;
display_name: string;
description?: string;
teamid?: string;
participants?: { username: string; name: string }[];
}
interface ChatRoomProps {
roomId: string;
selectedUserId: string;
}
export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
const [room, setRoom] = useState<Room | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [showSearch, setShowSearch] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const fetchRoom = async () => {
try {
const response = await fetch(`${config.chatUrl}/rooms/${roomId}?userId=${selectedUserId}`);
const data = await response.json();
if (data.status === 'success') {
setRoom(data.room);
} else {
throw new Error(data.error || 'Failed to fetch room');
}
} catch (err) {
console.error('Error fetching room:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
}
};
const fetchMessages = async (before?: string, append = false) => {
if (!append) setLoading(true);
else setLoadingMore(true);
try {
const params = new URLSearchParams({
limit: '50',
offset: append ? messages.length.toString() : '0'
});
if (before) {
params.set('before', before);
}
const response = await fetch(`${config.chatUrl}/rooms/${roomId}/messages?${params}`);
const data = await response.json();
if (data.status === 'success') {
const newMessages = data.messages;
if (append) {
// Prepend older messages
setMessages(prev => [...newMessages, ...prev]);
setHasMore(newMessages.length === 50);
} else {
setMessages(newMessages);
setHasMore(newMessages.length === 50);
// Scroll to bottom on initial load
setTimeout(scrollToBottom, 100);
}
// Load attachments for these messages in the background (non-blocking)
if (newMessages.length > 0) {
loadAttachments(newMessages.map((m: Message) => m.id));
}
} else {
throw new Error(data.error || 'Failed to fetch messages');
}
} catch (err) {
console.error('Error fetching messages:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
setLoadingMore(false);
}
};
const loadAttachments = async (messageIds: number[]) => {
try {
const response = await fetch(`${config.chatUrl}/messages/attachments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ messageIds }),
});
const data = await response.json();
if (data.status === 'success' && data.attachments) {
// Update messages with their attachments
setMessages(prevMessages =>
prevMessages.map(msg => ({
...msg,
attachments: data.attachments[msg.id] || []
}))
);
}
} catch (err) {
console.error('Error loading attachments:', err);
// Don't show error to user for attachments - messages are already displayed
}
};
const loadMoreMessages = () => {
if (messages.length > 0 && hasMore && !loadingMore) {
const oldestMessage = messages[0];
fetchMessages(oldestMessage.ts, true);
}
};
const searchMessages = async () => {
if (!searchQuery || searchQuery.length < 2) return;
try {
const response = await fetch(
`${config.chatUrl}/users/${selectedUserId}/search?q=${encodeURIComponent(searchQuery)}&limit=20`
);
const data = await response.json();
if (data.status === 'success') {
// Handle search results
}
} catch (err) {
console.error('Error searching messages:', err);
}
};
useEffect(() => {
if (roomId && selectedUserId) {
setMessages([]);
setError(null);
setHasMore(true);
fetchRoom();
fetchMessages();
}
}, [roomId, selectedUserId]);
const getRoomIcon = (room: Room) => {
switch (room.type) {
case 'c':
return <Hash className="h-4 w-4 text-blue-500" />;
case 'p':
// Distinguish between teams and discussions based on teamid
if (room.teamid) {
return <Users2 className="h-4 w-4 text-purple-500" />; // Teams
} else {
return <MessageCircle className="h-4 w-4 text-orange-500" />; // Discussions
}
case 'd':
return <MessageSquare className="h-4 w-4 text-green-500" />;
default:
return <Hash className="h-4 w-4 text-gray-500" />;
}
};
const formatTime = (timestamp: string) => {
// The stored timestamps are actually in Eastern Time but labeled as UTC
// We need to compensate by treating them as UTC and then converting back to Eastern
const originalDate = new Date(timestamp);
// Subtract 4 hours to compensate for the EDT offset (adjust to 5 hours for EST months)
// This assumes the original data was incorrectly stored as UTC when it was actually EDT/EST
const isDST = (date: Date) => {
const jan = new Date(date.getFullYear(), 0, 1);
const jul = new Date(date.getFullYear(), 6, 1);
return date.getTimezoneOffset() < Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset());
};
// Determine if the timestamp falls in DST period for Eastern Time
const offsetHours = isDST(originalDate) ? 4 : 5; // EDT = UTC-4, EST = UTC-5
const correctedDate = new Date(originalDate.getTime() - (offsetHours * 60 * 60 * 1000));
const now = new Date();
const timeZone = 'America/New_York';
// Compare dates in Eastern Time to ensure correct "today" detection
const dateInET = correctedDate.toLocaleDateString([], { timeZone });
const nowInET = now.toLocaleDateString([], { timeZone });
const isToday = dateInET === nowInET;
if (isToday) {
return correctedDate.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
timeZone
});
} else {
return correctedDate.toLocaleDateString([], { timeZone }) + ' ' + correctedDate.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
timeZone
});
}
};
const renderMessageText = (text: string, urls?: any[]) => {
if (!text) return '';
// First, convert emoji shortcodes to actual emoji
let processedText = convertEmojiShortcodes(text);
// Then, handle markdown links [text](url) and convert them to HTML
processedText = processedText.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s\)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline">$1</a>'
);
// If we have URL previews, replace standalone URLs (that aren't already in markdown) with just the preview
if (urls && urls.length > 0) {
urls.forEach((urlData) => {
// Only replace standalone URLs that aren't part of markdown links
const standaloneUrlRegex = new RegExp(`(?<!\\]\\()${urlData.url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\))`, 'g');
processedText = processedText.replace(standaloneUrlRegex, '');
});
}
return <span dangerouslySetInnerHTML={{ __html: processedText }} />;
};
const renderURLPreviews = (urls: any[]) => {
if (!urls || urls.length === 0) return null;
return (
<div className="mt-2 space-y-2">
{urls.map((urlData, index) => (
<div key={index} className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-start gap-2">
<ExternalLink className="h-4 w-4 mt-1 text-blue-500 flex-shrink-0" />
<div className="min-w-0 flex-1">
<a
href={urlData.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm break-all"
>
{urlData.meta?.pageTitle || urlData.url}
</a>
{urlData.meta?.ogDescription && (
<div className="text-xs text-muted-foreground mt-1">{urlData.meta.ogDescription}</div>
)}
</div>
</div>
</div>
))}
</div>
);
};
const renderAttachments = (attachments: any[]) => {
if (!attachments || attachments.length === 0) return null;
// Filter out thumbnail attachments (they're usually lower quality versions)
const filteredAttachments = attachments.filter(attachment =>
!attachment.name?.toLowerCase().startsWith('thumb-')
);
if (filteredAttachments.length === 0) return null;
return (
<div className="mt-2 space-y-2">
{filteredAttachments.map((attachment, index) => {
const isImage = attachment.typegroup === 'image';
const filePath = `${config.chatUrl}/files/by-id/${attachment.mongo_id}`;
const handleDownload = () => {
// Create a temporary anchor element to trigger download
const link = document.createElement('a');
link.href = filePath;
link.download = attachment.name || 'download';
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div key={index} className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-center gap-2">
{isImage ? (
<Image className="h-4 w-4 text-green-500" />
) : (
<FileText className="h-4 w-4 text-blue-500" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{attachment.name}</div>
<div className="text-xs text-muted-foreground">
{(attachment.size / 1024).toFixed(1)} KB
{attachment.identify?.size && (
<span> {attachment.identify.size.width}×{attachment.identify.size.height}</span>
)}
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleDownload}>
<Download className="h-4 w-4" />
</Button>
</div>
{isImage && attachment.identify?.size && (
<div className="mt-2">
<img
src={filePath}
alt={attachment.name}
className="max-w-xs max-h-48 rounded border cursor-pointer"
loading="lazy"
onClick={() => window.open(filePath, '_blank')}
/>
</div>
)}
</div>
);
})}
</div>
);
};
const renderMentions = (text: string, mentions: any[]) => {
if (!mentions || mentions.length === 0) return text;
// First, convert emoji shortcodes to actual emoji
let renderedText = convertEmojiShortcodes(text);
// Then process mentions
mentions.forEach((mention) => {
if (mention.username) {
const mentionPattern = new RegExp(`@${mention.username}`, 'g');
renderedText = renderedText.replace(
mentionPattern,
`<span class="bg-blue-100 text-blue-800 px-1 rounded">@${mention.username}</span>`
);
}
});
return <span dangerouslySetInnerHTML={{ __html: renderedText }} />;
};
const renderMessage = (message: Message, index: number) => {
const prevMessage = index > 0 ? messages[index - 1] : null;
const isConsecutive = prevMessage &&
prevMessage.u.username === message.u.username &&
new Date(message.ts).getTime() - new Date(prevMessage.ts).getTime() < 300000; // 5 minutes
return (
<div key={message.id} className={`${isConsecutive ? 'mt-1' : 'mt-4'}`}>
{!isConsecutive && (
<div className="flex items-center gap-2 mb-1">
<Avatar className="h-8 w-8">
<AvatarImage
src={`${config.chatUrl}/avatar/${message.u._id}`}
alt={message.u.name || message.u.username}
/>
<AvatarFallback className="text-sm font-medium">
{(message.u.name || message.u.username).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="font-medium text-sm">
{message.u.name || message.u.username}
</span>
<span className="text-xs text-muted-foreground">
{formatTime(message.ts)}
</span>
</div>
)}
<div className={`${isConsecutive ? 'ml-10' : 'ml-10'} text-sm`}>
<div className="break-words">
{message.mentions && message.mentions.length > 0
? renderMentions(message.msg, message.mentions)
: renderMessageText(message.msg, message.urls)
}
</div>
{message.urls && renderURLPreviews(message.urls)}
{message.attachments && renderAttachments(message.attachments)}
</div>
</div>
);
};
if (!roomId) {
return (
<Card className="h-full">
<CardContent className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Select a room to view messages</p>
</CardContent>
</Card>
);
}
if (loading && messages.length === 0) {
return (
<Card className="h-full">
<CardContent className="flex items-center justify-center h-full">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading messages...</span>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="h-full border-red-200 bg-red-50">
<CardContent className="flex items-center justify-center h-full">
<p className="text-red-700">{error}</p>
</CardContent>
</Card>
);
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{room && getRoomIcon(room)}
<div>
<CardTitle className="text-lg">
{room?.type === 'd'
? `Direct message with ${room?.display_name || 'Unknown User'}`
: room?.display_name || room?.fname || room?.name || 'Unnamed Room'
}
</CardTitle>
{room?.description && room?.type !== 'd' && (
<p className="text-sm text-muted-foreground">{room.description}</p>
)}
{/* Only show participants for non-direct messages since DM names are already in the title */}
{room?.participants && room.participants.length > 0 && room?.type !== 'd' && (
<p className="text-xs text-muted-foreground">
{room.participants.map(p => p.name || p.username).join(', ')}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowSearch(!showSearch)}
>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{showSearch && (
<div className="flex gap-2 mt-2">
<Input
placeholder="Search messages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && searchMessages()}
/>
<Button onClick={searchMessages} size="sm">Search</Button>
</div>
)}
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden">
<div
ref={messagesContainerRef}
className="h-full overflow-y-auto p-4"
>
{hasMore && messages.length > 0 && (
<div className="text-center mb-4">
<Button
variant="outline"
size="sm"
onClick={loadMoreMessages}
disabled={loadingMore}
>
{loadingMore ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<ChevronUp className="h-4 w-4 mr-2" />
)}
Load older messages
</Button>
</div>
)}
{messages.length === 0 ? (
<div className="text-center text-muted-foreground">
No messages in this room
</div>
) : (
<div className="space-y-1">
{messages.map((message, index) => renderMessage(message, index))}
</div>
)}
<div ref={messagesEndRef} />
</div>
</CardContent>
</Card>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, Hash, Lock, Users, MessageSquare } from 'lucide-react';
import config from '@/config';
interface Room {
id: number;
name: string;
fname: string;
type: string;
msgs: number;
last_message_date: string;
}
interface ChatTestProps {
selectedUserId: string;
}
export function ChatTest({ selectedUserId }: ChatTestProps) {
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!selectedUserId) {
setRooms([]);
return;
}
const fetchUserRooms = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${config.chatUrl}/users/${selectedUserId}/rooms`);
const data = await response.json();
if (data.status === 'success') {
setRooms(data.rooms);
} else {
throw new Error(data.error || 'Failed to fetch rooms');
}
} catch (err) {
console.error('Error fetching user rooms:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchUserRooms();
}, [selectedUserId]);
const getRoomIcon = (roomType: string) => {
switch (roomType) {
case 'c':
return <Hash className="h-4 w-4 text-blue-500" />;
case 'p':
return <Lock className="h-4 w-4 text-orange-500" />;
case 'd':
return <MessageSquare className="h-4 w-4 text-green-500" />;
default:
return <Users className="h-4 w-4 text-gray-500" />;
}
};
const getRoomTypeLabel = (roomType: string) => {
switch (roomType) {
case 'c':
return 'Channel';
case 'p':
return 'Private';
case 'd':
return 'Direct';
default:
return 'Unknown';
}
};
if (!selectedUserId) {
return (
<Card>
<CardHeader>
<CardTitle>Database Connection Test</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Select a user from the dropdown above to view their rooms and test the database connection.
</p>
</CardContent>
</Card>
);
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Loading User Rooms...</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Fetching rooms for selected user...</span>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="border-red-200 bg-red-50">
<CardHeader>
<CardTitle className="text-red-800">Error Loading Rooms</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-700">{error}</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>User Rooms ({rooms.length})</CardTitle>
<p className="text-sm text-muted-foreground">
Rooms accessible to the selected user
</p>
</CardHeader>
<CardContent>
{rooms.length === 0 ? (
<p className="text-muted-foreground">No rooms found for this user.</p>
) : (
<div className="space-y-2">
{rooms.map((room) => (
<div
key={room.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center gap-3">
{getRoomIcon(room.type)}
<div>
<div className="font-medium">
{room.fname || room.name || 'Unnamed Room'}
</div>
<div className="text-sm text-muted-foreground">
{room.name && room.fname !== room.name && (
<span className="font-mono">#{room.name}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
{getRoomTypeLabel(room.type)}
</Badge>
<Badge variant="outline">
{room.msgs} messages
</Badge>
{room.last_message_date && (
<Badge variant="outline" className="text-xs">
{new Date(room.last_message_date).toLocaleDateString()}
</Badge>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+330
View File
@@ -0,0 +1,330 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Loader2, Hash, Users, MessageSquare, MessageCircle, Users2 } from 'lucide-react';
import config from '@/config';
interface Room {
id: number;
name: string;
fname: string;
type: string;
msgs: number;
last_message_date: string;
display_name: string;
userscount?: number;
description?: string;
teamid?: string;
archived?: boolean;
open?: boolean;
participants?: {
username: string;
name: string;
mongo_id: string;
avataretag?: string;
}[];
}
interface RoomListProps {
selectedUserId: string;
selectedRoomId: string | null;
onRoomSelect: (roomId: string) => void;
}
export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomListProps) {
const [rooms, setRooms] = useState<Room[]>([]);
const [filteredRooms, setFilteredRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchFilter] = useState('');
useEffect(() => {
if (!selectedUserId) {
setRooms([]);
setFilteredRooms([]);
return;
}
const fetchUserRooms = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${config.chatUrl}/users/${selectedUserId}/rooms`);
const data = await response.json();
if (data.status === 'success') {
setRooms(data.rooms);
setFilteredRooms(data.rooms);
} else {
throw new Error(data.error || 'Failed to fetch rooms');
}
} catch (err) {
console.error('Error fetching user rooms:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchUserRooms();
}, [selectedUserId]);
useEffect(() => {
if (!searchFilter) {
setFilteredRooms(rooms);
} else {
const filtered = rooms.filter(room =>
(room.display_name?.toLowerCase() || '').includes(searchFilter.toLowerCase()) ||
(room.name?.toLowerCase() || '').includes(searchFilter.toLowerCase()) ||
(room.fname?.toLowerCase() || '').includes(searchFilter.toLowerCase())
);
setFilteredRooms(filtered);
}
}, [searchFilter, rooms]);
const groupRoomsByType = (rooms: Room[]) => {
const teams: Room[] = [];
const discussions: Room[] = [];
const channels: Room[] = [];
const directMessages: Room[] = [];
rooms.forEach(room => {
switch (room.type) {
case 'p':
if (room.teamid) {
teams.push(room);
} else {
discussions.push(room);
}
break;
case 'c':
channels.push(room);
break;
case 'd':
directMessages.push(room);
break;
default:
channels.push(room); // fallback for unknown types
}
});
// Sort each group by message count descending
const sortByMessages = (a: Room, b: Room) => (b.msgs || 0) - (a.msgs || 0);
teams.sort(sortByMessages);
discussions.sort(sortByMessages);
channels.sort(sortByMessages);
directMessages.sort(sortByMessages);
return { teams, discussions, channels, directMessages };
};
const renderRoomIcon = (room: Room) => {
// For direct messages, show participant avatars
if (room.type === 'd' && room.participants && room.participants.length > 0) {
if (room.participants.length === 1) {
// Single participant - show their avatar
const participant = room.participants[0];
return (
<Avatar className="h-10 w-10">
<AvatarImage
src={`${config.chatUrl}/avatar/${participant.mongo_id}`}
alt={participant.name || participant.username}
/>
<AvatarFallback className="text-lg">
{(participant.name || participant.username).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
);
} else {
// Multiple participants - show overlapping avatars
return (
<div className="relative flex items-center h-10 w-10">
{room.participants.slice(0, 3).map((participant, index) => (
<Avatar
key={participant.mongo_id}
className={`h-8 w-8 border-2 border-white ${index > 0 ? '-ml-4' : ''}`}
style={{ zIndex: 30 - index }}
>
<AvatarImage
src={`${config.chatUrl}/avatar/${participant.mongo_id}`}
alt={participant.name || participant.username}
/>
<AvatarFallback className="text-lg">
{(participant.name || participant.username).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
))}
</div>
);
}
}
// For other room types, use icons
switch (room.type) {
case 'c':
return (
<div className="h-10 w-10 bg-blue-50 rounded-full flex items-center justify-center">
<Hash className="h-6 w-6 text-blue-500" />
</div>
);
case 'p':
// Distinguish between teams and discussions based on teamid
if (room.teamid) {
return (
<div className="h-10 w-10 bg-purple-50 rounded-full flex items-center justify-center">
<Users2 className="h-6 w-6 text-purple-500" />
</div>
); // Teams
} else {
return (
<div className="h-10 w-10 bg-orange-50 rounded-full flex items-center justify-center">
<MessageCircle className="h-6 w-6 text-orange-500" />
</div>
); // Discussions
}
case 'd':
return (
<div className="h-12 w-12 bg-green-50 rounded-full flex items-center justify-center">
<MessageSquare className="h-6 w-6 text-green-500" />
</div>
);
default:
return (
<div className="h-12 w-12 bg-gray-50 rounded-full flex items-center justify-center">
<Users className="h-6 w-6 text-gray-500" />
</div>
);
}
};
if (!selectedUserId) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Rooms</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Select a user from the dropdown above to view their rooms.
</p>
</CardContent>
</Card>
);
}
if (loading) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Loading Rooms...</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Fetching rooms for selected user...</span>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="h-full border-red-200 bg-red-50">
<CardHeader>
<CardTitle className="text-red-800">Error Loading Rooms</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-700 text-sm">{error}</p>
</CardContent>
</Card>
);
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Rooms</CardTitle>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden">
<div className="h-full overflow-y-auto">
{filteredRooms.length === 0 ? (
<div className="p-4">
<p className="text-muted-foreground text-sm">
{searchFilter ? 'No rooms match your search.' : 'No rooms found for this user.'}
</p>
</div>
) : (
<div className="space-y-3 px-2 pb-4">
{(() => {
const { teams, discussions, channels, directMessages } = groupRoomsByType(filteredRooms);
const renderRoomGroup = (title: string, rooms: Room[], icon: React.ReactNode) => {
if (rooms.length === 0) return null;
return (
<div key={title} className="space-y-0.5">
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100">
{icon}
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{title} ({rooms.length})
</h3>
</div>
<div className="space-y-0.5">
{rooms.map((room) => (
<div
key={room.id}
onClick={() => onRoomSelect(room.id.toString())}
className={`
flex items-center justify-between py-0.5 px-3 rounded-lg cursor-pointer transition-colors
hover:bg-gray-100
${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''}
${(room.open === false || room.archived === true) ? 'opacity-60' : ''}
`}
>
<div className="grid grid-cols-4 items-center gap-2 min-w-0 flex-1">
{renderRoomIcon(room)}
<div className="min-w-0 flex-1 col-span-3">
<div className="flex items-center justify-between">
<div className={`font-medium text-sm truncate ${(room.open === false || room.archived === true) ? 'text-muted-foreground' : ''}`}>
{room.display_name || room.fname || room.name || 'Unnamed Room'}
</div>
{room.msgs > 0 && (
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
{room.msgs} msgs
</span>
)}
</div>
{room.description && (
<div className="text-xs text-muted-foreground truncate">
{room.description}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
};
return (
<>
{renderRoomGroup('Teams', teams, <Users2 className="h-3 w-3 text-purple-500" />)}
{renderRoomGroup('Discussions', discussions, <MessageCircle className="h-3 w-3 text-orange-500" />)}
{renderRoomGroup('Channels', channels, <Hash className="h-3 w-3 text-blue-500" />)}
{renderRoomGroup('Direct Messages', directMessages, <MessageSquare className="h-3 w-3 text-green-500" />)}
</>
);
})()}
</div>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,116 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Hash, Lock, MessageSquare, X } from 'lucide-react';
import { convertEmojiShortcodes } from '@/utils/emojiUtils';
interface SearchResult {
id: number;
msg: string;
ts: string;
u: {
username: string;
name?: string;
};
room_id: number;
room_name: string;
room_fname: string;
room_type: string;
}
interface SearchResultsProps {
results: SearchResult[];
query: string;
onClose: () => void;
onRoomSelect: (roomId: string) => void;
}
export function SearchResults({ results, query, onClose, onRoomSelect }: SearchResultsProps) {
const getRoomIcon = (roomType: string) => {
switch (roomType) {
case 'c':
return <Hash className="h-3 w-3 text-blue-500" />;
case 'p':
return <Lock className="h-3 w-3 text-orange-500" />;
case 'd':
return <MessageSquare className="h-3 w-3 text-green-500" />;
default:
return <Hash className="h-3 w-3 text-gray-500" />;
}
};
const highlightText = (text: string, query: string) => {
if (!query) return convertEmojiShortcodes(text);
// First convert emoji shortcodes
const textWithEmoji = convertEmojiShortcodes(text);
const regex = new RegExp(`(${query})`, 'gi');
const parts = textWithEmoji.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<span key={index} className="bg-yellow-200 font-medium">
{part}
</span>
) : (
part
)
);
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
};
return (
<Card className="absolute top-full left-0 right-0 z-10 mt-2 max-h-96 overflow-y-auto">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm">
Search Results for "{query}" ({results.length})
</CardTitle>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="pt-0">
{results.length === 0 ? (
<p className="text-sm text-muted-foreground">No messages found matching your search.</p>
) : (
<div className="space-y-3">
{results.map((result) => (
<div
key={result.id}
className="border rounded-lg p-3 hover:bg-gray-50 cursor-pointer"
onClick={() => {
onRoomSelect(result.room_id.toString());
onClose();
}}
>
<div className="flex items-center gap-2 mb-2">
{getRoomIcon(result.room_type)}
<span className="text-sm font-medium">
{result.room_fname || result.room_name || 'Unnamed Room'}
</span>
<Badge variant="outline" className="text-xs">
{result.u.name || result.u.username}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{formatTime(result.ts)}
</span>
</div>
<div className="text-sm">
{highlightText(result.msg, query)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
// API base URL - update based on your actual API endpoint
apiUrl: '/api',
// Add other config values as needed
};
export default config;
@@ -0,0 +1,133 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
import { Button } from "@/components/dashboard/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
import { Loader2, AlertCircle, CheckCircle, RefreshCw } from "lucide-react";
const AcotTest = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const [connectionStatus, setConnectionStatus] = useState(null);
const testConnection = async () => {
setLoading(true);
setError(null);
try {
const response = await axios.get("/api/acot/test/test-connection");
setConnectionStatus(response.data);
} catch (err) {
setError(err.response?.data?.error || err.message);
setConnectionStatus(null);
} finally {
setLoading(false);
}
};
const fetchOrderCount = async () => {
setLoading(true);
setError(null);
try {
const response = await axios.get("/api/acot/test/order-count");
setData(response.data.data);
} catch (err) {
setError(err.response?.data?.error || err.message);
setData(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
testConnection();
}, []);
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center justify-between">
ACOT Server Test
<Button
size="icon"
variant="outline"
onClick={() => {
testConnection();
if (connectionStatus?.success) {
fetchOrderCount();
}
}}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Connection Status */}
<div className="space-y-2">
<h3 className="text-sm font-medium">Connection Status</h3>
{connectionStatus?.success ? (
<Alert className="bg-green-50 border-green-200">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertTitle className="text-green-800">Connected</AlertTitle>
<AlertDescription className="text-green-700">
{connectionStatus.message}
</AlertDescription>
</Alert>
) : error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<div className="text-sm text-muted-foreground">
Testing connection...
</div>
)}
</div>
{/* Order Count */}
{connectionStatus?.success && (
<div className="space-y-2">
<Button
onClick={fetchOrderCount}
disabled={loading}
className="w-full"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
"Fetch Order Count"
)}
</Button>
{data && (
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground">
Total Orders in Database
</div>
<div className="text-2xl font-bold">
{data.orderCount?.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground mt-1">
Last updated: {new Date(data.timestamp).toLocaleTimeString()}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
};
export default AcotTest;
@@ -0,0 +1,608 @@
// components/AircallDashboard.jsx
import React, { useState, useEffect } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/dashboard/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/dashboard/ui/select";
import { Alert, AlertDescription } from "@/components/dashboard/ui/alert";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/dashboard/ui/table";
import {
PhoneCall,
PhoneMissed,
Clock,
UserCheck,
PhoneIncoming,
PhoneOutgoing,
ArrowUpDown,
Timer,
Loader2,
Download,
Search,
} from "lucide-react";
import { Button } from "@/components/dashboard/ui/button";
import { Input } from "@/components/dashboard/ui/input";
import { Progress } from "@/components/dashboard/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/dashboard/ui/tooltip";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
Legend,
ResponsiveContainer,
BarChart,
Bar,
} from "recharts";
const COLORS = {
inbound: "hsl(262.1 83.3% 57.8%)", // Purple
outbound: "hsl(142.1 76.2% 36.3%)", // Green
missed: "hsl(47.9 95.8% 53.1%)", // Yellow
answered: "hsl(142.1 76.2% 36.3%)", // Green
duration: "hsl(221.2 83.2% 53.3%)", // Blue
hourly: "hsl(321.2 81.1% 41.2%)", // Pink
};
const TIME_RANGES = [
{ label: "Today", value: "today" },
{ label: "Yesterday", value: "yesterday" },
{ label: "Last 7 Days", value: "last7days" },
{ label: "Last 30 Days", value: "last30days" },
{ label: "Last 90 Days", value: "last90days" },
];
const REFRESH_INTERVAL = 5 * 60 * 1000;
const formatDuration = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m ${remainingSeconds}s`;
};
const MetricCard = ({ title, value, subtitle, icon: Icon, iconColor }) => (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2 p-4">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">{title}</CardTitle>
<Icon className={`h-4 w-4 ${iconColor}`} />
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">{value}</div>
{subtitle && (
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
)}
</CardContent>
</Card>
);
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none">
<CardContent className="p-0 space-y-2">
<p className="font-medium text-sm text-gray-900 dark:text-gray-100 border-b border-gray-100 dark:border-gray-800 pb-1 mb-2">{label}</p>
{payload.map((entry, index) => (
<p key={index} className="text-sm text-muted-foreground">
{`${entry.name}: ${entry.value}`}
</p>
))}
</CardContent>
</Card>
);
}
return null;
};
const AgentPerformanceTable = ({ agents, onSort }) => {
const [sortConfig, setSortConfig] = useState({
key: "total",
direction: "desc",
});
const handleSort = (key) => {
const direction =
sortConfig.key === key && sortConfig.direction === "desc"
? "asc"
: "desc";
setSortConfig({ key, direction });
onSort(key, direction);
};
return (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Agent</TableHead>
<TableHead onClick={() => handleSort("total")}>Total Calls</TableHead>
<TableHead onClick={() => handleSort("answered")}>Answered</TableHead>
<TableHead onClick={() => handleSort("missed")}>Missed</TableHead>
<TableHead onClick={() => handleSort("average_duration")}>Average Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agents.map((agent) => (
<TableRow key={agent.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">{agent.name}</TableCell>
<TableCell>{agent.total}</TableCell>
<TableCell className="text-emerald-600 dark:text-emerald-400">{agent.answered}</TableCell>
<TableCell className="text-rose-600 dark:text-rose-400">{agent.missed}</TableCell>
<TableCell className="text-muted-foreground">{formatDuration(agent.average_duration)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};
const SkeletonMetricCard = () => (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-col items-start p-4">
<Skeleton className="h-4 w-24 mb-2 bg-muted" />
<Skeleton className="h-8 w-32 mb-2 bg-muted" />
<div className="flex gap-4">
<Skeleton className="h-4 w-20 bg-muted" />
<Skeleton className="h-4 w-20 bg-muted" />
</div>
</CardHeader>
</Card>
);
const SkeletonChart = ({ type = "line" }) => (
<div className="h-[300px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
<div className="h-full flex flex-col">
<div className="flex-1 relative">
{type === "bar" ? (
<div className="h-full flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className="w-full bg-muted rounded-t animate-pulse"
style={{ height: `${15 + Math.random() * 70}%` }}
/>
))}
</div>
) : (
<div className="h-full w-full relative">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${20 + i * 20}%` }}
/>
))}
<div
className="absolute inset-0 bg-muted animate-pulse"
style={{
opacity: 0.2,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
)}
</div>
</div>
</div>
);
const SkeletonTable = ({ rows = 5 }) => (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(rows)].map((_, i) => (
<TableRow key={i} className="hover:bg-muted/50 transition-colors">
<TableCell><Skeleton className="h-4 w-32 bg-muted" /></TableCell>
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
<TableCell><Skeleton className="h-4 w-24 bg-muted" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
const AircallDashboard = () => {
const [timeRange, setTimeRange] = useState("last7days");
const [metrics, setMetrics] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [agentSort, setAgentSort] = useState({
key: "total",
direction: "desc",
});
const safeArray = (arr) => (Array.isArray(arr) ? arr : []);
const safeObject = (obj) => (obj && typeof obj === "object" ? obj : {});
const sortedAgents = metrics?.by_users
? Object.values(metrics.by_users).sort((a, b) => {
const multiplier = agentSort.direction === "desc" ? -1 : 1;
return multiplier * (a[agentSort.key] - b[agentSort.key]);
})
: [];
const formatDate = (dateString) => {
try {
// Parse the date string (YYYY-MM-DD)
const [year, month, day] = dateString.split('-').map(Number);
// Create a date object in ET timezone
const date = new Date(Date.UTC(year, month - 1, day));
// Format the date in ET timezone
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "America/New_York"
}).format(date);
} catch (error) {
console.error("Date formatting error:", error, { dateString });
return "Invalid Date";
}
};
const handleExport = () => {
const timestamp = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
exportToCSV(filteredAgents, `aircall-agent-metrics-${timestamp}`);
};
const chartData = {
hourly: metrics?.by_hour
? metrics.by_hour.map((count, hour) => ({
hour: new Date(2000, 0, 1, hour).toLocaleString('en-US', {
hour: 'numeric',
hour12: true
}).toUpperCase(),
calls: count || 0,
}))
: [],
missedReasons: metrics?.by_missed_reason
? Object.entries(metrics.by_missed_reason).map(([reason, count]) => ({
reason: (reason || "").replace(/_/g, " "),
count: count || 0,
}))
: [],
daily: safeArray(metrics?.daily_data).map((day) => ({
...day,
inbound: day.inbound || 0,
outbound: day.outbound || 0,
date: new Date(day.date).toLocaleString('en-US', {
month: 'short',
day: 'numeric'
}),
})),
};
const peakHour = metrics?.by_hour
? metrics.by_hour.indexOf(Math.max(...metrics.by_hour))
: null;
const busyAgent = sortedAgents?.length > 0 ? sortedAgents[0] : null;
const bestAnswerRate = sortedAgents
?.filter((agent) => agent.total > 0)
?.sort((a, b) => b.answered / b.total - a.answered / a.total)[0];
const fetchData = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/aircall/metrics/${timeRange}`);
if (!response.ok) throw new Error("Failed to fetch metrics");
const data = await response.json();
setMetrics(data);
setLastUpdated(data._meta?.generatedAt);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [timeRange]);
if (error) {
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
Error loading call data: {error}
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-6">
<div className="flex justify-between items-center">
<div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">Calls</CardTitle>
</div>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9 bg-white dark:bg-gray-800">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-6 pt-0 space-y-4">
{/* Metric Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{isLoading ? (
[...Array(4)].map((_, i) => (
<SkeletonMetricCard key={i} />
))
) : metrics ? (
<>
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-col items-start p-4">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Calls</CardTitle>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">{metrics.total}</div>
<div className="flex gap-4 mt-2">
<div className="text-sm text-muted-foreground">
<span className="text-blue-500"> {metrics.by_direction.inbound}</span> inbound
</div>
<div className="text-sm text-muted-foreground">
<span className="text-emerald-500"> {metrics.by_direction.outbound}</span> outbound
</div>
</div>
</CardHeader>
</Card>
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-col items-start p-4">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Answer Rate</CardTitle>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">
{`${((metrics.by_status.answered / metrics.total) * 100).toFixed(1)}%`}
</div>
<div className="flex gap-6">
<div className="text-sm text-muted-foreground">
<span className="text-emerald-500">{metrics.by_status.answered}</span> answered
</div>
<div className="text-sm text-muted-foreground">
<span className="text-rose-500">{metrics.by_status.missed}</span> missed
</div>
</div>
</CardHeader>
</Card>
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-col items-start p-4">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Peak Hour</CardTitle>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">
{metrics?.by_hour ? new Date(2000, 0, 1, metrics.by_hour.indexOf(Math.max(...metrics.by_hour))).toLocaleString('en-US', { hour: 'numeric', hour12: true }).toUpperCase() : 'N/A'}
</div>
<div className="text-sm text-muted-foreground mt-2">
Busiest Agent: {sortedAgents[0]?.name || "N/A"}
</div>
</CardHeader>
</Card>
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-col items-start p-4">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Avg Duration</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{formatDuration(metrics.average_duration)}
</div>
<div className="text-sm text-muted-foreground mt-2">
{metrics?.daily_data?.length > 0
? `${Math.round(metrics.total / metrics.daily_data.length)} calls/day`
: "N/A"}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-[300px] bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<div className="space-y-2">
<p className="font-medium text-gray-900 dark:text-gray-100">Duration Distribution</p>
{metrics?.duration_distribution?.map((d, i) => (
<div key={i} className="flex justify-between text-sm text-muted-foreground">
<span>{d.range}</span>
<span>{d.count} calls</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
</Card>
</>
) : null}
</div>
{/* Charts and Tables Section */}
<div className="space-y-4">
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Daily Call Volume */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-4">
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Daily Call Volume</CardTitle>
</CardHeader>
<CardContent className="h-[300px]">
{isLoading ? (
<SkeletonChart type="bar" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData.daily} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<RechartsTooltip content={<CustomTooltip />} />
<Legend />
<Bar dataKey="inbound" fill={COLORS.inbound} name="Inbound" />
<Bar dataKey="outbound" fill={COLORS.outbound} name="Outbound" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Hourly Distribution */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-4">
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Hourly Distribution</CardTitle>
</CardHeader>
<CardContent className="h-[300px]">
{isLoading ? (
<SkeletonChart type="bar" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData.hourly} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="hour"
tick={{ fontSize: 12 }}
interval={2}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<RechartsTooltip content={<CustomTooltip />} />
<Bar dataKey="calls" fill={COLORS.hourly} name="Calls" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
{/* Tables Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Agent Performance */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-4">
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Agent Performance</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<SkeletonTable rows={5} />
) : (
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<AgentPerformanceTable
agents={sortedAgents}
onSort={(key, direction) => setAgentSort({ key, direction })}
/>
</div>
)}
</CardContent>
</Card>
{/* Missed Call Reasons Table */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-4">
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Missed Call Reasons</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<SkeletonTable rows={5} />
) : (
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="font-medium text-gray-900 dark:text-gray-100">Reason</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{chartData.missedReasons.map((reason, index) => (
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{reason.reason}
</TableCell>
<TableCell className="text-right text-rose-600 dark:text-rose-400">
{reason.count}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default AircallDashboard;
@@ -0,0 +1,597 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/dashboard/ui/select";
import { Button } from "@/components/dashboard/ui/button";
import { Separator } from "@/components/dashboard/ui/separator";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
import { Loader2, TrendingUp, AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/dashboard/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/dashboard/ui/table";
// Add helper function for currency formatting
const formatCurrency = (value, useFractionDigits = true) => {
if (typeof value !== "number") return "$0.00";
const roundedValue = parseFloat(value.toFixed(useFractionDigits ? 2 : 0));
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: useFractionDigits ? 2 : 0,
maximumFractionDigits: useFractionDigits ? 2 : 0,
}).format(roundedValue);
};
// Add skeleton components
const SkeletonChart = () => (
<div className="h-[400px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
<div className="h-full flex flex-col">
<div className="flex-1 relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-muted rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-muted rounded-sm" />
))}
</div>
{/* Chart line */}
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className="absolute inset-0 bg-muted rounded-sm"
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
</div>
</div>
</div>
);
const SkeletonStats = () => (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
{[...Array(4)].map((_, i) => (
<Card key={i} className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
</CardHeader>
<CardContent className="p-4 pt-0">
<Skeleton className="h-8 w-32 bg-muted rounded-sm mb-2" />
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
</CardContent>
</Card>
))}
</div>
);
const SkeletonButtons = () => (
<div className="flex flex-wrap gap-1">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-8 w-20 bg-muted rounded-sm" />
))}
</div>
);
// Add StatCard component
const StatCard = ({
title,
value,
description,
trend,
trendValue,
colorClass = "text-gray-900 dark:text-gray-100",
}) => (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<span className="text-sm text-muted-foreground font-medium">{title}</span>
{trend && (
<span
className={`text-sm flex items-center gap-1 font-medium ${
trend === "up"
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400"
}`}
>
{trendValue}
</span>
)}
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-bold mb-1.5 ${colorClass}`}>{value}</div>
{description && (
<div className="text-sm font-medium text-muted-foreground">{description}</div>
)}
</CardContent>
</Card>
);
// Add color constants
const METRIC_COLORS = {
activeUsers: {
color: "#8b5cf6",
className: "text-purple-600 dark:text-purple-400",
},
newUsers: {
color: "#10b981",
className: "text-emerald-600 dark:text-emerald-400",
},
pageViews: {
color: "#f59e0b",
className: "text-amber-600 dark:text-amber-400",
},
conversions: {
color: "#3b82f6",
className: "text-blue-600 dark:text-blue-400",
},
};
export const AnalyticsDashboard = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState("30");
const [metrics, setMetrics] = useState({
activeUsers: true,
newUsers: true,
pageViews: true,
conversions: true,
});
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(
`/api/dashboard-analytics/metrics?startDate=${timeRange}daysAgo`,
{
credentials: "include",
}
);
if (!response.ok) {
throw new Error("Failed to fetch metrics");
}
const result = await response.json();
if (!result?.data?.rows) {
console.log("No result data received");
return;
}
const processedData = result.data.rows.map((row) => ({
date: formatGADate(row.dimensionValues[0].value),
activeUsers: parseInt(row.metricValues[0].value),
newUsers: parseInt(row.metricValues[1].value),
avgSessionDuration: parseFloat(row.metricValues[2].value),
pageViews: parseInt(row.metricValues[3].value),
bounceRate: parseFloat(row.metricValues[4].value) * 100,
conversions: parseInt(row.metricValues[5].value),
}));
const sortedData = processedData.sort((a, b) => a.date - b.date);
setData(sortedData);
} catch (error) {
console.error("Failed to fetch analytics:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, [timeRange]);
const formatGADate = (gaDate) => {
const year = gaDate.substring(0, 4);
const month = gaDate.substring(4, 6);
const day = gaDate.substring(6, 8);
return new Date(year, month - 1, day);
};
const formatXAxis = (date) => {
if (!date) return "";
return date.toLocaleDateString([], { month: "short", day: "numeric" });
};
const calculateSummaryStats = () => {
if (!data.length) return null;
const totals = data.reduce(
(acc, day) => ({
activeUsers: acc.activeUsers + day.activeUsers,
newUsers: acc.newUsers + day.newUsers,
pageViews: acc.pageViews + day.pageViews,
conversions: acc.conversions + day.conversions,
}),
{
activeUsers: 0,
newUsers: 0,
pageViews: 0,
conversions: 0,
}
);
const averages = {
activeUsers: totals.activeUsers / data.length,
newUsers: totals.newUsers / data.length,
pageViews: totals.pageViews / data.length,
conversions: totals.conversions / data.length,
};
return { totals, averages };
};
const summaryStats = calculateSummaryStats();
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border border-border">
<CardContent className="p-0 space-y-2">
<p className="font-medium text-sm border-b border-border pb-1.5 mb-2 text-foreground">
{label instanceof Date ? label.toLocaleDateString() : label}
</p>
<div className="space-y-1.5">
{payload.map((entry, index) => (
<div
key={index}
className="flex justify-between items-center text-sm"
>
<span className="font-medium" style={{ color: entry.color }}>{entry.name}:</span>
<span className="font-medium ml-4 text-foreground">
{entry.value.toLocaleString()}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
return null;
};
return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-6 pb-4">
<div className="flex flex-col space-y-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Analytics Overview
</CardTitle>
</div>
<div className="flex items-center gap-2">
{loading ? (
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
) : (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9">
Details
</Button>
</DialogTrigger>
<DialogContent className="max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<DialogHeader className="flex-none">
<DialogTitle className="text-gray-900 dark:text-gray-100">Daily Details</DialogTitle>
<div className="flex items-center justify-center gap-2 pt-4">
<div className="flex flex-wrap gap-1">
{Object.entries(metrics).map(([key, value]) => (
<Button
key={key}
variant={value ? "default" : "outline"}
size="sm"
onClick={() =>
setMetrics((prev) => ({
...prev,
[key]: !prev[key],
}))
}
>
{key === "activeUsers" ? "Active Users" :
key === "newUsers" ? "New Users" :
key === "pageViews" ? "Page Views" :
"Conversions"}
</Button>
))}
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto mt-6">
<div className="rounded-lg border bg-white dark:bg-gray-900/60 backdrop-blur-sm w-full">
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHead className="text-center whitespace-nowrap px-6 w-[120px]">Date</TableHead>
{metrics.activeUsers && (
<TableHead className="text-center whitespace-nowrap px-6 min-w-[100px]">Active Users</TableHead>
)}
{metrics.newUsers && (
<TableHead className="text-center whitespace-nowrap px-6 min-w-[100px]">New Users</TableHead>
)}
{metrics.pageViews && (
<TableHead className="text-center whitespace-nowrap px-6 min-w-[140px]">Page Views</TableHead>
)}
{metrics.conversions && (
<TableHead className="text-center whitespace-nowrap px-6 min-w-[120px]">Conversions</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{data.map((day) => (
<TableRow key={day.date}>
<TableCell className="text-center whitespace-nowrap px-6">{formatXAxis(day.date)}</TableCell>
{metrics.activeUsers && (
<TableCell className="text-center whitespace-nowrap px-6">
{day.activeUsers.toLocaleString()}
</TableCell>
)}
{metrics.newUsers && (
<TableCell className="text-center whitespace-nowrap px-6">
{day.newUsers.toLocaleString()}
</TableCell>
)}
{metrics.pageViews && (
<TableCell className="text-center whitespace-nowrap px-6">
{day.pageViews.toLocaleString()}
</TableCell>
)}
{metrics.conversions && (
<TableCell className="text-center whitespace-nowrap px-6">
{day.conversions.toLocaleString()}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</>
)}
</div>
</div>
{loading ? (
<SkeletonStats />
) : summaryStats ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
<StatCard
title="Active Users"
value={summaryStats.totals.activeUsers.toLocaleString()}
description={`Avg: ${Math.round(
summaryStats.averages.activeUsers
).toLocaleString()} per day`}
colorClass={METRIC_COLORS.activeUsers.className}
/>
<StatCard
title="New Users"
value={summaryStats.totals.newUsers.toLocaleString()}
description={`Avg: ${Math.round(
summaryStats.averages.newUsers
).toLocaleString()} per day`}
colorClass={METRIC_COLORS.newUsers.className}
/>
<StatCard
title="Page Views"
value={summaryStats.totals.pageViews.toLocaleString()}
description={`Avg: ${Math.round(
summaryStats.averages.pageViews
).toLocaleString()} per day`}
colorClass={METRIC_COLORS.pageViews.className}
/>
<StatCard
title="Conversions"
value={summaryStats.totals.conversions.toLocaleString()}
description={`Avg: ${Math.round(
summaryStats.averages.conversions
).toLocaleString()} per day`}
colorClass={METRIC_COLORS.conversions.className}
/>
</div>
) : null}
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
<div className="flex flex-wrap gap-1">
<Button
variant={metrics.activeUsers ? "default" : "outline"}
size="sm"
className="font-medium"
onClick={() =>
setMetrics((prev) => ({
...prev,
activeUsers: !prev.activeUsers,
}))
}
>
<span className="hidden sm:inline">Active Users</span>
<span className="sm:hidden">Active</span>
</Button>
<Button
variant={metrics.newUsers ? "default" : "outline"}
size="sm"
className="font-medium"
onClick={() =>
setMetrics((prev) => ({
...prev,
newUsers: !prev.newUsers,
}))
}
>
<span className="hidden sm:inline">New Users</span>
<span className="sm:hidden">New</span>
</Button>
<Button
variant={metrics.pageViews ? "default" : "outline"}
size="sm"
className="font-medium"
onClick={() =>
setMetrics((prev) => ({
...prev,
pageViews: !prev.pageViews,
}))
}
>
<span className="hidden sm:inline">Page Views</span>
<span className="sm:hidden">Views</span>
</Button>
<Button
variant={metrics.conversions ? "default" : "outline"}
size="sm"
className="font-medium"
onClick={() =>
setMetrics((prev) => ({
...prev,
conversions: !prev.conversions,
}))
}
>
<span className="hidden sm:inline">Conversions</span>
<span className="sm:hidden">Conv.</span>
</Button>
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
{loading ? (
<SkeletonChart />
) : !data.length ? (
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
<div className="text-center">
<TrendingUp className="h-12 w-12 mx-auto mb-4 opacity-50" />
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100">No analytics data available</div>
<div className="text-sm text-muted-foreground">
Try selecting a different time range
</div>
</div>
</div>
) : (
<div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: -30, left: -5, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="date"
tickFormatter={formatXAxis}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<YAxis
yAxisId="left"
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<YAxis
yAxisId="right"
orientation="right"
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
{metrics.activeUsers && (
<Line
yAxisId="left"
type="monotone"
dataKey="activeUsers"
name="Active Users"
stroke={METRIC_COLORS.activeUsers.color}
strokeWidth={2}
dot={false}
/>
)}
{metrics.newUsers && (
<Line
yAxisId="left"
type="monotone"
dataKey="newUsers"
name="New Users"
stroke={METRIC_COLORS.newUsers.color}
strokeWidth={2}
dot={false}
/>
)}
{metrics.pageViews && (
<Line
yAxisId="right"
type="monotone"
dataKey="pageViews"
name="Page Views"
stroke={METRIC_COLORS.pageViews.color}
strokeWidth={2}
dot={false}
/>
)}
{metrics.conversions && (
<Line
yAxisId="right"
type="monotone"
dataKey="conversions"
name="Conversions"
stroke={METRIC_COLORS.conversions.color}
strokeWidth={2}
dot={false}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
};
export default AnalyticsDashboard;
@@ -0,0 +1,456 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent } from '@/components/dashboard/ui/card';
import { Calendar as CalendarComponent } from '@/components/dashboard/ui/calendaredit';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/dashboard/ui/popover';
import { Alert, AlertDescription } from '@/components/dashboard/ui/alert';
import {
Sun,
Cloud,
CloudRain,
CloudDrizzle,
CloudSnow,
CloudLightning,
CloudFog,
CloudSun,
CircleAlert,
Tornado,
Haze,
Moon,
Wind,
Droplets,
ThermometerSun,
ThermometerSnowflake,
Sunrise,
Sunset,
AlertTriangle,
Umbrella,
ChevronLeft,
ChevronRight
} from 'lucide-react';
import { cn } from "@/lib/utils";
const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const [datetime, setDatetime] = useState(new Date());
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
const [isTimeChanging, setIsTimeChanging] = useState(false);
const [mounted, setMounted] = useState(false);
const [weather, setWeather] = useState(null);
const [forecast, setForecast] = useState(null);
useEffect(() => {
setTimeout(() => setMounted(true), 150);
const timer = setInterval(() => {
const newDate = new Date();
const newTime = getTimeComponents(newDate);
if (newTime.minutes !== prevTime.minutes) {
setIsTimeChanging(true);
setTimeout(() => setIsTimeChanging(false), 200);
}
setPrevTime(newTime);
setDatetime(newDate);
}, 1000);
return () => clearInterval(timer);
}, [prevTime]);
useEffect(() => {
const fetchWeatherData = async () => {
try {
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
const [weatherResponse, forecastResponse] = await Promise.all([
fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
),
fetch(
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
)
]);
const weatherData = await weatherResponse.json();
const forecastData = await forecastResponse.json();
setWeather(weatherData);
// Process forecast data to get daily forecasts with precipitation
const dailyForecasts = forecastData.list.reduce((acc, item) => {
const date = new Date(item.dt * 1000).toLocaleDateString();
if (!acc[date]) {
acc[date] = {
...item,
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
pop: item.pop * 100 // Probability of precipitation as percentage
};
}
return acc;
}, {});
setForecast(Object.values(dailyForecasts).slice(0, 5));
} catch (error) {
console.error("Error fetching weather:", error);
}
};
fetchWeatherData();
const weatherTimer = setInterval(fetchWeatherData, 300000);
return () => clearInterval(weatherTimer);
}, []);
function getTimeComponents(date) {
let hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12;
return {
hours: hours.toString(),
minutes: minutes.toString().padStart(2, '0'),
ampm
};
}
const formatDate = (date) => {
return {
weekday: date.toLocaleDateString('en-US', { weekday: 'long' }),
month: date.toLocaleDateString('en-US', { month: 'long' }),
day: date.getDate()
};
};
const getWeatherIcon = (weatherCode, currentTime, small = false) => {
if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />;
const code = parseInt(weatherCode, 10);
const iconProps = small ? "w-8 h-8" : "w-12 h-12";
const isNight = currentTime.getHours() >= 18 || currentTime.getHours() < 6;
switch (true) {
case code >= 200 && code < 300:
return <CloudLightning className={cn(iconProps, "text-yellow-300")} />;
case code >= 300 && code < 500:
return <CloudDrizzle className={cn(iconProps, "text-blue-300")} />;
case code >= 500 && code < 600:
return <CloudRain className={cn(iconProps, "text-blue-300")} />;
case code >= 600 && code < 700:
return <CloudSnow className={cn(iconProps, "text-blue-200")} />;
case code >= 700 && code < 721:
return <CloudFog className={cn(iconProps, "text-gray-300")} />;
case code === 721:
return <Haze className={cn(iconProps, "text-gray-300")} />;
case code >= 722 && code < 781:
return <CloudFog className={cn(iconProps, "text-gray-300")} />;
case code === 781:
return <Tornado className={cn(iconProps, "text-gray-300")} />;
case code === 800:
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
<Sun className={cn(iconProps, "text-yellow-300")} />
) : (
<Moon className={cn(iconProps, "text-gray-300")} />
);
case code >= 800 && code < 803:
return <CloudSun className={cn(iconProps, isNight ? "text-gray-300" : "text-gray-200")} />;
case code >= 803:
return <Cloud className={cn(iconProps, "text-gray-300")} />;
default:
return <CircleAlert className={cn(iconProps, "text-red-500")} />;
}
};
const getWeatherBackground = (weatherCode, isNight) => {
const code = parseInt(weatherCode, 10);
// Thunderstorm (200-299)
if (code >= 200 && code < 300) {
return "bg-gradient-to-br from-slate-900 to-purple-800";
}
// Drizzle (300-399)
if (code >= 300 && code < 400) {
return "bg-gradient-to-br from-slate-800 to-blue-800";
}
// Rain (500-599)
if (code >= 500 && code < 600) {
return "bg-gradient-to-br from-slate-800 to-blue-800";
}
// Snow (600-699)
if (code >= 600 && code < 700) {
return "bg-gradient-to-br from-slate-700 to-blue-800";
}
// Atmosphere (700-799: mist, smoke, haze, fog, etc.)
if (code >= 700 && code < 800) {
return "bg-gradient-to-br from-slate-700 to-slate-500";
}
// Clear (800)
if (code === 800) {
if (isNight) {
return "bg-gradient-to-br from-slate-900 to-blue-900";
}
return "bg-gradient-to-br from-blue-600 to-sky-400";
}
// Clouds (801-804)
if (code > 800) {
if (isNight) {
return "bg-gradient-to-br from-slate-800 to-slate-600";
}
return "bg-gradient-to-br from-slate-600 to-slate-400";
}
// Default fallback
return "bg-gradient-to-br from-slate-700 to-slate-500";
};
const getTemperatureColor = (weatherCode, isNight) => {
const code = parseInt(weatherCode, 10);
// Snow - dark background, light text
if (code >= 600 && code < 700) {
return "text-white";
}
// Clear day - light background, dark text
if (code === 800 && !isNight) {
return "text-white";
}
// Cloudy day - medium background, ensure contrast
if (code > 800 && !isNight) {
return "text-white";
}
// All other cases (darker backgrounds)
return "text-white";
};
const { hours, minutes, ampm } = getTimeComponents(datetime);
const dateInfo = formatDate(datetime);
const formatTime = (timestamp) => {
if (!timestamp) return '--:--';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
};
const WeatherDetails = () => (
<div className="space-y-4 p-3 bg-gradient-to-br from-slate-800 to-slate-700">
<div className="grid grid-cols-3 gap-2">
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<ThermometerSun className="w-5 h-5 text-yellow-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">High</span>
<span className="text-sm font-bold text-white">{Math.round(weather.main.temp_max)}°F</span>
</div>
</div>
</Card>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<ThermometerSnowflake className="w-5 h-5 text-blue-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Low</span>
<span className="text-sm font-bold text-white">{Math.round(weather.main.temp_min)}°F</span>
</div>
</div>
</Card>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<Droplets className="w-5 h-5 text-blue-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Humidity</span>
<span className="text-sm font-bold text-white">{weather.main.humidity}%</span>
</div>
</div>
</Card>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<Wind className="w-5 h-5 text-slate-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Wind</span>
<span className="text-sm font-bold text-white">{Math.round(weather.wind.speed)} mph</span>
</div>
</div>
</Card>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<Sunrise className="w-5 h-5 text-yellow-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Sunrise</span>
<span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunrise)}</span>
</div>
</div>
</Card>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<Sunset className="w-5 h-5 text-orange-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Sunset</span>
<span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunset)}</span>
</div>
</div>
</Card>
</div>
{forecast && (
<div>
<div className="grid grid-cols-5 gap-2">
{forecast.map((day, index) => {
const forecastTime = new Date(day.dt * 1000);
const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6;
return (
<Card
key={index}
className={cn(
getWeatherBackground(day.weather[0].id, isNight),
"p-2"
)}
>
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-white">
{forecastTime.toLocaleDateString('en-US', { weekday: 'short' })}
</span>
{getWeatherIcon(day.weather[0].id, forecastTime, true)}
<div className="flex justify-center gap-1 items-baseline w-full">
<span className="text-sm font-medium text-white">
{Math.round(day.main.temp_max)}°
</span>
<span className="text-xs text-slate-300">
{Math.round(day.main.temp_min)}°
</span>
</div>
<div className="flex flex-col items-center gap-1 w-full pt-1">
{day.rain?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudRain className="w-3 h-3 text-blue-300" />
<span className="text-xs text-white">{day.rain['3h'].toFixed(2)}"</span>
</div>
)}
{day.snow?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudSnow className="w-3 h-3 text-blue-300" />
<span className="text-xs text-white">{day.snow['3h'].toFixed(2)}"</span>
</div>
)}
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
<div className="flex items-center gap-1">
<Umbrella className="w-3 h-3 text-slate-300" />
<span className="text-xs text-white">0"</span>
</div>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
)}
</div>
);
return (
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
{/* Time Display */}
<Card className="bg-gradient-to-br mb-[7px] from-indigo-900 to-blue-800 backdrop-blur-sm dark:bg-slate-800 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
<CardContent className="p-3 h-[106px] flex items-center">
<div className="flex justify-center items-baseline w-full">
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
<span className="text-6xl font-bold text-white">{hours}</span>
<span className="text-6xl font-bold text-white">:</span>
<span className="text-6xl font-bold text-white">{minutes}</span>
<span className="text-lg font-medium text-white/90 ml-1">{ampm}</span>
</div>
</div>
</CardContent>
</Card>
{/* Date and Weather Display */}
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
<Card className="h-full bg-gradient-to-br from-violet-900 to-purple-800 backdrop-blur-sm flex items-center justify-center">
<CardContent className="h-full p-0">
<div className="flex flex-col items-center justify-center h-full">
<span className="text-6xl font-bold text-white">
{dateInfo.day}
</span>
<span className="text-sm font-bold text-white mt-2">
{dateInfo.weekday}
</span>
</div>
</CardContent>
</Card>
{weather?.main && (
<Popover>
<PopoverTrigger asChild>
<Card className={cn(
getWeatherBackground(
weather.weather[0]?.id,
datetime.getHours() >= 18 || datetime.getHours() < 6
),
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative backdrop-blur-sm"
)}>
<CardContent className="h-full p-3">
<div className="flex flex-col items-center">
{getWeatherIcon(weather.weather[0]?.id, datetime)}
<span className="text-3xl font-bold ml-1 mt-2 text-white">
{Math.round(weather.main.temp)}°
</span>
</div>
</CardContent>
{weather.alerts && (
<div className="absolute top-1 right-1">
<AlertTriangle className="w-5 h-5 text-red-500" />
</div>
)}
</Card>
</PopoverTrigger>
<PopoverContent
className="w-[450px] bg-gradient-to-br from-slate-800 to-slate-700 border-slate-600"
align="start"
side="right"
sideOffset={10}
style={{
transform: `scale(${scaleFactor})`,
transformOrigin: 'left top'
}}
>
{weather.alerts && (
<Alert variant="warning" className="mb-3 bg-amber-900/50 border-amber-700">
<AlertTriangle className="h-3 w-3 text-amber-500" />
<AlertDescription className="text-xs text-amber-200">
{weather.alerts[0].event}
</AlertDescription>
</Alert>
)}
<WeatherDetails />
</PopoverContent>
</Popover>
)}
</div>
{/* Calendar Display */}
<Card className="w-full bg-white dark:bg-slate-800">
<CardContent className="p-0">
<CalendarComponent
selected={datetime}
className="w-full"
/>
</CardContent>
</Card>
</div>
);
};
export default DateTimeWeatherDisplay;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,580 @@
import React, { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader } from "@/components/dashboard/ui/card";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/dashboard/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/dashboard/ui/table";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
import {
Clock,
Star,
MessageSquare,
Mail,
Send,
Loader2,
ArrowUp,
ArrowDown,
Zap,
Timer,
BarChart3,
ClipboardCheck,
} from "lucide-react";
import axios from "axios";
const TIME_RANGES = {
"today": "Today",
"7": "Last 7 Days",
"14": "Last 14 Days",
"30": "Last 30 Days",
"90": "Last 90 Days",
};
const formatDuration = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
};
const getDateRange = (days) => {
// Create date in Eastern Time
const now = new Date();
const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" })
);
if (days === "today") {
// For today, set the range to be the current day in Eastern Time
const start = new Date(easternTime);
start.setHours(0, 0, 0, 0);
const end = new Date(easternTime);
end.setHours(23, 59, 59, 999);
return {
start_datetime: start.toISOString(),
end_datetime: end.toISOString()
};
}
// For other periods, calculate from end of previous day
const end = new Date(easternTime);
end.setHours(23, 59, 59, 999);
const start = new Date(easternTime);
start.setDate(start.getDate() - Number(days));
start.setHours(0, 0, 0, 0);
return {
start_datetime: start.toISOString(),
end_datetime: end.toISOString()
};
};
const MetricCard = ({
title,
value,
delta,
suffix = "",
icon: Icon,
colorClass = "blue",
more_is_better = true,
loading = false,
}) => {
const getDeltaColor = (d) => {
if (d === 0) return "text-gray-600 dark:text-gray-400";
const isPositive = d > 0;
return isPositive === more_is_better
? "text-green-600 dark:text-green-500"
: "text-red-600 dark:text-red-500";
};
const formatDelta = (d) => {
if (d === undefined || d === null) return null;
if (d === 0) return "0";
return Math.abs(d) + suffix;
};
return (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
{loading ? (
<>
<Skeleton className="h-4 w-24 mb-4 dark:bg-gray-700" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-8 w-20 dark:bg-gray-700" />
<Skeleton className="h-4 w-12 dark:bg-gray-700" />
</div>
</>
) : (
<>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold">
{typeof value === "number"
? value.toLocaleString() + suffix
: value}
</p>
{delta !== undefined && delta !== 0 && (
<div className={`flex items-center ${getDeltaColor(delta)}`}>
{delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span className="text-xs font-medium">
{formatDelta(delta)}
</span>
</div>
)}
</div>
</>
)}
</div>
{!loading && Icon && (
<Icon className={`h-5 w-5 flex-shrink-0 ml-2 ${colorClass === "blue" ? "text-blue-500" :
colorClass === "green" ? "text-green-500" :
colorClass === "purple" ? "text-purple-500" :
colorClass === "indigo" ? "text-indigo-500" :
colorClass === "orange" ? "text-orange-500" :
colorClass === "teal" ? "text-teal-500" :
colorClass === "cyan" ? "text-cyan-500" :
"text-blue-500"}`} />
)}
{loading && (
<Skeleton className="h-5 w-5 rounded-full dark:bg-gray-700" />
)}
</div>
</CardContent>
</Card>
);
};
const SkeletonMetricCard = () => (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<Skeleton className="h-4 w-24 mb-4 bg-muted" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-8 w-20 bg-muted" />
<Skeleton className="h-4 w-12 bg-muted" />
</div>
</div>
<Skeleton className="h-5 w-5 rounded-full bg-muted" />
</div>
</CardContent>
</Card>
);
const TableSkeleton = () => (
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(5)].map((_, i) => (
<TableRow key={i} className="dark:border-gray-800">
<TableCell><Skeleton className="h-4 w-32 bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
const GorgiasOverview = () => {
const [timeRange, setTimeRange] = useState("7");
const [data, setData] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const loadStats = useCallback(async () => {
setLoading(true);
const filters = getDateRange(timeRange);
try {
const [overview, channelStats, agentStats, satisfaction] =
await Promise.all([
axios.post('/api/gorgias/stats/overview', filters)
.then(res => res.data?.data?.data?.data || []),
axios.post('/api/gorgias/stats/tickets-created-per-channel', filters)
.then(res => res.data?.data?.data?.data?.lines || []),
axios.post('/api/gorgias/stats/tickets-closed-per-agent', filters)
.then(res => res.data?.data?.data?.data?.lines || []),
axios.post('/api/gorgias/stats/satisfaction-surveys', filters)
.then(res => res.data?.data?.data?.data || []),
]);
console.log('Raw API responses:', {
overview,
channelStats,
agentStats,
satisfaction,
});
setData({
overview,
channels: channelStats,
agents: agentStats,
satisfaction,
});
setError(null);
} catch (err) {
console.error("Error loading stats:", err);
const errorMessage = err.response?.data?.error || err.message;
setError(errorMessage);
if (err.response?.status === 401) {
setError('Authentication failed. Please check your Gorgias API credentials.');
}
} finally {
setLoading(false);
}
}, [timeRange]);
useEffect(() => {
loadStats();
// Set up auto-refresh every 5 minutes
const interval = setInterval(loadStats, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [loadStats]);
// Convert overview array to stats format
const stats = (data.overview || []).reduce((acc, item) => {
acc[item.name] = {
value: item.value || 0,
delta: item.delta || 0,
type: item.type,
more_is_better: item.more_is_better
};
return acc;
}, {});
console.log('Processed stats:', stats);
// Process satisfaction data
const satisfactionStats = (data.satisfaction || []).reduce((acc, item) => {
if (item.name !== 'response_distribution') {
acc[item.name] = {
value: item.value || 0,
delta: item.delta || 0,
type: item.type,
more_is_better: item.more_is_better
};
}
return acc;
}, {});
console.log('Processed satisfaction stats:', satisfactionStats);
// Process channel data
const channels = data.channels?.map(line => ({
name: line[0]?.value || '',
total: line[1]?.value || 0,
percentage: line[2]?.value || 0,
delta: line[3]?.value || 0
})) || [];
console.log('Processed channels:', channels);
// Process agent data
const agents = data.agents?.map(line => ({
name: line[0]?.value || '',
closed: line[1]?.value || 0,
rating: line[2]?.value,
percentage: line[3]?.value || 0,
delta: line[4]?.value || 0
})) || [];
console.log('Processed agents:', agents);
if (error) {
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
{error}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Customer Service
</h2>
<div className="flex items-center gap-2">
<Select
value={timeRange}
onValueChange={(value) => setTimeRange(value)}
>
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800">
<SelectValue placeholder="Select range">
{TIME_RANGES[timeRange]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[
["today", "Today"],
["7", "Last 7 Days"],
["14", "Last 14 Days"],
["30", "Last 30 Days"],
["90", "Last 90 Days"],
].map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Message & Response Metrics */}
{loading ? (
[...Array(7)].map((_, i) => (
<SkeletonMetricCard key={i} />
))
) : (
<>
<div className="h-full">
<MetricCard
title="Messages Received"
value={stats.total_messages_received?.value}
delta={stats.total_messages_received?.delta}
icon={Mail}
colorClass="blue"
loading={loading}
/>
</div>
<div className="h-full">
<MetricCard
title="Messages Sent"
value={stats.total_messages_sent?.value}
delta={stats.total_messages_sent?.delta}
icon={Send}
colorClass="green"
loading={loading}
/>
</div>
<div className="h-full">
<MetricCard
title="First Response"
value={formatDuration(stats.median_first_response_time?.value)}
delta={stats.median_first_response_time?.delta}
icon={Zap}
colorClass="purple"
more_is_better={false}
loading={loading}
/>
</div>
<div className="h-full">
<MetricCard
title="One-Touch Rate"
value={stats.total_one_touch_tickets?.value}
delta={stats.total_one_touch_tickets?.delta}
suffix="%"
icon={BarChart3}
colorClass="indigo"
loading={loading}
/>
</div>
<div className="h-full">
<MetricCard
title="Customer Satisfaction"
value={`${satisfactionStats.average_rating?.value}/5`}
delta={satisfactionStats.average_rating?.delta}
suffix="%"
icon={Star}
colorClass="orange"
loading={loading}
/>
</div>
<div className="h-full">
<MetricCard
title="Survey Response Rate"
value={satisfactionStats.response_rate?.value}
delta={satisfactionStats.response_rate?.delta}
suffix="%"
icon={ClipboardCheck}
colorClass="pink"
loading={loading}
/>
</div>
<div className="h-full">
<MetricCard
title="Resolution Time"
value={formatDuration(stats.median_resolution_time?.value)}
delta={stats.median_resolution_time?.delta}
icon={Timer}
colorClass="teal"
more_is_better={false}
loading={loading}
/>
</div>
</>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Channel Distribution */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="pb-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Channel Distribution
</h3>
</CardHeader>
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
{loading ? (
<TableSkeleton />
) : (
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-left font-medium text-gray-900 dark:text-gray-100">Channel</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Total</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">%</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Change</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channels
.sort((a, b) => b.total - a.total)
.map((channel, index) => (
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
<TableCell className="text-gray-900 dark:text-gray-100">
{channel.name}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{channel.total}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{channel.percentage}%
</TableCell>
<TableCell
className={`text-right ${
channel.delta > 0
? "text-green-600 dark:text-green-500"
: channel.delta < 0
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
<div className="flex items-center justify-end gap-0.5">
{channel.delta !== 0 && (
<>
{channel.delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span>{Math.abs(channel.delta)}%</span>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Agent Performance */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="pb-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Agent Performance
</h3>
</CardHeader>
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
{loading ? (
<TableSkeleton />
) : (
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-left font-medium text-gray-900 dark:text-gray-100">Agent</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Closed</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Rating</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Change</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agents
.filter((agent) => agent.name !== "Unassigned")
.map((agent, index) => (
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
<TableCell className="text-gray-900 dark:text-gray-100">
{agent.name}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{agent.closed}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{agent.rating ? `${agent.rating}/5` : "-"}
</TableCell>
<TableCell
className={`text-right ${
agent.delta > 0
? "text-green-600 dark:text-green-500"
: agent.delta < 0
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
<div className="flex items-center justify-end gap-0.5">
{agent.delta !== 0 && (
<>
{agent.delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span>{Math.abs(agent.delta)}%</span>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</CardContent>
</Card>
);
};
export default GorgiasOverview;
@@ -0,0 +1,376 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/dashboard/ui/card";
import {
Calendar,
Clock,
Sun,
Cloud,
CloudRain,
CloudDrizzle,
CloudSnow,
CloudLightning,
CloudFog,
CloudSun,
CircleAlert,
Tornado,
Haze,
Moon,
Monitor,
Wind,
Droplets,
ThermometerSun,
ThermometerSnowflake,
Sunrise,
Sunset,
AlertTriangle,
Umbrella,
} from "lucide-react";
import { useScroll } from "@/contexts/DashboardScrollContext";
import { useTheme } from "@/components/dashboard/theme/ThemeProvider";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/dashboard/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/dashboard/ui/popover";
import { Alert, AlertDescription } from "@/components/dashboard/ui/alert";
const CraftsIcon = () => (
<svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true">
<path
fill="white"
d="M911.230469 1807.75C974.730469 1695.5 849.919922 1700.659912 783.610352 1791.25C645.830078 1979.439941 874.950195 2120.310059 1112.429688 2058.800049C1201.44043 2035.72998 1278.759766 2003.080078 1344.580078 1964.159912C1385.389648 1940.040039 1380.900391 1926.060059 1344.580078 1935.139893C1294.040039 1947.800049 1261.69043 1953.73999 1177.700195 1966.97998C1084.719727 1981.669922 832.790039 1984.22998 911.230469 1807.75M1046.799805 1631.389893C1135.280273 1670.419922 1139.650391 1624.129883 1056.980469 1562.070068C925.150391 1463.110107 787.360352 1446.379883 661.950195 1478.280029C265.379883 1579.179932 67.740234 2077.050049 144.099609 2448.399902C357.860352 3487.689941 1934.570313 3457.959961 2143.030273 2467.540039C2204.700195 2174.439941 2141.950195 1852.780029 1917.990234 1665.149902C1773.219727 1543.870117 1575.009766 1536.659912 1403.599609 1591.72998C1380.639648 1599.110107 1381.410156 1616.610107 1403.599609 1612.379883C1571.25 1596.040039 1750.790039 1606 1856.75 1745.280029C2038.769531 1984.459961 2052.570313 2274.080078 1974.629883 2511.209961C1739.610352 3226.25 640.719727 3226.540039 401.719727 2479.26001C308.040039 2186.350098 400.299805 1788.800049 690 1639.100098C785.830078 1589.590088 907.040039 1569.709961 1046.799805 1631.389893Z"
/>
<path
fill="white"
d="M1270.089844 1727.72998C1292.240234 1431.47998 1284.94043 952.430176 1257.849609 717.390137C1235.679688 525.310059 1166.200195 416.189941 1093.629883 349.390137C1157.620117 313.180176 1354.129883 485.680176 1447.830078 603.350098C1790.870117 1034.100098 2235.580078 915.060059 2523.480469 721.129883C2569.120117 680.51001 2592.900391 654.030029 2523.480469 651.339844C2260.400391 615.330078 2115 463.060059 1947.530273 293.890137C1672.870117 16.459961 1143.719727 162.169922 1033.969727 303.040039C999.339844 280.299805 966.849609 265 941.709961 252.419922C787.139648 175.160156 670.049805 223.580078 871.780273 341.569824C962.599609 394.689941 1089.849609 483.48999 1168.230469 799.589844C1222.370117 1018.040039 1230.009766 1423.919922 1242.360352 1728.379883C1247 1761.850098 1264.799805 1759.629883 1270.089844 1727.72998"
/>
</svg>
);
const Header = () => {
const [currentTime, setCurrentTime] = useState(new Date());
const [weather, setWeather] = useState(null);
const [forecast, setForecast] = useState(null);
const { isStuck } = useScroll();
const { theme, systemTheme, toggleTheme, setTheme } = useTheme();
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const fetchWeatherData = async () => {
try {
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
const [weatherResponse, forecastResponse] = await Promise.all([
fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
),
fetch(
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
)
]);
const weatherData = await weatherResponse.json();
const forecastData = await forecastResponse.json();
setWeather(weatherData);
// Process forecast data to get daily forecasts
const dailyForecasts = forecastData.list.reduce((acc, item) => {
const date = new Date(item.dt * 1000).toLocaleDateString();
if (!acc[date]) {
acc[date] = {
...item,
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
pop: item.pop * 100
};
}
return acc;
}, {});
setForecast(Object.values(dailyForecasts).slice(0, 5));
} catch (error) {
console.error("Error fetching weather:", error);
}
};
fetchWeatherData();
const weatherTimer = setInterval(fetchWeatherData, 300000);
return () => clearInterval(weatherTimer);
}, []);
const getWeatherIcon = (weatherCode, currentTime, small = false) => {
if (!weatherCode) return <CircleAlert className={cn(small ? "w-6 h-6" : "w-7 h-7", "text-red-500")} />;
const code = parseInt(weatherCode, 10);
const iconProps = small ? "w-6 h-6" : "w-7 h-7";
switch (true) {
case code >= 200 && code < 300:
return <CloudLightning className={cn(iconProps, "text-gray-700")} />;
case code >= 300 && code < 500:
return <CloudDrizzle className={cn(iconProps, "text-blue-600")} />;
case code >= 500 && code < 600:
return <CloudRain className={cn(iconProps, "text-blue-600")} />;
case code >= 600 && code < 700:
return <CloudSnow className={cn(iconProps, "text-blue-400")} />;
case code >= 700 && code < 721:
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
case code === 721:
return <Haze className={cn(iconProps, "text-gray-700")} />;
case code >= 722 && code < 781:
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
case code === 781:
return <Tornado className={cn(iconProps, "text-gray-700")} />;
case code === 800:
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
<Sun className={cn(iconProps, "text-yellow-500")} />
) : (
<Moon className={cn(iconProps, "text-gray-300")} />
);
case code >= 800 && code < 803:
return <CloudSun className={cn(iconProps, "text-gray-600")} />;
case code >= 803:
return <Cloud className={cn(iconProps, "text-gray-600")} />;
default:
return <CircleAlert className={cn(iconProps, "text-red-500")} />;
}
};
const formatTime = (timestamp) => {
if (!timestamp) return '--:--';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
};
const WeatherDetails = () => (
<div className="space-y-4 p-3">
<div className="grid grid-cols-3 gap-2">
<Card className="p-2">
<div className="flex items-center gap-1">
<ThermometerSun className="w-5 h-5 text-orange-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">High</span>
<span className="text-sm font-bold">{Math.round(weather.main.temp_max)}°F</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<ThermometerSnowflake className="w-5 h-5 text-blue-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Low</span>
<span className="text-sm font-bold">{Math.round(weather.main.temp_min)}°F</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Droplets className="w-5 h-5 text-blue-400" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Humidity</span>
<span className="text-sm font-bold">{weather.main.humidity}%</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Wind className="w-5 h-5 text-gray-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Wind</span>
<span className="text-sm font-bold">{Math.round(weather.wind.speed)} mph</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Sunrise className="w-5 h-5 text-yellow-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Sunrise</span>
<span className="text-sm font-bold">{formatTime(weather.sys?.sunrise)}</span>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Sunset className="w-5 h-5 text-orange-400" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Sunset</span>
<span className="text-sm font-bold">{formatTime(weather.sys?.sunset)}</span>
</div>
</div>
</Card>
</div>
{forecast && (
<div>
<div className="grid grid-cols-5 gap-2">
{forecast.map((day, index) => (
<Card key={index} className="p-2">
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium">
{new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })}
</span>
{getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)}
<div className="flex justify-center gap-1 items-baseline w-full">
<span className="text-sm font-bold">
{Math.round(day.main.temp_max)}°
</span>
<span className="text-xs text-muted-foreground">
{Math.round(day.main.temp_min)}°
</span>
</div>
<div className="flex flex-col items-center gap-1 w-full pt-1">
{day.rain?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudRain className="w-3 h-3 text-blue-400" />
<span className="text-xs">{day.rain['3h'].toFixed(2)}"</span>
</div>
)}
{day.snow?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudSnow className="w-3 h-3 text-blue-400" />
<span className="text-xs">{day.snow['3h'].toFixed(2)}"</span>
</div>
)}
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
<div className="flex items-center gap-1">
<Umbrella className="w-3 h-3 text-gray-400" />
<span className="text-xs">0"</span>
</div>
)}
</div>
</div>
</Card>
))}
</div>
</div>
)}
</div>
);
const formatDate = (date) =>
date.toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
});
const formatTimeDisplay = (date) => {
const hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
const period = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes}:${seconds} ${period}`;
};
return (
<Card
className={cn(
"w-full bg-white dark:bg-gray-900 shadow-sm",
isStuck ? "rounded-b-lg border-b-1" : "border-b-0 rounded-b-none"
)}
>
<CardContent className="p-4">
<div className="flex flex-col justify-between lg:flex-row items-center sm:items-center flex-wrap">
<div className="flex items-center space-x-4">
<div className="flex space-x-2">
<div
onClick={toggleTheme}
className={cn(
"bg-gradient-to-r from-blue-500 to-blue-600 p-3 rounded-lg shadow-md cursor-pointer hover:opacity-90 transition-opacity",
theme === "light" && "ring-1 ring-yellow-300",
theme === "dark" && "ring-1 ring-purple-300",
"ring-offset-2 ring-offset-white dark:ring-offset-gray-900"
)}
>
<CraftsIcon />
</div>
</div>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-blue-400 bg-clip-text text-transparent">
ACOT Dashboard
</h1>
</div>
</div>
<div className="flex items-left sm:items-center justify-center flex-wrap mt-2 sm:mt-0">
{weather?.main && (
<>
<div className="flex-col items-center text-center">
<Popover>
<PopoverTrigger asChild>
<div className="items-center justify-center space-x-2 rounded-lg px-4 hidden sm:flex cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors p-2">
{getWeatherIcon(weather.weather[0]?.id, currentTime)}
<div>
<p className="text-xl font-bold tracking-tight dark:text-gray-100">
{Math.round(weather.main.temp)}° F
</p>
</div>
{weather.alerts && (
<AlertTriangle className="w-5 h-5 text-red-500 ml-1" />
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-[450px]" align="end" side="bottom" sideOffset={5}>
{weather.alerts && (
<Alert variant="warning" className="mb-3">
<AlertTriangle className="h-3 w-3" />
<AlertDescription className="text-xs">
{weather.alerts[0].event}
</AlertDescription>
</Alert>
)}
<WeatherDetails />
</PopoverContent>
</Popover>
</div>
</>
)}
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
<Calendar className="w-5 h-5 text-green-500 shrink-0" />
<div>
<p className="text-sm sm:text-xl font-bold tracking-tight p-0 dark:text-gray-100">
{formatDate(currentTime)}
</p>
</div>
</div>
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
<Clock className="w-5 h-5 text-blue-500 shrink-0" />
<div>
<p className="text-md sm:text-xl font-bold tracking-tight tabular-nums dark:text-gray-100 mr-2">
{formatTimeDisplay(currentTime)}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
};
export default Header;
@@ -0,0 +1,477 @@
import React, { useState, useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/dashboard/ui/tooltip";
import { DateTime } from "luxon";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/dashboard/ui/select";
import { Button } from "@/components/dashboard/ui/button";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { Mail, MessageSquare, ArrowUpDown, BookOpen } from "lucide-react";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
// Helper functions for formatting
const formatRate = (value, isSMS = false, hideForSMS = false) => {
if (isSMS && hideForSMS) return "N/A";
if (typeof value !== "number") return "0.0%";
return `${(value * 100).toFixed(1)}%`;
};
const formatCurrency = (value) => {
if (typeof value !== "number") return "$0";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
// Loading skeleton component
const TableSkeleton = () => (
<table className="w-full">
<thead>
<tr>
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
<Skeleton className="h-8 w-24 bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{[...Array(15)].map((_, i) => (
<tr key={i} className="hover:bg-muted/50 transition-colors">
<td className="p-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 bg-muted" />
<div className="space-y-2">
<Skeleton className="h-4 w-48 bg-muted" />
<Skeleton className="h-3 w-64 bg-muted" />
<Skeleton className="h-3 w-32 bg-muted" />
</div>
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
</tr>
))}
</tbody>
</table>
);
// Error alert component
const ErrorAlert = ({ description }) => (
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
{description}
</div>
);
// MetricCell component for displaying campaign metrics
const MetricCell = ({
value,
count,
isMonetary = false,
showConversionRate = false,
totalRecipients = 0,
isSMS = false,
hideForSMS = false,
}) => {
if (isSMS && hideForSMS) {
return (
<td className="p-2 text-center">
<div className="text-muted-foreground text-lg font-semibold">N/A</div>
<div className="text-muted-foreground text-sm">-</div>
</td>
);
}
return (
<td className="p-2 text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
</div>
<div className="text-muted-foreground text-sm">
{count?.toLocaleString() || 0} {count === 1 ? "recipient" : "recipients"}
{showConversionRate &&
totalRecipients > 0 &&
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
</div>
</td>
);
};
const KlaviyoCampaigns = ({ className }) => {
const [campaigns, setCampaigns] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedChannels, setSelectedChannels] = useState({ email: true, sms: true, blog: true });
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
const [sortConfig, setSortConfig] = useState({
key: "send_time",
direction: "desc",
});
const handleSort = (key) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
};
const fetchCampaigns = async () => {
try {
setIsLoading(true);
const response = await fetch(
`/api/klaviyo/reporting/campaigns/${selectedTimeRange}`
);
if (!response.ok) {
throw new Error(`Failed to fetch campaigns: ${response.status}`);
}
const data = await response.json();
setCampaigns(data.data || []);
setError(null);
} catch (err) {
console.error("Error fetching campaigns:", err);
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCampaigns();
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes
return () => clearInterval(interval);
}, [selectedTimeRange]); // Only refresh when time range changes
// Sort campaigns
const sortedCampaigns = [...campaigns].sort((a, b) => {
const direction = sortConfig.direction === "desc" ? -1 : 1;
switch (sortConfig.key) {
case "send_time":
return direction * (DateTime.fromISO(a.send_time) - DateTime.fromISO(b.send_time));
case "delivery_rate":
return direction * ((a.stats.delivery_rate || 0) - (b.stats.delivery_rate || 0));
case "open_rate":
return direction * ((a.stats.open_rate || 0) - (b.stats.open_rate || 0));
case "click_rate":
return direction * ((a.stats.click_rate || 0) - (b.stats.click_rate || 0));
case "click_to_open_rate":
return direction * ((a.stats.click_to_open_rate || 0) - (b.stats.click_to_open_rate || 0));
case "conversion_value":
return direction * ((a.stats.conversion_value || 0) - (b.stats.conversion_value || 0));
default:
return 0;
}
});
// Filter campaigns by search term and channels
const filteredCampaigns = sortedCampaigns.filter(
(campaign) => {
const isBlog = campaign?.name?.includes("_Blog");
const channelType = isBlog ? "blog" : campaign?.channel;
return campaign?.name?.toLowerCase().includes((searchTerm || "").toLowerCase()) &&
selectedChannels[channelType];
}
);
if (isLoading) {
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<div className="flex justify-between items-center">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
<Skeleton className="h-6 w-48 bg-muted" />
</CardTitle>
<div className="flex gap-2">
<div className="flex ml-1 gap-1 items-center">
<Skeleton className="h-8 w-20 bg-muted" />
<Skeleton className="h-8 w-20 bg-muted" />
</div>
<Skeleton className="h-8 w-[130px] bg-muted" />
</div>
</div>
</CardHeader>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<TableSkeleton />
</CardContent>
</Card>
);
}
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
{error && <ErrorAlert description={error} />}
<CardHeader className="pb-2">
<div className="flex justify-between items-center">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Klaviyo Campaigns
</CardTitle>
<div className="flex gap-2">
<div className="flex ml-1 gap-1 items-center">
<Button
variant={selectedChannels.email ? "default" : "outline"}
size="sm"
onClick={() => setSelectedChannels(prev => {
if (prev.email && Object.values(prev).filter(Boolean).length === 1) {
// If only email is selected, show all
return { email: true, sms: true, blog: true };
}
// Show only email
return { email: true, sms: false, blog: false };
})}
>
<Mail className="h-4 w-4" />
<span className="hidden sm:inline">Email</span>
</Button>
<Button
variant={selectedChannels.sms ? "default" : "outline"}
size="sm"
onClick={() => setSelectedChannels(prev => {
if (prev.sms && Object.values(prev).filter(Boolean).length === 1) {
// If only SMS is selected, show all
return { email: true, sms: true, blog: true };
}
// Show only SMS
return { email: false, sms: true, blog: false };
})}
>
<MessageSquare className="h-4 w-4" />
<span className="hidden sm:inline">SMS</span>
</Button>
<Button
variant={selectedChannels.blog ? "default" : "outline"}
size="sm"
onClick={() => setSelectedChannels(prev => {
if (prev.blog && Object.values(prev).filter(Boolean).length === 1) {
// If only blog is selected, show all
return { email: true, sms: true, blog: true };
}
// Show only blog
return { email: false, sms: false, blog: true };
})}
>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline">Blog</span>
</Button>
</div>
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<table className="w-full">
<thead>
<tr>
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant="ghost"
onClick={() => handleSort("send_time")}
className="w-full justify-start h-8"
>
Campaign
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "delivery_rate" ? "default" : "ghost"}
onClick={() => handleSort("delivery_rate")}
className="w-full justify-center h-8"
>
Delivery
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "open_rate" ? "default" : "ghost"}
onClick={() => handleSort("open_rate")}
className="w-full justify-center h-8"
>
Opens
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "click_rate" ? "default" : "ghost"}
onClick={() => handleSort("click_rate")}
className="w-full justify-center h-8"
>
Clicks
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "click_to_open_rate" ? "default" : "ghost"}
onClick={() => handleSort("click_to_open_rate")}
className="w-full justify-center h-8"
>
CTR
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "conversion_value" ? "default" : "ghost"}
onClick={() => handleSort("conversion_value")}
className="w-full justify-center h-8"
>
Orders
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filteredCampaigns.map((campaign) => (
<tr
key={campaign.id}
className="hover:bg-muted/50 transition-colors"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<td className="p-2 align-top">
<div className="flex items-center gap-2">
{campaign.name?.includes("_Blog") ? (
<BookOpen className="h-4 w-4 text-muted-foreground" />
) : campaign.channel === 'sms' ? (
<MessageSquare className="h-4 w-4 text-muted-foreground" />
) : (
<Mail className="h-4 w-4 text-muted-foreground" />
)}
<div className="font-medium text-gray-900 dark:text-gray-100">
{campaign.name}
</div>
</div>
<div className="text-sm text-muted-foreground truncate max-w-[300px]">
{campaign.subject}
</div>
<div className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</div>
</td>
</TooltipTrigger>
<TooltipContent
side="top"
className="break-words bg-white dark:bg-gray-900/60 backdrop-blur-sm text-gray-900 dark:text-gray-100 border dark:border-gray-800"
>
<p className="font-medium">{campaign.name}</p>
<p>{campaign.subject}</p>
<p className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<MetricCell
value={campaign.stats.delivery_rate}
count={campaign.stats.delivered}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
<MetricCell
value={campaign.stats.open_rate}
count={campaign.stats.opens_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
<MetricCell
value={campaign.stats.click_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
<MetricCell
value={campaign.stats.click_to_open_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.opens_unique}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
<MetricCell
value={campaign.stats.conversion_value}
count={campaign.stats.conversion_uniques}
isMonetary={true}
showConversionRate={true}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);
};
export default KlaviyoCampaigns;
@@ -0,0 +1,28 @@
import React from 'react';
import { Button } from "@/components/dashboard/ui/button";
import { Lock } from "lucide-react";
const LockButton = () => {
const handleLock = () => {
// Remove PIN verification from session storage
sessionStorage.removeItem('pinVerified');
// Reset attempt count
localStorage.removeItem('pinAttempts');
localStorage.removeItem('lastAttemptTime');
// Force reload to show PIN screen
window.location.reload();
};
return (
<Button
variant="ghost"
size="icon"
className="hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={handleLock}
>
<Lock className="h-5 w-5" />
</Button>
);
};
export default LockButton;
@@ -0,0 +1,737 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/dashboard/ui/tooltip";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/dashboard/ui/select";
import {
Instagram,
Loader2,
Users,
DollarSign,
Eye,
Repeat,
MousePointer,
BarChart,
Target,
ShoppingCart,
MessageCircle,
Hash,
} from "lucide-react";
import { Button } from "@/components/dashboard/ui/button";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
// Helper functions for formatting
const formatCurrency = (value, decimalPlaces = 2) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(value || 0);
const formatNumber = (value, decimalPlaces = 0) => {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(value || 0);
};
const formatPercent = (value, decimalPlaces = 2) =>
`${(value || 0).toFixed(decimalPlaces)}%`;
const summaryCard = (label, value, options = {}) => {
const {
isMonetary = false,
isPercentage = false,
decimalPlaces = 0,
icon: Icon,
iconColor,
} = options;
let displayValue;
if (isMonetary) {
displayValue = formatCurrency(value, decimalPlaces);
} else if (isPercentage) {
displayValue = formatPercent(value, decimalPlaces);
} else {
displayValue = formatNumber(value, decimalPlaces);
}
return (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{displayValue}</p>
</div>
{Icon && (
<Icon className={`h-5 w-5 flex-shrink-0 ml-2 ${iconColor || "text-blue-500"}`} />
)}
</div>
</CardContent>
</Card>
);
};
const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
const formattedValue = isMonetary
? formatCurrency(value, decimalPlaces)
: isPercentage
? formatPercent(value, decimalPlaces)
: formatNumber(value, decimalPlaces);
return (
<td className="p-2 text-center align-top">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{formattedValue}
</div>
{(label || sublabel) && (
<div className="text-muted-foreground text-sm">
{label || sublabel}
</div>
)}
</td>
);
};
const getActionValue = (campaign, actionType) => {
if (actionType === "impressions" || actionType === "reach") {
return campaign.metrics[actionType] || 0;
}
const actions = campaign.metrics.actions;
if (Array.isArray(actions)) {
const action = actions.find((a) => a.action_type === actionType);
return action ? parseInt(action.value) || 0 : 0;
}
return 0;
};
const CampaignName = ({ name }) => {
if (name.startsWith("Instagram post: ")) {
return (
<div className="flex items-center space-x-2">
<Instagram className="w-4 h-4" />
<span>{name.replace("Instagram post: ", "")}</span>
</div>
);
}
return <span>{name}</span>;
};
const getObjectiveAction = (campaignObjective) => {
const objectiveMap = {
OUTCOME_AWARENESS: { action_type: "impressions", label: "Impressions" },
OUTCOME_ENGAGEMENT: { action_type: "post_engagement", label: "Post Engagements" },
OUTCOME_TRAFFIC: { action_type: "link_click", label: "Link Clicks" },
OUTCOME_LEADS: { action_type: "lead", label: "Leads" },
OUTCOME_SALES: { action_type: "purchase", label: "Purchases" },
MESSAGES: { action_type: "messages", label: "Messages" },
};
return objectiveMap[campaignObjective] || { action_type: "link_click", label: "Link Clicks" };
};
const calculateBudget = (campaign) => {
if (campaign.daily_budget) {
return { value: campaign.daily_budget / 100, type: "day" };
}
if (campaign.lifetime_budget) {
return { value: campaign.lifetime_budget / 100, type: "lifetime" };
}
const adsets = campaign.adsets?.data || [];
const dailyTotal = adsets.reduce((sum, adset) => sum + (adset.daily_budget || 0), 0);
const lifetimeTotal = adsets.reduce((sum, adset) => sum + (adset.lifetime_budget || 0), 0);
if (dailyTotal > 0) return { value: dailyTotal / 100, type: "day" };
if (lifetimeTotal > 0) return { value: lifetimeTotal / 100, type: "lifetime" };
return { value: 0, type: "day" };
};
const processMetrics = (campaign) => {
const insights = campaign.insights?.data?.[0] || {};
const spend = parseFloat(insights.spend || 0);
const impressions = parseInt(insights.impressions || 0);
const clicks = parseInt(insights.clicks || 0);
const reach = parseInt(insights.reach || 0);
const cpc = parseFloat(insights.cpc || 0);
const ctr = parseFloat(insights.ctr || 0);
const cpm = parseFloat(insights.cpm || 0);
const frequency = parseFloat(insights.frequency || 0);
// Purchase value and total purchases
const purchaseValue = (insights.action_values || [])
.filter(({ action_type }) => action_type === "purchase")
.reduce((sum, { value }) => sum + parseFloat(value || 0), 0);
const totalPurchases = (insights.actions || [])
.filter(({ action_type }) => action_type === "purchase")
.reduce((sum, { value }) => sum + parseInt(value || 0), 0);
// Aggregate unique actions
const actionMap = new Map();
(insights.actions || []).forEach(({ action_type, value }) => {
const currentValue = actionMap.get(action_type) || 0;
actionMap.set(action_type, currentValue + parseInt(value || 0));
});
const actions = Array.from(actionMap.entries()).map(([action_type, value]) => ({
action_type,
value,
}));
// Map of cost per action
const costPerActionMap = new Map();
(insights.cost_per_action_type || []).forEach(({ action_type, value }) => {
costPerActionMap.set(action_type, parseFloat(value || 0));
});
// Total post engagements
const totalPostEngagements = actionMap.get("post_engagement") || 0;
return {
spend,
impressions,
clicks,
reach,
frequency,
ctr,
cpm,
cpc,
actions,
costPerActionMap,
purchaseValue,
totalPurchases,
totalPostEngagements,
};
};
const processCampaignData = (campaign) => {
const metrics = processMetrics(campaign);
const budget = calculateBudget(campaign);
const { action_type, label } = getObjectiveAction(campaign.objective);
// Get cost per result from costPerActionMap
const costPerResult = metrics.costPerActionMap.get(action_type) || 0;
return {
id: campaign.id,
name: campaign.name,
status: campaign.status,
objective: label,
objectiveActionType: action_type,
budget: budget.value,
budgetType: budget.type,
metrics: {
...metrics,
costPerResult,
},
};
};
const SkeletonMetricCard = () => (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<Skeleton className="h-4 w-24 mb-4 bg-muted" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-8 w-20 bg-muted" />
</div>
</div>
<Skeleton className="h-5 w-5 rounded-full bg-muted" />
</div>
</CardContent>
</Card>
);
const SkeletonTable = () => (
<div className="h-full max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2">
<table className="min-w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-800">
<th className="p-2 sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
<Skeleton className="h-4 w-32 bg-muted" />
</th>
{[...Array(8)].map((_, i) => (
<th key={i} className="p-2 text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
<Skeleton className="h-4 w-20 mx-auto bg-muted" />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{[...Array(5)].map((_, rowIndex) => (
<tr key={rowIndex} className="hover:bg-muted/50 transition-colors">
<td className="p-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 bg-muted" />
<div className="space-y-2">
<Skeleton className="h-4 w-48 bg-muted" />
<Skeleton className="h-3 w-64 bg-muted" />
<Skeleton className="h-3 w-32 bg-muted" />
</div>
</div>
</td>
{[...Array(8)].map((_, colIndex) => (
<td key={colIndex} className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
const MetaCampaigns = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [campaigns, setCampaigns] = useState([]);
const [timeframe, setTimeframe] = useState("7");
const [summaryMetrics, setSummaryMetrics] = useState(null);
const [sortConfig, setSortConfig] = useState({
key: "spend",
direction: "desc",
});
const handleSort = (key) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
};
const computeDateRange = (timeframe) => {
// Create date in Eastern Time
const now = new Date();
const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" })
);
easternTime.setHours(0, 0, 0, 0); // Set to start of day
let sinceDate, untilDate;
if (timeframe === "today") {
// For today, both dates should be the current date in Eastern Time
sinceDate = untilDate = new Date(easternTime);
} else {
// For other periods, calculate the date range
untilDate = new Date(easternTime);
untilDate.setDate(untilDate.getDate() - 1); // Yesterday
sinceDate = new Date(untilDate);
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
}
return {
since: sinceDate.toISOString().split("T")[0],
until: untilDate.toISOString().split("T")[0],
};
};
useEffect(() => {
const fetchMetaAdsData = async () => {
try {
setLoading(true);
setError(null);
const { since, until } = computeDateRange(timeframe);
const [campaignData, accountInsights] = await Promise.all([
fetch(`/api/meta/campaigns?since=${since}&until=${until}`),
fetch(`/api/meta/account-insights?since=${since}&until=${until}`)
]);
const [campaignsJson, accountJson] = await Promise.all([
campaignData.json(),
accountInsights.json()
]);
// Process campaigns with the new processing logic
const processedCampaigns = campaignsJson.map(processCampaignData);
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
setCampaigns(activeCampaigns);
if (activeCampaigns.length > 0) {
const totalSpend = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.spend, 0);
const totalImpressions = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.impressions, 0);
const totalReach = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.reach, 0);
const totalPurchases = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.totalPurchases, 0);
const totalPurchaseValue = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.purchaseValue, 0);
const totalLinkClicks = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.clicks, 0);
const totalPostEngagements = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.totalPostEngagements, 0);
const numCampaigns = activeCampaigns.length;
const avgFrequency = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.frequency, 0) / numCampaigns;
const avgCpm = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.cpm, 0) / numCampaigns;
const avgCtr = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.ctr, 0) / numCampaigns;
const avgCpc = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.cpc, 0) / numCampaigns;
setSummaryMetrics({
totalSpend,
totalPurchaseValue,
totalLinkClicks,
totalImpressions,
totalReach,
totalPurchases,
avgFrequency,
avgCpm,
avgCtr,
avgCpc,
totalPostEngagements,
totalCampaigns: numCampaigns,
});
}
} catch (err) {
console.error("Meta Ads fetch error:", err);
setError(`Failed to fetch Meta Ads data: ${err.message}`);
} finally {
setLoading(false);
}
};
fetchMetaAdsData();
}, [timeframe]);
// Sort campaigns
const sortedCampaigns = [...campaigns].sort((a, b) => {
const direction = sortConfig.direction === "desc" ? -1 : 1;
switch (sortConfig.key) {
case "date":
// Add date sorting using campaign ID (Meta IDs are chronological)
return direction * (parseInt(b.id) - parseInt(a.id));
case "spend":
return direction * ((a.metrics.spend || 0) - (b.metrics.spend || 0));
case "reach":
return direction * ((a.metrics.reach || 0) - (b.metrics.reach || 0));
case "impressions":
return direction * ((a.metrics.impressions || 0) - (b.metrics.impressions || 0));
case "cpm":
return direction * ((a.metrics.cpm || 0) - (b.metrics.cpm || 0));
case "ctr":
return direction * ((a.metrics.ctr || 0) - (b.metrics.ctr || 0));
case "results":
return direction * ((getActionValue(a, a.objectiveActionType) || 0) - (getActionValue(b, b.objectiveActionType) || 0));
case "value":
return direction * ((a.metrics.purchaseValue || 0) - (b.metrics.purchaseValue || 0));
case "engagements":
return direction * ((a.metrics.totalPostEngagements || 0) - (b.metrics.totalPostEngagements || 0));
default:
return 0;
}
});
if (loading) {
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<div className="flex justify-between items-start mb-6">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Meta Ads Performance
</CardTitle>
<Select disabled value="7">
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
{[...Array(12)].map((_, i) => (
<SkeletonMetricCard key={i} />
))}
</div>
</CardHeader>
<CardContent className="p-4">
<SkeletonTable />
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
{error}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<div className="flex justify-between items-start mb-6">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Meta Ads Performance
</CardTitle>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
{[
{
label: "Active Campaigns",
value: summaryMetrics?.totalCampaigns,
options: { icon: Target, iconColor: "text-purple-500" },
},
{
label: "Total Spend",
value: summaryMetrics?.totalSpend,
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-green-500" },
},
{
label: "Total Reach",
value: summaryMetrics?.totalReach,
options: { icon: Users, iconColor: "text-blue-500" },
},
{
label: "Total Impressions",
value: summaryMetrics?.totalImpressions,
options: { icon: Eye, iconColor: "text-indigo-500" },
},
{
label: "Avg Frequency",
value: summaryMetrics?.avgFrequency,
options: { decimalPlaces: 2, icon: Repeat, iconColor: "text-cyan-500" },
},
{
label: "Total Engagements",
value: summaryMetrics?.totalPostEngagements,
options: { icon: MessageCircle, iconColor: "text-pink-500" },
},
{
label: "Avg CPM",
value: summaryMetrics?.avgCpm,
options: { isMonetary: true, decimalPlaces: 2, icon: DollarSign, iconColor: "text-emerald-500" },
},
{
label: "Avg CTR",
value: summaryMetrics?.avgCtr,
options: { isPercentage: true, decimalPlaces: 2, icon: BarChart, iconColor: "text-orange-500" },
},
{
label: "Avg CPC",
value: summaryMetrics?.avgCpc,
options: { isMonetary: true, decimalPlaces: 2, icon: MousePointer, iconColor: "text-rose-500" },
},
{
label: "Total Link Clicks",
value: summaryMetrics?.totalLinkClicks,
options: { icon: MousePointer, iconColor: "text-amber-500" },
},
{
label: "Total Purchases",
value: summaryMetrics?.totalPurchases,
options: { icon: ShoppingCart, iconColor: "text-teal-500" },
},
{
label: "Purchase Value",
value: summaryMetrics?.totalPurchaseValue,
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-lime-500" },
},
].map((card) => (
<div key={card.label} className="h-full">
{summaryCard(card.label, card.value, card.options)}
</div>
))}
</div>
</CardHeader>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-800">
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant="ghost"
className="pl-0 justify-start w-full h-8"
onClick={() => handleSort("date")}
>
Campaign
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "spend" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("spend")}
>
Spend
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "reach" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("reach")}
>
Reach
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("impressions")}
>
Impressions
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "cpm" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("cpm")}
>
CPM
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "ctr" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("ctr")}
>
CTR
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "results" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("results")}
>
Results
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "value" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("value")}
>
Value
</Button>
</th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100">
<Button
variant={sortConfig.key === "engagements" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("engagements")}
>
Engagements
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{sortedCampaigns.map((campaign) => (
<tr
key={campaign.id}
className="hover:bg-muted/50 transition-colors"
>
<td className="p-2 align-top">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100 break-words min-w-[200px] max-w-[300px]">
<CampaignName name={campaign.name} />
</div>
<div className="text-sm text-muted-foreground">
{campaign.objective}
</div>
</div>
</td>
<MetricCell
value={campaign.metrics.spend}
isMonetary
decimalPlaces={2}
sublabel={
campaign.budget
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
: "Budget: Ad set"
}
/>
<MetricCell
value={campaign.metrics.reach}
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
/>
<MetricCell
value={campaign.metrics.impressions}
/>
<MetricCell
value={campaign.metrics.cpm}
isMonetary
decimalPlaces={2}
/>
<MetricCell
value={campaign.metrics.ctr}
isPercentage
decimalPlaces={2}
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
/>
<MetricCell
value={getActionValue(campaign, campaign.objectiveActionType)}
label={campaign.objective}
/>
<MetricCell
value={campaign.metrics.purchaseValue}
isMonetary
decimalPlaces={2}
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
/>
<MetricCell
value={campaign.metrics.totalPostEngagements}
/>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);
};
export default MetaCampaigns;
@@ -0,0 +1,487 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import axios from "axios";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/dashboard/ui/card";
import { Badge } from "@/components/dashboard/ui/badge";
import { ScrollArea } from "@/components/dashboard/ui/scroll-area";
import {
Package,
Truck,
UserPlus,
XCircle,
DollarSign,
Activity,
AlertCircle,
FileText,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { format } from "date-fns";
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
import { EventDialog } from "./EventFeed.jsx";
import { Button } from "@/components/dashboard/ui/button";
const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF",
SHIPPED_ORDER: "VExpdL",
ACCOUNT_CREATED: "TeeypV",
CANCELED_ORDER: "YjVMNg",
NEW_BLOG_POST: "YcxeDr",
PAYMENT_REFUNDED: "R7XUYh",
};
const EVENT_TYPES = {
[METRIC_IDS.PLACED_ORDER]: {
label: "Order Placed",
color: "bg-green-200",
textColor: "text-green-50",
iconColor: "text-green-800",
gradient: "from-green-800 to-green-700",
},
[METRIC_IDS.SHIPPED_ORDER]: {
label: "Order Shipped",
color: "bg-blue-200",
textColor: "text-blue-50",
iconColor: "text-blue-800",
gradient: "from-blue-800 to-blue-700",
},
[METRIC_IDS.ACCOUNT_CREATED]: {
label: "New Account",
color: "bg-purple-200",
textColor: "text-purple-50",
iconColor: "text-purple-800",
gradient: "from-purple-800 to-purple-700",
},
[METRIC_IDS.CANCELED_ORDER]: {
label: "Order Canceled",
color: "bg-red-200",
textColor: "text-red-50",
iconColor: "text-red-800",
gradient: "from-red-800 to-red-700",
},
[METRIC_IDS.PAYMENT_REFUNDED]: {
label: "Payment Refunded",
color: "bg-orange-200",
textColor: "text-orange-50",
iconColor: "text-orange-800",
gradient: "from-orange-800 to-orange-700",
},
[METRIC_IDS.NEW_BLOG_POST]: {
label: "New Blog Post",
color: "bg-indigo-200",
textColor: "text-indigo-50",
iconColor: "text-indigo-800",
gradient: "from-indigo-800 to-indigo-700",
},
};
const EVENT_ICONS = {
[METRIC_IDS.PLACED_ORDER]: Package,
[METRIC_IDS.SHIPPED_ORDER]: Truck,
[METRIC_IDS.ACCOUNT_CREATED]: UserPlus,
[METRIC_IDS.CANCELED_ORDER]: XCircle,
[METRIC_IDS.PAYMENT_REFUNDED]: DollarSign,
[METRIC_IDS.NEW_BLOG_POST]: FileText,
};
// Loading State Component
const LoadingState = () => (
<div className="flex gap-3 px-4">
{[...Array(6)].map((_, i) => (
<Card key={i} className="w-[210px] h-[125px] shrink-0 bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
<div className="flex items-baseline justify-between w-full pr-1">
<Skeleton className="h-4 w-20 bg-gray-700" />
<Skeleton className="h-3 w-14 bg-gray-700" />
</div>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-gray-300" />
<Skeleton className="h-4 w-4 bg-gray-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-3 pt-1">
<div className="space-y-2">
<Skeleton className="h-7 w-36 bg-gray-700" />
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-28 bg-gray-700" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
// Empty State Component
const EmptyState = () => (
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
<CardContent className="flex flex-col items-center justify-center h-full text-center p-4">
<div className="bg-gray-800 rounded-full p-2 mb-2">
<Activity className="h-4 w-4 text-gray-400" />
</div>
<p className="text-sm text-gray-400 font-medium">
No recent activity
</p>
</CardContent>
</Card>
);
const EventCard = ({ event }) => {
const eventType = EVENT_TYPES[event.metric_id];
if (!eventType) return null;
const Icon = EVENT_ICONS[event.metric_id] || Package;
const details = event.event_properties || {};
return (
<EventDialog event={event}>
<Card className={`w-[210px] border-none shrink-0 hover:brightness-110 cursor-pointer transition-colors h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
<div className="flex items-baseline justify-between w-full pr-1">
<CardTitle className={`text-sm font-bold ${eventType.textColor}`}>
{eventType.label}
</CardTitle>
{event.datetime && (
<CardDescription className={`text-xs ${eventType.textColor} opacity-80`}>
{format(new Date(event.datetime), "h:mm a")}
</CardDescription>
)}
</div>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full ${eventType.color}`} />
<Icon className={`h-4 w-4 ${eventType.iconColor} relative`} />
</div>
</CardHeader>
<CardContent className="p-3 pt-1">
{event.metric_id === METRIC_IDS.PLACED_ORDER && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
#{details.OrderId} {formatCurrency(details.TotalAmount)}
</div>
</div>
{(details.IsOnHold || details.OnHoldReleased || details.StillOwes || details.LocalPickup || details.HasPreorder || details.HasNotions || details.OnlyDigitalGC || details.HasDigitalGC || details.HasDigiItem || details.OnlyDigiItem) && (
<div className="flex gap-1.5 items-center flex-wrap mt-1">
{details.IsOnHold && (
<Badge
variant="secondary"
className="bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs py-0"
>
On Hold
</Badge>
)}
{details.OnHoldReleased && (
<Badge
variant="secondary"
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
>
Hold Released
</Badge>
)}
{details.StillOwes && (
<Badge
variant="secondary"
className="bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs py-0"
>
Owes
</Badge>
)}
{details.LocalPickup && (
<Badge
variant="secondary"
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
>
Local
</Badge>
)}
{details.HasPreorder && (
<Badge
variant="secondary"
className="bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 text-xs py-0"
>
Pre-order
</Badge>
)}
{details.HasNotions && (
<Badge
variant="secondary"
className="bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 text-xs py-0"
>
Notions
</Badge>
)}
{(details.OnlyDigitalGC || details.HasDigitalGC) && (
<Badge
variant="secondary"
className="bg-pink-100 dark:bg-pink-900/20 text-pink-700 dark:text-pink-300 text-xs py-0"
>
eGift Card
</Badge>
)}
{(details.HasDigiItem || details.OnlyDigiItem) && (
<Badge
variant="secondary"
className="bg-indigo-100 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300 text-xs py-0"
>
Digital
</Badge>
)}
</div>
)}
</>
)}
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
#{details.OrderId} {formatShipMethodSimple(details.ShipMethod)}
</div>
</div>
{event.event_properties?.ShippedBy && (
<div className={`text-sm font-medium ${eventType.textColor} opacity-90 truncate mt-1`}>
Shipped by {event.event_properties.ShippedBy}
</div>
)}
</>
)}
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.FirstName} {details.LastName}
</div>
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate mt-1`}>
{details.EmailAddress}
</div>
</>
)}
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
#{details.OrderId} {formatCurrency(details.TotalAmount)}
</div>
</div>
<div className={`text-xs ${eventType.textColor} opacity-80 mt-1.5 truncate`}>
{details.CancelReason}
</div>
</>
)}
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
<>
<div className={`text-xl truncate font-bold ${eventType.textColor}`}>
{details.ShippingName}
</div>
<div className="flex items-center justify-between mt-1">
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 truncate`}>
#{details.FromOrder} {formatCurrency(details.PaymentAmount)}
</div>
</div>
<div className={`text-xs ${eventType.textColor} opacity-80 mt-1.5 truncate`}>
via {details.PaymentName}
</div>
</>
)}
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
<>
<div className={`text-lg truncate font-bold ${eventType.textColor}`}>
{details.title}
</div>
<div className={`text-sm font-semibold ${eventType.textColor} opacity-80 line-clamp-2 mt-1`}>
{details.description}
</div>
</>
)}
</CardContent>
</Card>
</EventDialog>
);
};
const DEFAULT_METRICS = Object.values(METRIC_IDS);
const MiniEventFeed = ({
selectedMetrics = DEFAULT_METRICS,
}) => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
const scrollRef = useRef(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const handleScroll = () => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeftArrow(scrollLeft > 0);
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 1);
}
};
const scrollToEnd = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: 'smooth'
});
}
};
const scrollToStart = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: 0,
behavior: 'smooth'
});
}
};
const fetchEvents = useCallback(async () => {
try {
setError(null);
if (events.length === 0) {
setLoading(true);
}
const response = await axios.get("/api/klaviyo/events/feed", {
params: {
timeRange: "today",
metricIds: JSON.stringify(selectedMetrics),
},
});
const processedEvents = (response.data.data || []).map((event) => ({
...event,
datetime: event.attributes?.datetime || event.datetime,
event_properties: event.attributes?.event_properties || {}
}));
setEvents(processedEvents);
setLastUpdate(new Date());
// Scroll to the right after events are loaded
if (scrollRef.current) {
setTimeout(() => {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: 'instant'
});
handleScroll();
}, 0);
}
} catch (error) {
console.error("Error fetching events:", error);
setError(error.message);
} finally {
setLoading(false);
}
}, [selectedMetrics]);
useEffect(() => {
fetchEvents();
const interval = setInterval(fetchEvents, 30000);
return () => clearInterval(interval);
}, [fetchEvents]);
useEffect(() => {
handleScroll();
}, [events]);
return (
<div className="fixed bottom-0 left-0 right-0">
<Card className="bg-gradient-to-br rounded-none from-gray-900 to-gray-600 backdrop-blur">
<div className="px-1 pt-2 pb-3 relative">
{showLeftArrow && (
<Button
variant="ghost"
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
onClick={scrollToStart}
>
<ChevronLeft className="text-white" />
</Button>
)}
{showRightArrow && (
<Button
variant="ghost"
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
onClick={scrollToEnd}
>
<ChevronRight className="text-white" />
</Button>
)}
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
>
<div className="flex flex-row gap-3 pr-4" style={{ width: 'max-content' }}>
{loading && !events.length ? (
<LoadingState />
) : error ? (
<Alert variant="destructive" className="mx-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load event feed: {error}
</AlertDescription>
</Alert>
) : !events || events.length === 0 ? (
<div className="px-4">
<EmptyState />
</div>
) : (
[...events].reverse().map((event) => (
<EventCard
key={event.id}
event={event}
/>
))
)}
</div>
</div>
</div>
</Card>
</div>
);
};
export default MiniEventFeed;
// Helper Functions
const formatCurrency = (amount) => {
// Convert to number if it's a string
const num = typeof amount === "string" ? parseFloat(amount) : amount;
// Handle negative numbers
const absNum = Math.abs(num);
// Format to 2 decimal places and add negative sign if needed
return `${num < 0 ? "-" : ""}$${absNum.toFixed(2)}`;
};
const formatShipMethodSimple = (method) => {
if (!method) return "Digital";
if (method.includes("usps")) return "USPS";
if (method.includes("fedex")) return "FedEx";
if (method.includes("ups")) return "UPS";
return "Standard";
};
@@ -0,0 +1,256 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { AlertTriangle, Users, Activity } from "lucide-react";
import { Alert, AlertDescription } from "@/components/dashboard/ui/alert";
import { format } from "date-fns";
import {
summaryCard,
SkeletonSummaryCard,
SkeletonBarChart,
processBasicData,
} from "./RealtimeAnalytics";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
const SkeletonCard = ({ colorScheme = "sky" }) => (
<Card className={`w-full h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle>
<div className="space-y-2">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
</CardTitle>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300/20`} />
<div className="h-5 w-5 relative rounded-full bg-${colorScheme}-300/20" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className={`h-8 w-32 bg-${colorScheme}-300/20`} />
<div className="flex justify-between items-center">
<div className="space-y-1">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
const MiniRealtimeAnalytics = () => {
const [basicData, setBasicData] = useState({
last30MinUsers: 0,
last5MinUsers: 0,
byMinute: [],
tokenQuota: null,
lastUpdated: null,
});
const [loading, setLoading] = useState(true);
const [isPaused, setIsPaused] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let basicInterval;
const fetchBasicData = async () => {
if (isPaused) return;
try {
const response = await fetch("/api/dashboard-analytics/realtime/basic", {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch basic realtime data");
}
const result = await response.json();
const processed = processBasicData(result.data);
setBasicData(processed);
setError(null);
setLoading(false);
} catch (error) {
console.error("Error details:", {
message: error.message,
stack: error.stack,
response: error.response,
});
if (error.message === "QUOTA_EXCEEDED") {
setError("Quota exceeded. Analytics paused until manually resumed.");
setIsPaused(true);
} else {
setError("Failed to fetch analytics data");
}
}
};
// Initial fetch
fetchBasicData();
// Set up interval
basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds
return () => {
clearInterval(basicInterval);
};
}, [isPaused]);
const renderContent = () => {
if (error) {
return (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
if (loading) {
return (
<div>
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
<SkeletonCard colorScheme="sky" />
<SkeletonCard colorScheme="sky" />
</div>
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
<CardContent className="p-4">
<div className="h-[216px]">
<div className="h-full w-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-sky-300/20"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-sky-300/20 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-sky-300/20 rounded-sm" />
))}
</div>
{/* Bars */}
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className="w-2 bg-sky-300/20 rounded-sm"
style={{ height: `${Math.random() * 80 + 10}%` }}
/>
))}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div>
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
{summaryCard(
"Last 30 Minutes",
"Active users",
basicData.last30MinUsers,
{
colorClass: "text-sky-200",
titleClass: "text-sky-100 font-bold text-md",
descriptionClass: "pt-2 text-sky-200 text-md font-semibold",
background: "h-[150px] pt-2 bg-gradient-to-br from-sky-900 to-sky-800",
icon: Users,
iconColor: "text-sky-900",
iconBackground: "bg-sky-300"
}
)}
{summaryCard(
"Last 5 Minutes",
"Active users",
basicData.last5MinUsers,
{
colorClass: "text-sky-200",
titleClass: "text-sky-100 font-bold text-md",
descriptionClass: "pt-2 text-sky-200 text-md font-semibold",
background: "h-[150px] pt-2 bg-gradient-to-br from-sky-900 to-sky-800",
icon: Activity,
iconColor: "text-sky-900",
iconBackground: "bg-sky-300"
}
)}
</div>
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
<CardContent className="p-4">
<div className="h-[216px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={basicData.byMinute}
margin={{ top: 5, right: 5, left: -35, bottom: -10 }}
>
<XAxis
dataKey="minute"
tickFormatter={(value) => value + "m"}
className="text-xs"
tick={{ fill: "#e0f2fe" }}
/>
<YAxis
className="text-xs"
tick={{ fill: "#e0f2fe" }}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<Card className="p-2 shadow-lg bg-sky-800 border-none">
<CardContent className="p-0 space-y-1">
<p className="font-medium text-sm text-sky-100 border-b border-sky-700 pb-1 mb-1">
{payload[0].payload.timestamp}
</p>
<div className="flex justify-between items-center text-sm">
<span className="text-sky-200">
Active Users:
</span>
<span className="font-medium ml-4 text-sky-100">
{payload[0].value}
</span>
</div>
</CardContent>
</Card>
);
}
return null;
}}
/>
<Bar dataKey="users" fill="#0EA5E9" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
);
};
return renderContent();
};
export default MiniRealtimeAnalytics;
@@ -0,0 +1,487 @@
import React, { useState, useEffect, useCallback, memo } from "react";
import axios from "axios";
import { acotService } from "@/services/dashboard/acotService";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/dashboard/ui/card";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { DateTime } from "luxon";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
import { AlertCircle, TrendingUp, DollarSign, ShoppingCart, Truck, PiggyBank, ArrowUp,ArrowDown, Banknote, Package } from "lucide-react";
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx";
const SkeletonChart = () => (
<div className="h-[216px]">
<div className="h-full w-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-slate-600"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
))}
</div>
{/* Chart lines */}
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className="absolute inset-0 bg-slate-600 rounded-sm"
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
</div>
</div>
);
const MiniStatCard = memo(({
title,
value,
icon: Icon,
colorClass,
iconColor,
iconBackground,
background,
previousValue,
trend,
trendValue,
onClick,
active = true,
titleClass = "text-sm font-bold text-gray-100",
descriptionClass = "text-sm font-semibold text-gray-200"
}) => (
<Card
className={`w-full bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm ${
onClick ? 'cursor-pointer transition-all hover:brightness-110' : ''
} ${!active ? 'opacity-50' : ''}`}
onClick={onClick}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className={titleClass}>
{title}
</CardTitle>
{Icon && (
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full ${iconBackground}`} />
<Icon className={`h-5 w-5 ${iconColor} relative`} />
</div>
)}
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<div>
<div className={`text-3xl font-extrabold ${colorClass}`}>
{value}
</div>
<div className="mt-2 items-center justify-between flex">
<span className={descriptionClass}>Prev: {previousValue}</span>
{trend && (
<span
className={`flex items-center gap-0 px-1 py-0.5 rounded-full ${
trend === 'up'
? 'text-sm font-bold bg-emerald-300 text-emerald-900'
: 'text-sm font-bold bg-rose-300 text-rose-900'
}`}
>
{trend === "up" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
{trendValue}
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
));
MiniStatCard.displayName = "MiniStatCard";
const SkeletonCard = ({ colorScheme = "emerald" }) => (
<Card className="w-full h-[150px] bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle>
<div className="space-y-2">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
</div>
</CardTitle>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300`} />
<Skeleton className={`h-5 w-5 bg-${colorScheme}-300 relative rounded-full`} />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className={`h-8 w-20 bg-${colorScheme}-300`} />
<div className="flex justify-between items-center">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
<Skeleton className={`h-4 w-12 bg-${colorScheme}-300 rounded-full`} />
</div>
</div>
</CardContent>
</Card>
);
const MiniSalesChart = ({ className = "" }) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [visibleMetrics, setVisibleMetrics] = useState({
revenue: true,
orders: true
});
const [summaryStats, setSummaryStats] = useState({
totalRevenue: 0,
totalOrders: 0,
prevRevenue: 0,
prevOrders: 0,
periodProgress: 100
});
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
const fetchProjection = useCallback(async () => {
if (summaryStats.periodProgress >= 100) return;
try {
setProjectionLoading(true);
const response = await acotService.getProjection({ timeRange: "last30days" });
setProjection(response);
} catch (error) {
console.error("Error loading projection:", error);
} finally {
setProjectionLoading(false);
}
}, [summaryStats.periodProgress]);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await acotService.getStatsDetails({
timeRange: "last30days",
metric: "revenue",
daily: true,
});
if (!response.stats) {
throw new Error("Invalid response format");
}
const stats = Array.isArray(response.stats)
? response.stats
: [];
const processedData = processData(stats);
// Calculate totals and growth
const totals = stats.reduce((acc, day) => ({
totalRevenue: acc.totalRevenue + (Number(day.revenue) || 0),
totalOrders: acc.totalOrders + (Number(day.orders) || 0),
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
periodProgress: day.periodProgress || 100,
}), {
totalRevenue: 0,
totalOrders: 0,
prevRevenue: 0,
prevOrders: 0,
periodProgress: 100
});
setData(processedData);
setSummaryStats(totals);
setError(null);
// Fetch projection if needed
if (totals.periodProgress < 100) {
fetchProjection();
}
} catch (error) {
console.error("Error fetching data:", error);
setError(error.message);
} finally {
setLoading(false);
}
}, [fetchProjection]);
useEffect(() => {
fetchData();
const intervalId = setInterval(fetchData, 300000);
return () => clearInterval(intervalId);
}, [fetchData]);
const formatXAxis = (value) => {
if (!value) return "";
const date = new Date(value);
return date.toLocaleDateString([], {
month: "short",
day: "numeric"
});
};
const toggleMetric = (metric) => {
setVisibleMetrics(prev => ({
...prev,
[metric]: !prev[metric]
}));
};
if (error) {
return (
<Alert variant="destructive" className="bg-white/10 backdrop-blur-sm">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load sales data: {error}</AlertDescription>
</Alert>
);
}
if (loading && !data) {
return (
<div className="space-y-2">
{/* Stat Cards */}
<div className="grid grid-cols-2 gap-2">
<SkeletonCard colorScheme="emerald" />
<SkeletonCard colorScheme="blue" />
</div>
{/* Chart Card */}
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
<CardContent className="p-4">
<SkeletonChart />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-2">
{/* Stat Cards */}
<div className="grid grid-cols-2 gap-2">
{loading ? (
<>
<SkeletonCard colorScheme="emerald" />
<SkeletonCard colorScheme="blue" />
</>
) : (
<>
<MiniStatCard
title="30 Days Revenue"
value={formatCurrency(summaryStats.totalRevenue, false)}
previousValue={formatCurrency(summaryStats.prevRevenue, false)}
trend={
summaryStats.periodProgress < 100
? ((projection?.projectedRevenue || summaryStats.totalRevenue) >= summaryStats.prevRevenue ? "up" : "down")
: (summaryStats.totalRevenue >= summaryStats.prevRevenue ? "up" : "down")
}
trendValue={
summaryStats.periodProgress < 100
? `${Math.abs(Math.round(((projection?.projectedRevenue || summaryStats.totalRevenue) - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`
: `${Math.abs(Math.round(((summaryStats.totalRevenue - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100))}%`
}
colorClass="text-emerald-300"
titleClass="text-emerald-300 font-bold text-md"
descriptionClass="text-emerald-300 text-md font-semibold pb-1"
icon={PiggyBank}
iconColor="text-emerald-900"
iconBackground="bg-emerald-300"
onClick={() => toggleMetric('revenue')}
active={visibleMetrics.revenue}
/>
<MiniStatCard
title="30 Days Orders"
value={summaryStats.totalOrders.toLocaleString()}
previousValue={summaryStats.prevOrders.toLocaleString()}
trend={
summaryStats.periodProgress < 100
? ((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) >= summaryStats.prevOrders ? "up" : "down")
: (summaryStats.totalOrders >= summaryStats.prevOrders ? "up" : "down")
}
trendValue={
summaryStats.periodProgress < 100
? `${Math.abs(Math.round(((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`
: `${Math.abs(Math.round(((summaryStats.totalOrders - summaryStats.prevOrders) / summaryStats.prevOrders) * 100))}%`
}
colorClass="text-blue-300"
titleClass="text-blue-300 font-bold text-md"
descriptionClass="text-blue-300 text-md font-semibold pb-1"
icon={Truck}
iconColor="text-blue-900"
iconBackground="bg-blue-300"
onClick={() => toggleMetric('orders')}
active={visibleMetrics.orders}
/>
</>
)}
</div>
{/* Chart Card */}
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
<CardContent className="p-4">
<div className="h-[216px]">
{loading ? (
<div className="h-full w-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-slate-600"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
))}
</div>
{/* Chart lines */}
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className="absolute inset-0 bg-slate-600 rounded-sm"
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 0, right: -30, left: -5, bottom: -10 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-stone-700" />
<XAxis
dataKey="timestamp"
tickFormatter={formatXAxis}
className="text-xs"
tick={{ fill: "#f5f5f4" }}
/>
{visibleMetrics.revenue && (
<YAxis
yAxisId="revenue"
tickFormatter={(value) => formatCurrency(value, false)}
className="text-xs"
tick={{ fill: "#f5f5f4" }}
/>
)}
{visibleMetrics.orders && (
<YAxis
yAxisId="orders"
orientation="right"
className="text-xs"
tick={{ fill: "#f5f5f4" }}
/>
)}
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const timestamp = new Date(payload[0].payload.timestamp);
return (
<Card className="p-2 shadow-lg bg-stone-800 border-none">
<CardContent className="p-0 space-y-1">
<p className="font-medium text-sm text-stone-100 border-b border-stone-700 pb-1 mb-1">
{timestamp.toLocaleDateString([], {
weekday: "short",
month: "short",
day: "numeric"
})}
</p>
{payload
.filter(entry => visibleMetrics[entry.dataKey])
.map((entry, index) => (
<div key={index} className="flex justify-between items-center text-sm">
<span className="text-stone-200">
{entry.name}:
</span>
<span className="font-medium ml-4 text-stone-100">
{entry.dataKey === 'revenue'
? formatCurrency(entry.value)
: entry.value.toLocaleString()}
</span>
</div>
))}
</CardContent>
</Card>
);
}
return null;
}}
/>
{visibleMetrics.revenue && (
<Line
yAxisId="revenue"
type="monotone"
dataKey="revenue"
name="Revenue"
stroke="#10b981"
strokeWidth={2}
dot={false}
/>
)}
{visibleMetrics.orders && (
<Line
yAxisId="orders"
type="monotone"
dataKey="orders"
name="Orders"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
/>
)}
</LineChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
</div>
);
};
export default MiniSalesChart;
@@ -0,0 +1,747 @@
import React, { useState, useEffect, useCallback, memo } from "react";
import axios from "axios";
import { acotService } from "@/services/dashboard/acotService";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/dashboard/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/dashboard/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/dashboard/ui/dialog";
import { DateTime } from "luxon";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
import {
DollarSign,
ShoppingCart,
Package,
AlertCircle,
CircleDollarSign,
Loader2,
} from "lucide-react";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/dashboard/ui/tooltip";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/dashboard/ui/table";
// Import the detail view components and utilities from StatCards
import {
RevenueDetails,
OrdersDetails,
AverageOrderDetails,
ShippingDetails,
StatCard,
DetailDialog,
formatCurrency,
formatPercentage,
SkeletonCard,
} from "./StatCards";
// Mini skeleton components
const MiniSkeletonChart = ({ type = "line" }) => (
<div className={`h-[230px] w-full ${
type === 'revenue' ? 'bg-emerald-50/10' :
type === 'orders' ? 'bg-blue-50/10' :
type === 'average_order' ? 'bg-violet-50/10' :
'bg-orange-50/10'
} rounded-lg p-4`}>
<div className="h-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className={`absolute w-full h-px ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
}`}
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className={`h-3 w-6 ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
} rounded-sm`} />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className={`h-3 w-8 ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
} rounded-sm`} />
))}
</div>
{type === "bar" ? (
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className={`w-2 ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
} rounded-sm`}
style={{ height: `${Math.random() * 80 + 10}%` }}
/>
))}
</div>
) : (
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className={`absolute inset-0 ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
} rounded-sm`}
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
)}
</div>
</div>
);
const MiniSkeletonTable = ({ rows = 8, colorScheme = "orange" }) => (
<div className={`rounded-lg border ${
colorScheme === 'orange' ? 'bg-orange-50/10 border-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-50/10 border-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-50/10 border-blue-200/20' :
'bg-violet-50/10 border-violet-200/20'
}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Skeleton className={`h-4 w-32 ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableHead>
<TableHead className="text-right">
<Skeleton className={`h-4 w-24 ml-auto ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableHead>
<TableHead className="text-right">
<Skeleton className={`h-4 w-24 ml-auto ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(rows)].map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className={`h-4 w-48 ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableCell>
<TableCell className="text-right">
<Skeleton className={`h-4 w-16 ml-auto ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableCell>
<TableCell className="text-right">
<Skeleton className={`h-4 w-16 ml-auto ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
const MiniStatCards = ({
timeRange: initialTimeRange = "today",
startDate,
endDate,
title = "Quick Stats",
description = "",
compact = false,
}) => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
const [timeRange, setTimeRange] = useState(initialTimeRange);
const [selectedMetric, setSelectedMetric] = useState(null);
const [detailDataLoading, setDetailDataLoading] = useState({});
const [detailData, setDetailData] = useState({});
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
// Reuse the trend calculation functions
const calculateTrend = useCallback((current, previous) => {
if (!current || !previous) return null;
const trend = current >= previous ? "up" : "down";
const diff = Math.abs(current - previous);
const percentage = (diff / previous) * 100;
return {
trend,
value: percentage,
current,
previous,
};
}, []);
const calculateRevenueTrend = useCallback(() => {
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
// If period is complete, use actual revenue
// If period is incomplete, use smart projection when available, fallback to simple projection
const currentRevenue = stats.periodProgress < 100
? (projection?.projectedRevenue || stats.projectedRevenue)
: stats.revenue;
const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue
console.log('[MiniStatCards RevenueTrend Debug]', {
periodProgress: stats.periodProgress,
currentRevenue,
smartProjection: projection?.projectedRevenue,
simpleProjection: stats.projectedRevenue,
actualRevenue: stats.revenue,
prevRevenue,
isProjected: stats.periodProgress < 100
});
if (!currentRevenue || !prevRevenue) return null;
// Calculate absolute difference percentage
const trend = currentRevenue >= prevRevenue ? "up" : "down";
const diff = Math.abs(currentRevenue - prevRevenue);
const percentage = (diff / prevRevenue) * 100;
console.log('[MiniStatCards RevenueTrend Result]', {
trend,
percentage,
calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%`
});
return {
trend,
value: percentage,
current: currentRevenue,
previous: prevRevenue,
};
}, [stats, projection]);
const calculateOrderTrend = useCallback(() => {
if (!stats?.prevPeriodOrders) return null;
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
}, [stats, calculateTrend]);
const calculateAOVTrend = useCallback(() => {
if (!stats?.prevPeriodAOV) return null;
return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV);
}, [stats, calculateTrend]);
// Initial load effect
useEffect(() => {
let isMounted = true;
const loadData = async () => {
try {
setLoading(true);
setStats(null);
const params =
timeRange === "custom" ? { startDate, endDate } : { timeRange };
const response = await acotService.getStats(params);
if (!isMounted) return;
setStats(response.stats);
setLastUpdate(DateTime.now().setZone("America/New_York"));
setError(null);
} catch (error) {
console.error("Error loading data:", error);
if (isMounted) {
setError(error.message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadData();
return () => {
isMounted = false;
};
}, [timeRange, startDate, endDate]);
// Load smart projection separately
useEffect(() => {
let isMounted = true;
const loadProjection = async () => {
if (!stats?.periodProgress || stats.periodProgress >= 100) return;
try {
setProjectionLoading(true);
const params =
timeRange === "custom" ? { startDate, endDate } : { timeRange };
const response = await acotService.getProjection(params);
if (!isMounted) return;
setProjection(response);
} catch (error) {
console.error("Error loading projection:", error);
} finally {
if (isMounted) {
setProjectionLoading(false);
}
}
};
loadProjection();
return () => {
isMounted = false;
};
}, [timeRange, startDate, endDate, stats?.periodProgress]);
// Auto-refresh for 'today' view
useEffect(() => {
if (timeRange !== "today") return;
const interval = setInterval(async () => {
try {
const [statsResponse, projectionResponse] = await Promise.all([
acotService.getStats({ timeRange: "today" }),
acotService.getProjection({ timeRange: "today" }),
]);
setStats(statsResponse.stats);
setProjection(projectionResponse);
setLastUpdate(DateTime.now().setZone("America/New_York"));
} catch (error) {
console.error("Error auto-refreshing stats:", error);
}
}, 60000);
return () => clearInterval(interval);
}, [timeRange]);
// Add function to fetch detail data
const fetchDetailData = useCallback(
async (metric) => {
if (detailData[metric]) return;
setDetailDataLoading((prev) => ({ ...prev, [metric]: true }));
try {
const response = await acotService.getStatsDetails({
timeRange: "last30days",
metric,
daily: true,
});
setDetailData((prev) => ({ ...prev, [metric]: response.stats }));
} catch (error) {
console.error(`Error fetching detail data for ${metric}:`, error);
} finally {
setDetailDataLoading((prev) => ({ ...prev, [metric]: false }));
}
},
[detailData]
);
// Add effect to load detail data when metric is selected
useEffect(() => {
if (selectedMetric) {
fetchDetailData(selectedMetric);
}
}, [selectedMetric, fetchDetailData]);
// Add preload effect with throttling
useEffect(() => {
// Preload detail data with throttling to avoid overwhelming the server
const preloadData = async () => {
const metrics = ["revenue", "orders", "average_order", "shipping"];
for (const metric of metrics) {
try {
await fetchDetailData(metric);
// Small delay between requests
await new Promise(resolve => setTimeout(resolve, 25));
} catch (error) {
console.error(`Error preloading ${metric}:`, error);
}
}
};
preloadData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (loading && !stats) {
return (
<div className="grid grid-cols-4 gap-2">
<Card className="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-emerald-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-emerald-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-emerald-300" />
<Skeleton className="h-5 w-5 bg-emerald-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-emerald-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-emerald-700" />
<Skeleton className="h-4 w-12 bg-emerald-700 rounded-full" />
</div>
</div>
</CardContent>
</Card>
<Card className="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-blue-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-blue-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-blue-300" />
<Skeleton className="h-5 w-5 bg-blue-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-blue-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-blue-700" />
<Skeleton className="h-4 w-12 bg-blue-700 rounded-full" />
</div>
</div>
</CardContent>
</Card>
<Card className="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-violet-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-violet-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-violet-300" />
<Skeleton className="h-5 w-5 bg-violet-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-violet-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-violet-700" />
<Skeleton className="h-4 w-12 bg-violet-700 rounded-full" />
</div>
</div>
</CardContent>
</Card>
<Card className="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-orange-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-orange-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-orange-300" />
<Skeleton className="h-5 w-5 bg-orange-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-orange-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-orange-700" />
<Skeleton className="h-4 w-12 bg-orange-700 rounded-full" />
</div>
</div>
</CardContent>
</Card>
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load stats: {error}</AlertDescription>
</Alert>
);
}
if (!stats) return null;
const revenueTrend = calculateRevenueTrend();
const orderTrend = calculateOrderTrend();
const aovTrend = calculateAOVTrend();
return (
<>
<div className="grid grid-cols-4 gap-2">
<StatCard
title="Today's Revenue"
value={formatCurrency(stats?.revenue || 0)}
description={
stats?.periodProgress < 100 ? (
<div className="flex items-center gap-1">
<span>Proj: </span>
{projectionLoading ? (
<div className="w-20">
<Skeleton className="h-4 w-15 bg-emerald-700" />
</div>
) : (
formatCurrency(
projection?.projectedRevenue || stats.projectedRevenue
)
)}
</div>
) : null
}
progress={stats?.periodProgress < 100 ? stats.periodProgress : undefined}
trend={projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.trend}
trendValue={
projectionLoading && stats?.periodProgress < 100 ? (
<div className="flex items-center gap-1">
<Skeleton className="h-4 w-4 bg-emerald-700 rounded-full" />
<Skeleton className="h-4 w-8 bg-emerald-700" />
</div>
) : revenueTrend?.value ? (
formatPercentage(revenueTrend.value)
) : null
}
colorClass="text-emerald-200"
titleClass="text-emerald-100 font-bold text-md"
descriptionClass="text-emerald-200 text-md font-semibold"
icon={DollarSign}
iconColor="text-emerald-900"
iconBackground="bg-emerald-300"
onDetailsClick={() => setSelectedMetric("revenue")}
isLoading={loading || !stats}
variant="mini"
background="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800"
/>
<StatCard
title="Today's Orders"
value={stats?.orderCount}
description={`${stats?.itemCount} total items`}
trend={orderTrend?.trend}
trendValue={orderTrend?.value ? formatPercentage(orderTrend.value) : null}
colorClass="text-blue-200"
titleClass="text-blue-100 font-bold text-md"
descriptionClass="text-blue-200 text-md font-semibold"
icon={ShoppingCart}
iconColor="text-blue-900"
iconBackground="bg-blue-300"
onDetailsClick={() => setSelectedMetric("orders")}
isLoading={loading || !stats}
variant="mini"
background="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800"
/>
<StatCard
title="Today's AOV"
value={stats?.averageOrderValue?.toFixed(2)}
valuePrefix="$"
description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
trend={aovTrend?.trend}
trendValue={aovTrend?.value ? formatPercentage(aovTrend.value) : null}
colorClass="text-violet-200"
titleClass="text-violet-100 font-bold text-md"
descriptionClass="text-violet-200 text-md font-semibold"
icon={CircleDollarSign}
iconColor="text-violet-900"
iconBackground="bg-violet-300"
onDetailsClick={() => setSelectedMetric("average_order")}
isLoading={loading || !stats}
variant="mini"
background="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800"
/>
<StatCard
title="Shipped Today"
value={stats?.shipping?.shippedCount || 0}
description={`${stats?.shipping?.locations?.total || 0} locations`}
colorClass="text-orange-200"
titleClass="text-orange-100 font-bold text-md"
descriptionClass="text-orange-200 text-md font-semibold"
icon={Package}
iconColor="text-orange-900"
iconBackground="bg-orange-300"
onDetailsClick={() => setSelectedMetric("shipping")}
isLoading={loading || !stats}
variant="mini"
background="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800"
/>
</div>
<Dialog
open={!!selectedMetric}
onOpenChange={() => setSelectedMetric(null)}
>
<DialogContent className={`w-[80vw] h-[80vh] max-w-none p-0 ${
selectedMetric === 'revenue' ? 'bg-emerald-50 dark:bg-emerald-950/30' :
selectedMetric === 'orders' ? 'bg-blue-50 dark:bg-blue-950/30' :
selectedMetric === 'average_order' ? 'bg-violet-50 dark:bg-violet-950/30' :
selectedMetric === 'shipping' ? 'bg-orange-50 dark:bg-orange-950/30' :
'bg-white dark:bg-gray-950'
} backdrop-blur-md border-none`}>
<div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]">
<div className="h-full w-full p-6">
<DialogHeader>
<DialogTitle className={`text-2xl font-bold ${
selectedMetric === 'revenue' ? 'text-emerald-900 dark:text-emerald-100' :
selectedMetric === 'orders' ? 'text-blue-900 dark:text-blue-100' :
selectedMetric === 'average_order' ? 'text-violet-900 dark:text-violet-100' :
selectedMetric === 'shipping' ? 'text-orange-900 dark:text-orange-100' :
'text-gray-900 dark:text-gray-100'
}`}>
{selectedMetric
? `${selectedMetric
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")} Details`
: ""}
</DialogTitle>
</DialogHeader>
<div className="mt-4 h-[calc(40vh-4rem)] overflow-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
{detailDataLoading[selectedMetric] ? (
<div className="space-y-4 h-full">
{selectedMetric === "shipping" ? (
<MiniSkeletonTable
rows={8}
colorScheme={
selectedMetric === 'revenue' ? 'emerald' :
selectedMetric === 'orders' ? 'blue' :
selectedMetric === 'average_order' ? 'violet' :
'orange'
}
/>
) : (
<>
<MiniSkeletonChart
type={selectedMetric === "orders" ? "bar" : "line"}
metric={selectedMetric}
/>
{selectedMetric === "orders" && (
<div className="mt-8">
<h3 className={`text-lg font-medium mb-4 ${
selectedMetric === 'revenue' ? 'text-emerald-900 dark:text-emerald-200' :
selectedMetric === 'orders' ? 'text-blue-900 dark:text-blue-200' :
selectedMetric === 'average_order' ? 'text-violet-900 dark:text-violet-200' :
selectedMetric === 'shipping' ? 'text-orange-900 dark:text-orange-200' :
'text-gray-900 dark:text-gray-200'
}`}>
Hourly Distribution
</h3>
<MiniSkeletonChart type="bar" metric={selectedMetric} />
</div>
)}
</>
)}
</div>
) : (
<div className="h-full">
{selectedMetric === "revenue" && (
<RevenueDetails
data={detailData.revenue || []}
colorScheme="emerald"
/>
)}
{selectedMetric === "orders" && (
<OrdersDetails
data={detailData.orders || []}
colorScheme="blue"
/>
)}
{selectedMetric === "average_order" && (
<AverageOrderDetails
data={detailData.average_order || []}
orderCount={stats.orderCount}
colorScheme="violet"
/>
)}
{selectedMetric === "shipping" && (
<ShippingDetails
data={[stats]}
timeRange={timeRange}
colorScheme="orange"
/>
)}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
};
export default MiniStatCards;
@@ -0,0 +1,268 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/dashboard/ui/button";
import { Card, CardContent } from "@/components/dashboard/ui/card";
import { cn } from "@/lib/utils";
import { useScroll } from "@/contexts/DashboardScrollContext";
import { ArrowUpToLine } from "lucide-react";
const Navigation = () => {
const [activeSections, setActiveSections] = useState([]);
const { isStuck, scrollContainerRef, scrollToSection } = useScroll();
const buttonRefs = useRef({});
const scrollContainerRef2 = useRef(null);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const lastScrollLeft = useRef(0);
const lastScrollTop = useRef(0);
// Define base sections that are always visible
const baseSections = [
{ id: "stats", label: "Statistics" },
{ id: "realtime", label: "Realtime" },
{ id: "feed", label: "Event Feed" },
{ id: "sales", label: "Sales Chart" },
{ id: "products", label: "Top Products" },
{ id: "campaigns", label: "Campaigns" },
{ id: "analytics", label: "Analytics" },
{ id: "user-behavior", label: "User Behavior" },
{ id: "meta-campaigns", label: "Meta Ads" },
{ id: "typeform", label: "Customer Surveys" },
{ id: "gorgias-overview", label: "Customer Service" },
{ id: "calls", label: "Calls" },
];
const sortSections = (sections) => {
const isMediumScreen = window.matchMedia(
"(min-width: 768px) and (max-width: 1023px)"
).matches;
return [...sections].sort((a, b) => {
const aOrder = a.order
? isMediumScreen
? a.order.md
: a.order.default
: 0;
const bOrder = b.order
? isMediumScreen
? b.order.md
: b.order.default
: 0;
if (aOrder && bOrder) {
return aOrder - bOrder;
}
return 0;
});
};
const sections = sortSections(baseSections);
const scrollToTop = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: 0,
behavior: "smooth",
});
} else {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
};
const handleSectionClick = (sectionId, responsiveIds) => {
scrollToSection(sectionId);
};
// Track horizontal scroll position changes
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const handleButtonBarScroll = () => {
if (Math.abs(container.scrollLeft - lastScrollLeft.current) > 5) {
setShouldAutoScroll(false);
}
lastScrollLeft.current = container.scrollLeft;
};
container.addEventListener("scroll", handleButtonBarScroll);
return () => container.removeEventListener("scroll", handleButtonBarScroll);
}, []);
// Handle page scroll and active sections
useEffect(() => {
const handlePageScroll = (e) => {
const scrollTop = e?.target?.scrollTop || window.pageYOffset || document.documentElement.scrollTop;
if (Math.abs(scrollTop - lastScrollTop.current) > 5) {
setShouldAutoScroll(true);
lastScrollTop.current = scrollTop;
} else {
return;
}
const activeIds = [];
const viewportHeight = window.innerHeight;
const threshold = viewportHeight * 0.5;
const container = scrollContainerRef.current;
sections.forEach((section) => {
if (section.responsiveIds) {
const visibleId = section.responsiveIds.find((id) => {
const element = document.getElementById(id);
if (!element) return false;
const style = window.getComputedStyle(element);
if (style.display === "none") return false;
if (container) {
// For container-based scrolling
const rect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const relativeTop = rect.top - containerRect.top;
const relativeBottom = rect.bottom - containerRect.top;
return (
relativeTop < containerRect.height - threshold &&
relativeBottom > threshold
);
} else {
// For window-based scrolling
const rect = element.getBoundingClientRect();
return (
rect.top < viewportHeight - threshold && rect.bottom > threshold
);
}
});
if (visibleId) {
activeIds.push(section.id);
}
} else {
const element = document.getElementById(section.id);
if (element) {
if (container) {
// For container-based scrolling
const rect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const relativeTop = rect.top - containerRect.top;
const relativeBottom = rect.bottom - containerRect.top;
if (
relativeTop < containerRect.height - threshold &&
relativeBottom > threshold
) {
activeIds.push(section.id);
}
} else {
// For window-based scrolling
const rect = element.getBoundingClientRect();
if (
rect.top < viewportHeight - threshold &&
rect.bottom > threshold
) {
activeIds.push(section.id);
}
}
}
}
});
setActiveSections(activeIds);
if (shouldAutoScroll && activeIds.length > 0) {
const firstActiveButton = buttonRefs.current[activeIds[0]];
if (firstActiveButton && scrollContainerRef2.current) {
scrollContainerRef2.current.scrollTo({
left:
firstActiveButton.offsetLeft -
scrollContainerRef2.current.offsetWidth / 2 +
firstActiveButton.offsetWidth / 2,
behavior: "auto",
});
}
}
};
// Attach to container or window
const container = scrollContainerRef.current;
if (container) {
container.addEventListener("scroll", handlePageScroll);
handlePageScroll({ target: container });
} else {
window.addEventListener("scroll", handlePageScroll);
handlePageScroll();
}
return () => {
if (container) {
container.removeEventListener("scroll", handlePageScroll);
} else {
window.removeEventListener("scroll", handlePageScroll);
}
};
}, [sections, shouldAutoScroll, scrollContainerRef]);
return (
<div
className={cn(
"sticky z-50 px-4 transition-all duration-200",
isStuck ? "top-1 sm:top-2 md:top-4 rounded-lg" : "rounded-t-none"
)}
>
<Card
className={cn(
"w-full bg-white dark:bg-gray-900 transition-all duration-200",
isStuck
? "rounded-lg mt-2 shadow-md"
: "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2"
)}
>
<CardContent className="py-2 px-4">
<div className="grid grid-cols-[1fr_auto] items-center min-w-0 relative">
<div
ref={scrollContainerRef2}
className="overflow-x-auto no-scrollbar min-w-0 -mx-1 px-1 touch-pan-x overscroll-y-contain pr-12"
>
<div className="flex flex-nowrap space-x-1">
{sections.map(({ id, label, responsiveIds }) => (
<Button
key={id}
ref={(el) => (buttonRefs.current[id] = el)}
variant={activeSections.includes(id) ? "default" : "ghost"}
size="sm"
className={cn(
"whitespace-nowrap flex-shrink-0 px-1 md:px-3 py-2 transition-all duration-200",
activeSections.includes(id) &&
"bg-blue-100 dark:bg-blue-900/70 text-primary dark:text-blue-100 shadow-sm hover:bg-blue-100 dark:hover:bg-blue-900/70 md:hover:bg-blue-200 dark:md:hover:bg-blue-900",
!activeSections.includes(id) &&
"hover:bg-blue-100 dark:hover:bg-blue-900/40 md:hover:bg-blue-50 dark:md:hover:bg-blue-900/20 hover:text-primary dark:hover:text-blue-100 dark:text-gray-400",
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50"
)}
onClick={() => handleSectionClick(id, responsiveIds)}
>
{label}
</Button>
))}
</div>
</div>
<div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-white dark:bg-gray-900 pl-1 pr-0">
<Button
variant="icon"
size="sm"
className={cn(
"flex-shrink-0 h-10 w-10 p-0 hover:bg-blue-100 dark:hover:bg-blue-900/40",
isStuck ? "" : "hidden"
)}
onClick={scrollToTop}
>
<ArrowUpToLine className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default Navigation;
@@ -0,0 +1,203 @@
import React, { useState, useCallback, useEffect } from 'react';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/dashboard/ui/input-otp"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/dashboard/ui/card";
import { Button } from "@/components/dashboard/ui/button";
import { Lock, Delete } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
const MAX_ATTEMPTS = 3;
const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
const PinProtection = ({ onSuccess }) => {
const [pin, setPin] = useState("");
const [attempts, setAttempts] = useState(() => {
return parseInt(localStorage.getItem('pinAttempts') || '0');
});
const [lockoutTime, setLockoutTime] = useState(() => {
const lastAttempt = localStorage.getItem('lastAttemptTime');
if (!lastAttempt) return 0;
const timeSinceLastAttempt = Date.now() - parseInt(lastAttempt);
if (timeSinceLastAttempt < LOCKOUT_DURATION) {
return LOCKOUT_DURATION - timeSinceLastAttempt;
}
return 0;
});
const { toast } = useToast();
useEffect(() => {
let timer;
if (lockoutTime > 0) {
timer = setInterval(() => {
setLockoutTime(prev => {
const newTime = prev - 1000;
if (newTime <= 0) {
localStorage.removeItem('pinAttempts');
localStorage.removeItem('lastAttemptTime');
return 0;
}
return newTime;
});
}, 1000);
}
return () => clearInterval(timer);
}, [lockoutTime]);
const handleComplete = useCallback((value) => {
if (lockoutTime > 0) {
return;
}
const newAttempts = attempts + 1;
setAttempts(newAttempts);
localStorage.setItem('pinAttempts', newAttempts.toString());
localStorage.setItem('lastAttemptTime', Date.now().toString());
if (newAttempts >= MAX_ATTEMPTS) {
setLockoutTime(LOCKOUT_DURATION);
toast({
title: "Too many attempts",
description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`,
variant: "destructive",
});
setPin("");
return;
}
if (value === "123456") {
toast({
title: "Success",
description: "PIN accepted",
});
// Reset attempts on success
setAttempts(0);
localStorage.removeItem('pinAttempts');
localStorage.removeItem('lastAttemptTime');
onSuccess();
} else {
toast({
title: "Error",
description: `Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`,
variant: "destructive",
});
setPin("");
}
}, [attempts, lockoutTime, onSuccess, toast]);
const handleKeyPress = (value) => {
if (pin.length < 6) {
const newPin = pin + value;
setPin(newPin);
if (newPin.length === 6) {
handleComplete(newPin);
}
}
};
const handleDelete = () => {
setPin(prev => prev.slice(0, -1));
};
const renderKeypad = () => {
const keys = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
['clear', 0, 'delete']
];
return keys.map((row, rowIndex) => (
<div key={rowIndex} className="flex justify-center gap-4">
{row.map((key, index) => {
if (key === 'delete') {
return (
<Button
key={key}
variant="ghost"
className="w-16 h-16 text-lg font-medium hover:bg-muted"
onClick={handleDelete}
>
<Delete className="h-6 w-6" />
</Button>
);
}
if (key === 'clear') {
return (
<Button
key={key}
variant="ghost"
className="w-16 h-16 text-lg font-medium hover:bg-muted"
onClick={() => setPin("")}
>
Clear
</Button>
);
}
return (
<Button
key={key}
variant="ghost"
className="w-16 h-16 text-2xl font-medium hover:bg-muted"
onClick={() => handleKeyPress(key.toString())}
>
{key}
</Button>
);
})}
</div>
));
};
// Create masked version of PIN
const maskedPin = pin.replace(/./g, '•');
return (
<div className="min-h-screen w-screen flex items-center justify-center bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<Lock className="h-12 w-12 text-gray-500" />
</div>
<CardTitle className="text-2xl text-center">Enter PIN</CardTitle>
<CardDescription className="text-center">
{lockoutTime > 0 ? (
`Too many attempts. Try again in ${Math.ceil(lockoutTime / 60000)} minutes`
) : (
"Enter your PIN to access the display"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={maskedPin}
disabled
>
<InputOTPGroup>
{[0,1,2,3,4,5].map((index) => (
<InputOTPSlot
key={index}
index={index}
className="w-14 h-14 text-2xl border-2 rounded-lg"
readOnly
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
<div className="space-y-4">
{renderKeypad()}
</div>
</CardContent>
</Card>
</div>
);
};
export default PinProtection;
@@ -0,0 +1,401 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/dashboard/ui/card";
import { ScrollArea } from "@/components/dashboard/ui/scroll-area";
import { Loader2, ArrowUpDown, AlertCircle, Package, Settings2, Search, X } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/dashboard/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/dashboard/ui/select";
import { Input } from "@/components/dashboard/ui/input";
import { Button } from "@/components/dashboard/ui/button";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/dashboard/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
const ProductGrid = ({
timeRange = "today",
onTimeRangeChange,
title = "Top Products",
description
}) => {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange);
const [sorting, setSorting] = useState({
column: "totalQuantity",
direction: "desc",
});
const [searchQuery, setSearchQuery] = useState("");
const [isSearchVisible, setIsSearchVisible] = useState(false);
useEffect(() => {
fetchProducts();
}, [selectedTimeRange]);
const fetchProducts = async () => {
try {
setLoading(true);
setError(null);
const response = await acotService.getProducts({ timeRange: selectedTimeRange });
setProducts(response.stats.products.list || []);
} catch (error) {
console.error("Error fetching products:", error);
setError(error.message);
} finally {
setLoading(false);
}
};
const handleTimeRangeChange = (value) => {
setSelectedTimeRange(value);
if (onTimeRangeChange) {
onTimeRangeChange(value);
}
};
const handleSort = (column) => {
setSorting((prev) => ({
column,
direction:
prev.column === column && prev.direction === "desc" ? "asc" : "desc",
}));
};
const sortedProducts = [...products].sort((a, b) => {
const direction = sorting.direction === "desc" ? -1 : 1;
const aValue = a[sorting.column];
const bValue = b[sorting.column];
if (typeof aValue === "number") {
return (aValue - bValue) * direction;
}
return String(aValue).localeCompare(String(bValue)) * direction;
});
const filteredProducts = sortedProducts.filter(product =>
product.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const SkeletonProduct = () => (
<tr className="hover:bg-muted/50 transition-colors">
<td className="p-1 align-middle w-[50px]">
<Skeleton className="h-[50px] w-[50px] rounded bg-muted" />
</td>
<td className="p-1 align-middle min-w-[200px]">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-[180px] bg-muted rounded-sm" />
<Skeleton className="h-3 w-[140px] bg-muted rounded-sm" />
</div>
</td>
<td className="p-1 align-middle text-center">
<Skeleton className="h-4 w-8 mx-auto bg-muted rounded-sm" />
</td>
<td className="p-1 align-middle text-center">
<Skeleton className="h-4 w-16 mx-auto bg-muted rounded-sm" />
</td>
<td className="p-1 align-middle text-center">
<Skeleton className="h-4 w-8 mx-auto bg-muted rounded-sm" />
</td>
</tr>
);
const LoadingState = () => (
<div className="h-full">
<div className="overflow-y-auto h-full">
<table className="w-full">
<thead>
<tr className="hover:bg-transparent">
<th className="p-1.5 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 w-[50px] min-w-[50px] border-b dark:border-gray-800" />
<th className="p-1.5 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 min-w-[200px] border-b dark:border-gray-800">
<Button
variant="ghost"
className="w-full p-2 justify-start h-8 pointer-events-none"
disabled
>
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
</Button>
</th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
<Button
variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none"
disabled
>
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
</Button>
</th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
<Button
variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none"
disabled
>
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
</Button>
</th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
<Button
variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none"
disabled
>
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{[...Array(20)].map((_, i) => (
<SkeletonProduct key={i} />
))}
</tbody>
</table>
</div>
</div>
);
if (loading) {
return (
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-6 pb-4">
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
<Skeleton className="h-6 w-32 bg-muted rounded-sm" />
</CardTitle>
{description && (
<CardDescription className="mt-1">
<Skeleton className="h-4 w-48 bg-muted rounded-sm" />
</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-9 bg-muted rounded-sm" />
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
<div className="h-full">
<LoadingState />
</div>
</CardContent>
</Card>
);
}
return (
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-6 pb-4">
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle>
{description && (
<CardDescription className="mt-1 text-muted-foreground">{description}</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
{!error && (
<Button
variant="outline"
size="icon"
onClick={() => setIsSearchVisible(!isSearchVisible)}
className={cn(
"h-9 w-9",
isSearchVisible && "bg-muted"
)}
>
<Search className="h-4 w-4" />
</Button>
)}
<Select
value={selectedTimeRange}
onValueChange={handleTimeRangeChange}
>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isSearchVisible && !error && (
<div className="relative w-full">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9 h-9 w-full"
autoFocus
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1 h-7 w-7"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
</CardHeader>
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
<div className="h-full">
{error ? (
<Alert variant="destructive" className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load products: {error}
</AlertDescription>
</Alert>
) : !products?.length ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<p className="font-medium mb-2 text-gray-900 dark:text-gray-100">No product data available</p>
<p className="text-sm text-muted-foreground">Try selecting a different time range</p>
</div>
) : (
<div className="h-full">
<div className="overflow-y-auto h-full">
<table className="w-full">
<thead>
<tr className="hover:bg-transparent">
<th className="p-1 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 h-[50px] min-h-[50px] w-[50px] min-w-[35px] border-b dark:border-gray-800" />
<th className="p-1 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
<Button
variant={sorting.column === "name" ? "default" : "ghost"}
onClick={() => handleSort("name")}
className="w-full p-2 justify-start h-8"
>
Product
</Button>
</th>
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
<Button
variant={sorting.column === "totalQuantity" ? "default" : "ghost"}
onClick={() => handleSort("totalQuantity")}
className="w-full p-2 justify-center h-8"
>
Sold
</Button>
</th>
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
<Button
variant={sorting.column === "totalRevenue" ? "default" : "ghost"}
onClick={() => handleSort("totalRevenue")}
className="w-full p-2 justify-center h-8"
>
Rev
</Button>
</th>
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
<Button
variant={sorting.column === "orderCount" ? "default" : "ghost"}
onClick={() => handleSort("orderCount")}
className="w-full p-2 justify-center h-8"
>
Orders
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filteredProducts.map((product) => (
<tr
key={product.id}
className="hover:bg-muted/50 transition-colors"
>
<td className="p-1 align-middle w-[50px]">
{product.ImgThumb && (
<img
src={product.ImgThumb}
alt=""
width={50}
height={50}
className="rounded bg-muted w-[50px] h-[50px] object-contain"
onError={(e) => (e.target.style.display = "none")}
/>
)}
</td>
<td className="p-1 align-middle min-w-[200px]">
<div className="flex flex-col min-w-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href={`https://backend.acherryontop.com/product/${product.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm hover:underline line-clamp-2 text-gray-900 dark:text-gray-100"
>
{product.name}
</a>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px]">
<p>{product.name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</td>
<td className="p-1 align-middle text-center text-sm font-medium text-gray-900 dark:text-gray-100">
{product.totalQuantity}
</td>
<td className="p-1 align-middle text-center text-emerald-600 dark:text-emerald-400 text-sm font-medium">
${product.totalRevenue.toFixed(2)}
</td>
<td className="p-1 align-middle text-center text-muted-foreground text-sm">
{product.orderCount}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
};
export default ProductGrid;
@@ -0,0 +1,633 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from "recharts";
import { Loader2, AlertTriangle } from "lucide-react";
import {
Tooltip as UITooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/dashboard/ui/tooltip";
import { Alert, AlertDescription } from "@/components/dashboard/ui/alert";
import { Button } from "@/components/dashboard/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/dashboard/ui/tabs";
import {
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/components/dashboard/ui/table";
import { format } from "date-fns";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
export const METRIC_COLORS = {
activeUsers: {
color: "#8b5cf6",
className: "text-purple-600 dark:text-purple-400",
},
pages: {
color: "#10b981",
className: "text-emerald-600 dark:text-emerald-400",
},
sources: {
color: "#f59e0b",
className: "text-amber-600 dark:text-amber-400",
},
};
export const summaryCard = (label, sublabel, value, options = {}) => {
const {
colorClass = "text-gray-900 dark:text-gray-100",
titleClass = "text-sm font-medium text-gray-500 dark:text-gray-400",
descriptionClass = "text-sm text-gray-600 dark:text-gray-300",
background = "bg-white dark:bg-gray-900/60",
icon: Icon,
iconColor,
iconBackground
} = options;
return (
<Card className={`w-full ${background} backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-2">
<CardTitle className={titleClass}>
{label}
</CardTitle>
{Icon && (
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full ${iconBackground}`} />
<Icon className={`h-5 w-5 ${iconColor} relative`} />
</div>
)}
</CardHeader>
<CardContent className="px-4 pt-0 pb-2">
<div className="space-y-2">
<div>
<div className={`text-3xl font-extrabold ${colorClass}`}>
{value.toLocaleString()}
</div>
<div className={descriptionClass}>
{sublabel}
</div>
</div>
</div>
</CardContent>
</Card>
);
};
export const SkeletonSummaryCard = () => (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-2">
<Skeleton className="h-4 w-24 bg-muted" />
</CardHeader>
<CardContent className="px-4 pt-0 pb-2">
<Skeleton className="h-8 w-20 mb-1 bg-muted" />
<Skeleton className="h-4 w-32 bg-muted" />
</CardContent>
</Card>
);
export const SkeletonBarChart = () => (
<div className="h-[235px] bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
<div className="h-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-muted" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-muted" />
))}
</div>
{/* Bars */}
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between">
{[...Array(30)].map((_, i) => (
<div
key={i}
className="w-1.5 bg-muted"
style={{
height: `${Math.random() * 80 + 10}%`,
}}
/>
))}
</div>
</div>
</div>
);
export const SkeletonTable = () => (
<div className="space-y-2 h-[230px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead>
<Skeleton className="h-4 w-32 bg-muted" />
</TableHead>
<TableHead className="text-right">
<Skeleton className="h-4 w-24 ml-auto bg-muted" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(8)].map((_, i) => (
<TableRow key={i} className="dark:border-gray-800">
<TableCell>
<Skeleton className="h-4 w-48 bg-muted" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-12 ml-auto bg-muted" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
export const processBasicData = (data) => {
const last30MinUsers = parseInt(
data.userResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
);
const last5MinUsers = parseInt(
data.fiveMinResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
);
const byMinute = Array.from({ length: 30 }, (_, i) => {
const matchingRow = data.timeSeriesResponse?.rows?.find(
(row) => parseInt(row.dimensionValues[0].value) === i
);
const users = matchingRow
? parseInt(matchingRow.metricValues[0].value)
: 0;
const timestamp = new Date(Date.now() - i * 60000);
return {
minute: -i,
users,
timestamp: timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
};
}).reverse();
const tokenQuota = data.quotaInfo
? {
projectHourly: data.quotaInfo.projectHourly || {},
daily: data.quotaInfo.daily || {},
serverErrors: data.quotaInfo.serverErrors || {},
thresholdedRequests: data.quotaInfo.thresholdedRequests || {},
}
: null;
return {
last30MinUsers,
last5MinUsers,
byMinute,
tokenQuota,
lastUpdated: new Date().toISOString(),
};
};
export const QuotaInfo = ({ tokenQuota }) => {
if (!tokenQuota || typeof tokenQuota !== "object") return null;
const {
projectHourly = {},
daily = {},
serverErrors = {},
thresholdedRequests = {},
} = tokenQuota;
const {
remaining: projectHourlyRemaining = 0,
consumed: projectHourlyConsumed = 0,
} = projectHourly;
const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily;
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
serverErrors;
const {
remaining: thresholdRemaining = 120,
consumed: thresholdConsumed = 0,
} = thresholdedRequests;
const hourlyPercentage = ((projectHourlyRemaining / 14000) * 100).toFixed(1);
const dailyPercentage = ((dailyRemaining / 200000) * 100).toFixed(1);
const errorPercentage = ((errorsRemaining / 10) * 100).toFixed(1);
const thresholdPercentage = ((thresholdRemaining / 120) * 100).toFixed(1);
const getStatusColor = (percentage) => {
const numericPercentage = parseFloat(percentage);
if (isNaN(numericPercentage) || numericPercentage < 20)
return "text-red-500 dark:text-red-400";
if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400";
return "text-green-500 dark:text-green-400";
};
return (
<>
<div className="flex items-center font-semibold rounded-md space-x-1">
<span>Quota:</span>
<span className={`font-medium ${getStatusColor(hourlyPercentage)}`}>
{hourlyPercentage}%
</span>
</div>
<div className="dark:border-gray-700">
<div className="space-y-3 mt-2">
<div>
<div className="font-semibold text-gray-100">
Project Hourly
</div>
<div className={`${getStatusColor(hourlyPercentage)}`}>
{projectHourlyRemaining.toLocaleString()} / 14,000 remaining
</div>
</div>
<div>
<div className="font-semibold text-gray-100">
Daily
</div>
<div className={`${getStatusColor(dailyPercentage)}`}>
{dailyRemaining.toLocaleString()} / 200,000 remaining
</div>
</div>
<div>
<div className="font-semibold text-gray-100">
Server Errors
</div>
<div className={`${getStatusColor(errorPercentage)}`}>
{errorsConsumed} / 10 used this hour
</div>
</div>
<div>
<div className="font-semibold text-gray-100">
Thresholded Requests
</div>
<div className={`${getStatusColor(thresholdPercentage)}`}>
{thresholdConsumed} / 120 used this hour
</div>
</div>
</div>
</div>
</>
);
};
export const RealtimeAnalytics = () => {
const [basicData, setBasicData] = useState({
last30MinUsers: 0,
last5MinUsers: 0,
byMinute: [],
tokenQuota: null,
lastUpdated: null,
});
const [detailedData, setDetailedData] = useState({
currentPages: [],
sources: [],
recentEvents: [],
lastUpdated: null,
});
const [loading, setLoading] = useState(true);
const [isPaused, setIsPaused] = useState(false);
const [error, setError] = useState(null);
const processDetailedData = (data) => {
return {
currentPages:
data.pageResponse?.rows?.map((row) => ({
path: row.dimensionValues[0].value,
activeUsers: parseInt(row.metricValues[0].value),
})) || [],
sources:
data.sourceResponse?.rows?.map((row) => ({
source: row.dimensionValues[0].value,
activeUsers: parseInt(row.metricValues[0].value),
})) || [],
recentEvents:
data.eventResponse?.rows
?.filter(
(row) =>
!["session_start", "(other)"].includes(
row.dimensionValues[0].value
)
)
.map((row) => ({
event: row.dimensionValues[0].value,
count: parseInt(row.metricValues[0].value),
})) || [],
lastUpdated: new Date().toISOString(),
};
};
useEffect(() => {
let basicInterval;
let detailedInterval;
const fetchBasicData = async () => {
if (isPaused) return;
try {
const response = await fetch("/api/dashboard-analytics/realtime/basic", {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch basic realtime data");
}
const result = await response.json();
const processed = processBasicData(result.data);
setBasicData(processed);
setError(null);
} catch (error) {
console.error("Error details:", {
message: error.message,
stack: error.stack,
response: error.response,
});
if (error.message === "QUOTA_EXCEEDED") {
setError("Quota exceeded. Analytics paused until manually resumed.");
setIsPaused(true);
} else {
setError("Failed to fetch analytics data");
}
}
};
const fetchDetailedData = async () => {
if (isPaused) return;
try {
const response = await fetch("/api/dashboard-analytics/realtime/detailed", {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch detailed realtime data");
}
const result = await response.json();
const processed = processDetailedData(result.data);
setDetailedData(processed);
} catch (error) {
console.error("Failed to fetch detailed realtime data:", error);
if (error.message === "QUOTA_EXCEEDED") {
setError("Quota exceeded. Analytics paused until manually resumed.");
setIsPaused(true);
} else {
setError("Failed to fetch analytics data");
}
} finally {
setLoading(false);
}
};
// Initial fetches
fetchBasicData();
fetchDetailedData();
// Set up intervals
basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds
detailedInterval = setInterval(fetchDetailedData, 300000); // 5 minutes
return () => {
clearInterval(basicInterval);
clearInterval(detailedInterval);
};
}, [isPaused]);
const togglePause = () => {
setIsPaused(!isPaused);
};
if (loading && !basicData && !detailedData) {
return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
<CardHeader className="p-6 pb-2">
<div className="flex justify-between items-center">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Real-Time Analytics
</CardTitle>
<Skeleton className="h-4 w-32 bg-muted" />
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
<div className="grid grid-cols-2 gap-2 md:gap-3 mt-1 mb-3">
<SkeletonSummaryCard />
<SkeletonSummaryCard />
</div>
<div className="space-y-4">
<div className="flex gap-2">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-8 w-20 bg-muted rounded-md" />
))}
</div>
<SkeletonBarChart />
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
<CardHeader className="p-6 pb-2">
<div className="flex justify-between items-center">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Real-Time Analytics
</CardTitle>
<div className="flex items-end">
<TooltipProvider>
<UITooltip>
<TooltipTrigger>
<div className="text-xs text-muted-foreground">
Last updated:{" "}
{format(new Date(basicData.lastUpdated), "h:mm a")}
</div>
</TooltipTrigger>
<TooltipContent className="p-3">
<QuotaInfo tokenQuota={basicData.tokenQuota} />
</TooltipContent>
</UITooltip>
</TooltipProvider>
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-4 mt-1 mb-3">
{summaryCard(
"Last 30 minutes",
"Active users",
basicData.last30MinUsers,
{ colorClass: METRIC_COLORS.activeUsers.className }
)}
{summaryCard(
"Last 5 minutes",
"Active users",
basicData.last5MinUsers,
{ colorClass: METRIC_COLORS.activeUsers.className }
)}
</div>
<Tabs defaultValue="activity" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="pages">Current Pages</TabsTrigger>
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
</TabsList>
<TabsContent value="activity">
<div className="h-[235px] bg-card rounded-lg">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={basicData.byMinute}
margin={{ top: 5, right: 5, left: -35, bottom: -5 }}
>
<XAxis
dataKey="minute"
tickFormatter={(value) => value + "m"}
className="text-xs"
tick={{ fill: "currentColor" }}
/>
<YAxis className="text-xs" tick={{ fill: "currentColor" }} />
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const timestamp = new Date(
Date.now() + payload[0].payload.minute * 60000
);
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
<CardContent className="p-0 space-y-2">
<p className="font-medium text-sm border-b pb-1 mb-2">
{format(timestamp, "h:mm a")}
</p>
<div className="flex justify-between items-center text-sm">
<span
style={{
color: METRIC_COLORS.activeUsers.color,
}}
>
Active Users:
</span>
<span className="font-medium ml-4">
{payload[0].value.toLocaleString()}
</span>
</div>
</CardContent>
</Card>
);
}
return null;
}}
/>
<Bar dataKey="users" fill={METRIC_COLORS.activeUsers.color} />
</BarChart>
</ResponsiveContainer>
</div>
</TabsContent>
<TabsContent value="pages">
<div className="space-y-2 h-[230px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Page
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Active Users
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.currentPages.map((page, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{page.path}
</TableCell>
<TableCell
className={`text-right ${METRIC_COLORS.pages.className}`}
>
{page.activeUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="sources">
<div className="space-y-2 h-[230px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Source
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Active Users
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.sources.map((source, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{source.source}
</TableCell>
<TableCell
className={`text-right ${METRIC_COLORS.sources.className}`}
>
{source.activeUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};
export default RealtimeAnalytics;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,700 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/dashboard/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/dashboard/ui/table";
import { Badge } from "@/components/dashboard/ui/badge";
import { ScrollArea } from "@/components/dashboard/ui/scroll-area";
import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
import { AlertCircle } from "lucide-react";
import { format } from "date-fns";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
} from "recharts";
// Get form IDs from environment variables
const FORM_IDS = {
FORM_1: import.meta.env.VITE_TYPEFORM_FORM_ID_1,
FORM_2: import.meta.env.VITE_TYPEFORM_FORM_ID_2,
};
const FORM_NAMES = {
[FORM_IDS.FORM_1]: "Product Relevance",
[FORM_IDS.FORM_2]: "Winback Survey",
};
// Loading skeleton components
const SkeletonChart = () => (
<div className="h-[300px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
<div className="h-full relative">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-muted" />
))}
</div>
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-3 w-16 bg-muted" />
))}
</div>
</div>
</div>
);
const SkeletonTable = () => (
<div className="space-y-2">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[200px]">
<Skeleton className="h-4 w-[180px] bg-muted" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[100px] bg-muted" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[80px] bg-muted" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(5)].map((_, i) => (
<TableRow key={i} className="hover:bg-transparent">
<TableCell>
<Skeleton className="h-4 w-[160px] bg-muted" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[90px] bg-muted" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[70px] bg-muted" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
const ResponseFeed = ({ responses, title, renderSummary }) => (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px]">
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{responses.items.map((response) => (
<div key={response.token} className="p-4">
{renderSummary(response)}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
const ProductRelevanceFeed = ({ responses }) => (
<ResponseFeed
responses={responses}
title="Product Relevance Responses"
renderSummary={(response) => {
const answer = response.answers?.find((a) => a.type === "boolean");
const textAnswer = response.answers?.find((a) => a.type === "text")?.text;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{response.hidden?.email ? (
<a
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline"
>
{response.hidden?.name || "Anonymous"}
</a>
) : (
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{response.hidden?.name || "Anonymous"}
</span>
)}
<Badge
className={
answer?.boolean
? "bg-green-200 text-green-700"
: "bg-red-200 text-red-700"
}
>
{answer?.boolean ? "Yes" : "No"}
</Badge>
</div>
<time
className="text-xs text-muted-foreground"
dateTime={response.submitted_at}
>
{format(new Date(response.submitted_at), "MMM d")}
</time>
</div>
{textAnswer && (
<div className="text-sm text-muted-foreground">"{textAnswer}"</div>
)}
</div>
);
}}
/>
);
const WinbackFeed = ({ responses }) => (
<ResponseFeed
responses={responses}
title="Winback Survey Responses"
renderSummary={(response) => {
const likelihoodAnswer = response.answers?.find(
(a) => a.type === "number"
);
const reasonsAnswer = response.answers?.find((a) => a.type === "choices");
const feedbackAnswer = response.answers?.find(
(a) => a.type === "text" && a.field.type === "long_text"
);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{response.hidden?.email ? (
<a
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline"
>
{response.hidden?.name || "Anonymous"}
</a>
) : (
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{response.hidden?.name || "Anonymous"}
</span>
)}
<Badge
className={
likelihoodAnswer?.number === 1
? "bg-red-200 text-red-700"
: likelihoodAnswer?.number === 2
? "bg-orange-200 text-orange-700"
: likelihoodAnswer?.number === 3
? "bg-yellow-200 text-yellow-700"
: likelihoodAnswer?.number === 4
? "bg-lime-200 text-lime-700"
: likelihoodAnswer?.number === 5
? "bg-green-200 text-green-700"
: "bg-gray-200 text-gray-700"
}
>
{likelihoodAnswer?.number}/5
</Badge>
</div>
<time
className="text-xs text-muted-foreground"
dateTime={response.submitted_at}
>
{format(new Date(response.submitted_at), "MMM d")}
</time>
</div>
<div className="flex flex-wrap gap-1">
{(reasonsAnswer?.choices?.labels || []).map((label, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{label}
</Badge>
))}
{reasonsAnswer?.choices?.other && (
<Badge variant="outline" className="text-xs">
{reasonsAnswer.choices.other}
</Badge>
)}
</div>
{feedbackAnswer?.text && (
<div className="text-sm text-muted-foreground">
{feedbackAnswer.text}
</div>
)}
</div>
);
}}
/>
);
const TypeformDashboard = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [formData, setFormData] = useState({
form1: { responses: null, hasMore: false, lastToken: null },
form2: { responses: null, hasMore: false, lastToken: null },
});
const fetchResponses = async (formId, before = null) => {
const params = { page_size: 1000 };
if (before) params.before = before;
const response = await axios.get(
`/api/typeform/forms/${formId}/responses`,
{ params }
);
return response.data;
};
useEffect(() => {
const fetchFormData = async () => {
try {
setLoading(true);
setError(null);
const forms = [FORM_IDS.FORM_1, FORM_IDS.FORM_2];
const results = await Promise.all(
forms.map(async (formId) => {
const responses = await fetchResponses(formId);
const hasMore = responses.items.length === 1000;
const lastToken = hasMore
? responses.items[responses.items.length - 1].token
: null;
return {
responses,
hasMore,
lastToken,
};
})
);
setFormData({
form1: results[0],
form2: results[1],
});
} catch (err) {
console.error("Error fetching Typeform data:", err);
setError("Failed to load form data. Please try again later.");
} finally {
setLoading(false);
}
};
fetchFormData();
}, []);
const calculateMetrics = () => {
if (!formData.form1.responses || !formData.form2.responses) return null;
const form1Responses = formData.form1.responses.items;
const form2Responses = formData.form2.responses.items;
// Product Relevance metrics
const yesResponses = form1Responses.filter((r) =>
r.answers?.some((a) => a.type === "boolean" && a.boolean === true)
).length;
const totalForm1 = form1Responses.length;
const yesPercentage = Math.round((yesResponses / totalForm1) * 100) || 0;
// Winback Survey metrics
const likelihoodAnswers = form2Responses
.map((r) => r.answers?.find((a) => a.type === "number"))
.filter(Boolean)
.map((a) => a.number);
const averageLikelihood = likelihoodAnswers.length
? Math.round(
(likelihoodAnswers.reduce((a, b) => a + b, 0) /
likelihoodAnswers.length) *
10
) / 10
: 0;
// Get reasons for not ordering (only predefined choices)
const reasonsMap = new Map();
form2Responses.forEach((response) => {
const reasonsAnswer = response.answers?.find((a) => a.type === "choices");
if (reasonsAnswer?.choices?.labels) {
reasonsAnswer.choices.labels.forEach((label) => {
reasonsMap.set(label, (reasonsMap.get(label) || 0) + 1);
});
}
});
const sortedReasons = Array.from(reasonsMap.entries())
.sort(([, a], [, b]) => b - a)
.map(([label, count]) => ({
reason: label,
count,
percentage: Math.round((count / form2Responses.length) * 100),
}));
return {
productRelevance: {
yesPercentage,
yesCount: yesResponses,
noCount: totalForm1 - yesResponses,
},
winback: {
averageRating: averageLikelihood,
reasons: sortedReasons,
},
};
};
const metrics = loading ? null : calculateMetrics();
// Find the newest response across both forms
const getNewestResponse = () => {
if (
!formData.form1.responses?.items?.length &&
!formData.form2.responses?.items?.length
)
return null;
const form1Latest = formData.form1.responses?.items[0]?.submitted_at;
const form2Latest = formData.form2.responses?.items[0]?.submitted_at;
if (!form1Latest) return form2Latest;
if (!form2Latest) return form1Latest;
return new Date(form1Latest) > new Date(form2Latest)
? form1Latest
: form2Latest;
};
const newestResponse = getNewestResponse();
if (error) {
return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
{error}
</div>
</CardContent>
</Card>
);
}
// Calculate likelihood counts for the chart
const likelihoodCounts =
!loading && formData.form2.responses
? [1, 2, 3, 4, 5].map((rating) => ({
rating: rating.toString(),
count: formData.form2.responses.items.filter(
(r) =>
r.answers?.find((a) => a.type === "number")?.number === rating
).length,
}))
: [];
return (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-6 pb-0">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Customer Surveys
</CardTitle>
{newestResponse && (
<p className="text-sm text-muted-foreground">
Newest response:{" "}
{format(new Date(newestResponse), "MMM d, h:mm a")}
</p>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="space-y-4">
<SkeletonChart />
<SkeletonTable />
</div>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-6">
<div className="flex items-baseline justify-between">
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">
How likely are you to place another order with us?
</CardTitle>
<span
className={`text-2xl font-bold ${
metrics.winback.averageRating <= 1
? "text-red-600 dark:text-red-500"
: metrics.winback.averageRating <= 2
? "text-orange-600 dark:text-orange-500"
: metrics.winback.averageRating <= 3
? "text-yellow-600 dark:text-yellow-500"
: metrics.winback.averageRating <= 4
? "text-lime-600 dark:text-lime-500"
: "text-green-600 dark:text-green-500"
}`}
>
{metrics.winback.averageRating}
<span className="text-base font-normal text-muted-foreground">
/5 avg
</span>
</span>
</div>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={likelihoodCounts}
margin={{ top: 0, right: 10, left: -20, bottom: -25 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="rating"
tickFormatter={(value) => {
return value === "1"
? "Not at all"
: value === "5"
? "Extremely"
: "";
}}
textAnchor="middle"
interval={0}
height={50}
className="text-muted-foreground text-xs md:text-sm"
/>
<YAxis className="text-muted-foreground text-xs md:text-sm" />
<Tooltip
content={({ payload }) => {
if (payload && payload.length) {
const { rating, count } = payload[0].payload;
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none">
<CardContent className="p-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{rating} Rating: {count} responses
</div>
</CardContent>
</Card>
);
}
return null;
}}
/>
<Bar dataKey="count">
{likelihoodCounts.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={
index === 0
? "#ef4444" // red
: index === 1
? "#f97316" // orange
: index === 2
? "#eab308" // yellow
: index === 3
? "#84cc16" // lime
: "#10b981" // green
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="p-6">
<div className="flex items-baseline justify-between gap-2">
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Were the suggested products in this email relevant to you?
</CardTitle>
<div className="flex flex-col items-end">
<span className="text-2xl font-bold text-green-600 dark:text-green-500">
{metrics.productRelevance.yesPercentage}% Relevant
</span>
</div>
</div>
</CardHeader>
<CardContent>
<div className="h-[100px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
{
yes: metrics.productRelevance.yesCount,
no: metrics.productRelevance.noCount,
total:
metrics.productRelevance.yesCount +
metrics.productRelevance.noCount,
},
]}
layout="vertical"
stackOffset="expand"
margin={{ top: 0, right: 0, left: -20, bottom: 0 }}
>
<XAxis type="number" hide domain={[0, 1]} />
<YAxis type="category" hide />
<Tooltip
cursor={false}
content={({ payload }) => {
if (payload && payload.length) {
const yesCount = payload[0].payload.yes;
const noCount = payload[0].payload.no;
const total = yesCount + noCount;
const yesPercent = Math.round(
(yesCount / total) * 100
);
const noPercent = Math.round(
(noCount / total) * 100
);
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none">
<CardContent className="p-0 space-y-2">
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-emerald-500 font-medium">
Yes:
</span>
<span className="ml-4 text-muted-foreground">
{yesCount} ({yesPercent}%)
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-red-500 font-medium">
No:
</span>
<span className="ml-4 text-muted-foreground">
{noCount} ({noPercent}%)
</span>
</div>
</div>
</CardContent>
</Card>
);
}
return null;
}}
/>
<Bar
dataKey="yes"
stackId="stack"
fill="#10b981"
radius={[0, 0, 0, 0]}
>
<text
x="50%"
y="50%"
textAnchor="middle"
fill="#fff"
fontSize={14}
fontWeight="bold"
>
{metrics.productRelevance.yesPercentage}%
</text>
</Bar>
<Bar
dataKey="no"
stackId="stack"
fill="#ef4444"
radius={[0, 0, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex justify-between mt-2 text-md font-semibold mx-1 text-muted-foreground">
<div>Yes: {metrics.productRelevance.yesCount}</div>
<div>No: {metrics.productRelevance.noCount}</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-2 lg:grid-cols-12 gap-4">
<div className="col-span-4 lg:col-span-12 xl:col-span-4">
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
<CardHeader>
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Reasons for Not Ordering
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-medium text-gray-900 dark:text-gray-100">
Reason
</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">
Count
</TableHead>
<TableHead className="text-right w-[80px] font-medium text-gray-900 dark:text-gray-100">
%
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{metrics.winback.reasons.map((reason, index) => (
<TableRow
key={index}
className="hover:bg-muted/50 transition-colors"
>
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{reason.reason}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{reason.count}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{reason.percentage}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
<div className="col-span-4 lg:col-span-6 xl:col-span-4">
<WinbackFeed responses={formData.form2.responses} />
</div>
<div className="col-span-4 lg:col-span-6 xl:col-span-4">
<ProductRelevanceFeed responses={formData.form1.responses} />
</div>
</div>
</>
)}
</CardContent>
</Card>
);
};
export default TypeformDashboard;
@@ -0,0 +1,412 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/dashboard/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/dashboard/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/dashboard/ui/table";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
} from "recharts";
import { Loader2 } from "lucide-react";
import { Skeleton } from "@/components/dashboard/ui/skeleton";
// Add skeleton components
const SkeletonTable = ({ rows = 12 }) => (
<div className="h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2">
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead><Skeleton className="h-4 w-48 bg-muted rounded-sm" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(rows)].map((_, i) => (
<TableRow key={i} className="dark:border-gray-800">
<TableCell className="py-3"><Skeleton className="h-4 w-64 bg-muted rounded-sm" /></TableCell>
<TableCell className="text-right py-3"><Skeleton className="h-4 w-12 ml-auto bg-muted rounded-sm" /></TableCell>
<TableCell className="text-right py-3"><Skeleton className="h-4 w-12 ml-auto bg-muted rounded-sm" /></TableCell>
<TableCell className="text-right py-3"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
const SkeletonPieChart = () => (
<div className="h-60 relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-40 h-40 rounded-full bg-muted animate-pulse" />
</div>
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 flex gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-3 w-3 rounded-full bg-muted" />
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
</div>
))}
</div>
</div>
);
const SkeletonTabs = () => (
<div className="space-y-2">
<div className="flex gap-2 mb-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-8 w-24 bg-muted rounded-sm" />
))}
</div>
</div>
);
export const UserBehaviorDashboard = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState("30");
const processPageData = (data) => {
if (!data?.rows) {
console.log("No rows in page data");
return [];
}
return data.rows.map((row) => ({
path: row.dimensionValues[0].value || "Unknown",
pageViews: parseInt(row.metricValues[0].value || 0),
avgSessionDuration: parseFloat(row.metricValues[1].value || 0),
bounceRate: parseFloat(row.metricValues[2].value || 0) * 100,
engagedSessions: parseInt(row.metricValues[3].value || 0),
}));
};
const processDeviceData = (data) => {
if (!data?.rows) {
console.log("No rows in device data");
return [];
}
return data.rows
.filter((row) => {
const device = (row.dimensionValues[0].value || "").toLowerCase();
return ["desktop", "mobile", "tablet"].includes(device);
})
.map((row) => {
const device = row.dimensionValues[0].value || "Unknown";
return {
device: device.charAt(0).toUpperCase() + device.slice(1).toLowerCase(),
pageViews: parseInt(row.metricValues[0].value || 0),
sessions: parseInt(row.metricValues[1].value || 0),
};
})
.sort((a, b) => b.pageViews - a.pageViews);
};
const processSourceData = (data) => {
if (!data?.rows) {
console.log("No rows in source data");
return [];
}
return data.rows.map((row) => ({
source: row.dimensionValues[0].value || "Unknown",
sessions: parseInt(row.metricValues[0].value || 0),
conversions: parseInt(row.metricValues[1].value || 0),
}));
};
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(
`/api/dashboard-analytics/user-behavior?timeRange=${timeRange}`,
{
credentials: "include",
}
);
if (!response.ok) {
throw new Error("Failed to fetch user behavior");
}
const result = await response.json();
console.log("Raw user behavior response:", result);
if (!result?.success) {
throw new Error("Invalid response structure");
}
// Handle both data structures
const rawData = result.data?.data || result.data;
// Try to access the data differently based on the structure
const pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
console.log("Extracted responses:", {
pageResponse,
deviceResponse,
sourceResponse,
});
const processed = {
success: true,
data: {
pageData: {
pageData: processPageData(pageResponse),
deviceData: processDeviceData(deviceResponse),
},
sourceData: processSourceData(sourceResponse),
},
};
console.log("Final processed data:", processed);
setData(processed);
} catch (error) {
console.error("Failed to fetch behavior data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, [timeRange]);
if (loading) {
return (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
<CardHeader className="p-6 pb-4">
<div className="flex justify-between items-start">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
User Behavior Analysis
</CardTitle>
<Skeleton className="h-9 w-36 bg-muted rounded-sm" />
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
<Tabs defaultValue="pages" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="pages" disabled>Top Pages</TabsTrigger>
<TabsTrigger value="sources" disabled>Traffic Sources</TabsTrigger>
<TabsTrigger value="devices" disabled>Device Usage</TabsTrigger>
</TabsList>
<TabsContent value="pages" className="mt-4 space-y-2">
<SkeletonTable rows={15} />
</TabsContent>
<TabsContent value="sources" className="mt-4 space-y-2">
<SkeletonTable rows={12} />
</TabsContent>
<TabsContent value="devices" className="mt-4 space-y-2">
<SkeletonPieChart />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
const COLORS = {
desktop: "#8b5cf6", // Purple
mobile: "#10b981", // Green
tablet: "#f59e0b", // Yellow
};
const deviceData = data?.data?.pageData?.deviceData || [];
const totalViews = deviceData.reduce((sum, item) => sum + item.pageViews, 0);
const totalSessions = deviceData.reduce(
(sum, item) => sum + item.sessions,
0
);
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1);
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border border-border">
<CardContent className="p-0 space-y-2">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{data.device}
</p>
<p className="text-sm text-muted-foreground">
{data.pageViews.toLocaleString()} views ({percentage}%)
</p>
<p className="text-sm text-muted-foreground">
{data.sessions.toLocaleString()} sessions ({sessionPercentage}%)
</p>
</CardContent>
</Card>
);
}
return null;
};
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
return (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
<CardHeader className="p-6 pb-4">
<div className="flex justify-between items-start">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
User Behavior Analysis
</CardTitle>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-36 h-9">
<SelectValue>
{timeRange === "7" && "Last 7 days"}
{timeRange === "14" && "Last 14 days"}
{timeRange === "30" && "Last 30 days"}
{timeRange === "90" && "Last 90 days"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
<Tabs defaultValue="pages" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="pages">Top Pages</TabsTrigger>
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
<TabsTrigger value="devices">Device Usage</TabsTrigger>
</TabsList>
<TabsContent
value="pages"
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-foreground">Page Path</TableHead>
<TableHead className="text-right text-foreground">Views</TableHead>
<TableHead className="text-right text-foreground">Bounce Rate</TableHead>
<TableHead className="text-right text-foreground">Avg. Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.pageData?.pageData.map((page, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-foreground">
{page.path}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{page.pageViews.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{page.bounceRate.toFixed(1)}%
</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatDuration(page.avgSessionDuration)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
<TabsContent
value="sources"
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-foreground w-[35%] min-w-[120px]">Source</TableHead>
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Sessions</TableHead>
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Conv.</TableHead>
<TableHead className="text-right text-foreground w-[25%] min-w-[80px]">Conv. Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.sourceData?.map((source, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-foreground break-words max-w-[160px]">
{source.source}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{source.sessions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{source.conversions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{((source.conversions / source.sessions) * 100).toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
<TabsContent
value="devices"
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<div className="h-60 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={deviceData}
dataKey="pageViews"
nameKey="device"
cx="50%"
cy="50%"
outerRadius={80}
labelLine={false}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(1)}%`
}
>
{deviceData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[entry.device.toLowerCase()]}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};
export default UserBehaviorDashboard;
@@ -0,0 +1,26 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "@/components/dashboard/theme/ThemeProvider"
import { Button } from "@/components/dashboard/ui/button"
export function ModeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="outline"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="w-9 h-9 rounded-md border-none bg-transparent hover:bg-transparent"
>
<div className="relative w-5 h-5">
<Sun
className="absolute inset-0 h-full w-full transition-all duration-300 text-yellow-500 dark:rotate-0 dark:scale-0 dark:opacity-0 rotate-0 scale-100 opacity-100"
/>
<Moon
className="absolute inset-0 h-full w-full transition-all duration-300 text-slate-900 dark:text-slate-200 rotate-90 scale-0 opacity-0 dark:rotate-0 dark:scale-100 dark:opacity-100"
/>
</div>
<span className="sr-only">Toggle theme</span>
</Button>
)
}
@@ -0,0 +1,44 @@
import { createContext, useContext, useEffect, useState } from "react"
import { useTheme as useNextTheme } from "next-themes"
const ThemeProviderContext = createContext({
theme: "system",
setTheme: () => null,
toggleTheme: () => null,
})
// Wrapper to make dashboard components compatible with next-themes
export function ThemeProvider({ children }) {
const { theme: nextTheme, setTheme: setNextTheme, systemTheme: nextSystemTheme } = useNextTheme()
const toggleTheme = () => {
if (nextTheme === 'system') {
const newTheme = nextSystemTheme === 'dark' ? 'light' : 'dark'
setNextTheme(newTheme)
} else {
const newTheme = nextTheme === 'light' ? 'dark' : 'light'
setNextTheme(newTheme)
}
}
const value = {
theme: nextTheme || 'system',
systemTheme: nextSystemTheme || 'light',
setTheme: setNextTheme,
toggleTheme,
}
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} />
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} />
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }
@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />)
);
})
Button.displayName = "Button"
export { Button, buttonVariants }
@@ -0,0 +1,67 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/dashboard/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}) {
return (
(<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props} />)
);
}
Calendar.displayName = "Calendar"
export { Calendar }
@@ -0,0 +1,66 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/dashboard/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-2", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-2 sm:space-x-2 sm:space-y-0",
month: "w-full",
caption: "flex justify-center relative items-center",
caption_label: "text-lg font-medium", // Reduced from text-4xl
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "ghost", size: "sm"}), // Changed from lg to sm
"h-6 w-6" // Reduced from h-12 w-18
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-6 font-normal text-[0.7rem] w-full", // Reduced sizes
row: "flex w-full mt-1", // Reduced margin
cell: cn(
"w-full relative p-0 text-center text-xs focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-6 w-6 p-0 font-normal text-xs aria-selected:opacity-100" // Reduced from h-12 w-12 and text-lg
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground/50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar"
export { Calendar }
@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,308 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = {
light: "",
dark: ".dark"
}
const ChartContext = React.createContext(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
(<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>)
);
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({
id,
config
}) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
if (!colorConfig.length) {
return null
}
return (
(<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`)
.join("\n"),
}} />)
);
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef((
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
(<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>)
);
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
(<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
(<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor
}
} />
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>)
);
})}
</div>
</div>)
);
})
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef((
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
(<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
(<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}} />
)}
{itemConfig?.label}
</div>)
);
})}
</div>)
);
})
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config,
payload,
key
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey = key
if (
key in payload &&
typeof payload[key] === "string"
) {
configLabelKey = payload[key]
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key] === "string"
) {
configLabelKey = payloadPayload[key]
}
return configLabelKey in config
? config[configLabelKey]
: config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

Some files were not shown because too many files have changed in this diff Show More