14 Commits

134 changed files with 23101 additions and 1346 deletions

8
.gitignore vendored
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

23
docs/setup-chat.md Normal file
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.

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

View File

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

View File

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

View File

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

1447
inventory-server/chat/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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

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;

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

View File

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

View File

@@ -116,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),
@@ -176,6 +177,29 @@ CREATE TABLE public.product_metrics (
-- 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
);
@@ -242,7 +266,8 @@ CREATE TABLE public.category_metrics (
-- 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
);
@@ -280,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(14, 4) -- (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);
@@ -309,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);

View File

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

View File

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

View File

@@ -357,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.`);
@@ -366,6 +366,7 @@ async function executeSqlStep(config, progress) {
console.log(`\n--- Starting Step: ${config.name} ---`);
const stepStartTime = Date.now();
let connection = 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
@@ -414,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
@@ -423,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...');
@@ -492,7 +516,8 @@ async function executeSqlStep(config, progress) {
return {
success: true,
message: `${config.name} completed successfully`,
duration: stepDuration
duration: stepDuration,
rowsAffected: rowsAffected
};
} catch (error) {
@@ -664,6 +689,17 @@ async function runAllCalculations() {
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);
@@ -692,28 +728,49 @@ async function runAllCalculations() {
// 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
const result = await executeSqlStep(step, progressUtils);
if (result.success) {
completedSteps.push({
name: step.name,
duration: result.duration,
status: 'completed'
});
}
} 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;
}
}
@@ -726,18 +783,32 @@ async function runAllCalculations() {
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('completed_steps', $3::jsonb)
WHERE id = $4::integer;
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',
JSON.stringify(completedSteps),
isCancelled ? 'cancelled' : 'completed',
processedCounts.rows[0].processed_products,
JSON.stringify(completedSteps),
JSON.stringify(stepTimings),
JSON.stringify(stepRowCounts),
combinedHistoryId
]);
@@ -753,6 +824,26 @@ async function runAllCalculations() {
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
}

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

View File

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

View File

@@ -221,8 +221,8 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
message: `Loading order items: ${processedCount} of ${totalOrderItems}`,
current: processedCount,
total: totalOrderItems,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
remaining: estimateRemaining(startTime, processedCount, totalOrderItems),
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalOrderItems),
rate: calculateRate(startTime, processedCount)
});
} catch (error) {
@@ -530,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)
});
}
@@ -681,6 +681,15 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
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
@@ -704,7 +713,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
message: `Importing orders: ${cumulativeProcessedOrders} of ${totalUniqueOrders}`,
current: cumulativeProcessedOrders,
total: totalUniqueOrders,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, cumulativeProcessedOrders, totalUniqueOrders),
rate: calculateRate(startTime, cumulativeProcessedOrders)
});
@@ -751,8 +760,15 @@ 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);

View File

@@ -576,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)
});
}
@@ -587,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) {
@@ -612,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(`
@@ -847,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)
});
}
@@ -872,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

View File

@@ -398,7 +398,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
message: `Processed ${offset} of ${totalPOs} purchase orders (${totalProcessed} line items)`,
current: offset,
total: totalPOs,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, offset, totalPOs),
rate: calculateRate(startTime, offset)
});
@@ -605,7 +605,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
message: `Processed ${offset} of ${totalReceivings} receivings (${totalProcessed} line items total)`,
current: offset,
total: totalReceivings,
elapsed: formatElapsedTime((Date.now() - startTime) / 1000),
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, offset, totalReceivings),
rate: calculateRate(startTime, offset)
});
@@ -730,6 +730,13 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
date_created = EXCLUDED.date_created,
date_ordered = EXCLUDED.date_ordered,
updated = CURRENT_TIMESTAMP
WHERE -- Only update if at least one key field has changed
purchase_orders.ordered IS DISTINCT FROM EXCLUDED.ordered OR
purchase_orders.po_cost_price IS DISTINCT FROM EXCLUDED.po_cost_price OR
purchase_orders.status IS DISTINCT FROM EXCLUDED.status OR
purchase_orders.expected_date IS DISTINCT FROM EXCLUDED.expected_date OR
purchase_orders.date IS DISTINCT FROM EXCLUDED.date OR
purchase_orders.vendor IS DISTINCT FROM EXCLUDED.vendor
RETURNING (xmax = 0) as inserted
`);
@@ -806,6 +813,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
supplier_id = EXCLUDED.supplier_id,
status = EXCLUDED.status,
updated = CURRENT_TIMESTAMP
WHERE -- Only update if at least one key field has changed
receivings.qty_each IS DISTINCT FROM EXCLUDED.qty_each OR
receivings.cost_each IS DISTINCT FROM EXCLUDED.cost_each OR
receivings.status IS DISTINCT FROM EXCLUDED.status OR
receivings.received_date IS DISTINCT FROM EXCLUDED.received_date OR
receivings.received_by IS DISTINCT FROM EXCLUDED.received_by
RETURNING (xmax = 0) as inserted
`);

View File

@@ -42,6 +42,20 @@ BEGIN
JOIN public.products p ON pm.pid = p.pid
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 (
-- Ensure all brands from products table are included, mapping NULL/empty to 'Unbranded'
SELECT DISTINCT COALESCE(brand, 'Unbranded') as brand_group
@@ -53,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,
@@ -78,9 +93,13 @@ BEGIN
-- 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
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,
@@ -95,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)
@@ -103,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;

View File

@@ -1,5 +1,5 @@
-- 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 $$
@@ -9,55 +9,21 @@ DECLARE
_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
-- Identify the hierarchy depth for each category
CategoryDepth AS (
WITH RECURSIVE CategoryTree AS (
-- Base case: Start with categories without parents (root categories)
SELECT cat_id, name, parent_id, 0 AS depth
FROM public.categories
WHERE parent_id IS NULL
UNION ALL
-- Recursive step: Add child categories with incremented depth
SELECT c.cat_id, c.name, c.parent_id, ct.depth + 1
FROM public.categories c
JOIN CategoryTree ct ON c.parent_id = ct.cat_id
)
SELECT cat_id, depth
FROM CategoryTree
),
-- For each product, find the most specific (deepest) category it belongs to
ProductDeepestCategory AS (
SELECT
pc.pid,
pc.cat_id
FROM public.product_categories pc
JOIN CategoryDepth cd ON pc.cat_id = cd.cat_id
-- This is the key part: for each product, select only the category with maximum depth
WHERE (pc.pid, cd.depth) IN (
SELECT pc2.pid, MAX(cd2.depth)
FROM public.product_categories pc2
JOIN CategoryDepth cd2 ON pc2.cat_id = cd2.cat_id
GROUP BY pc2.pid
)
),
-- Calculate metrics only at the most specific category level for each product
-- These are the direct metrics (only products directly in this category)
DirectCategoryMetrics AS (
-- First calculate direct metrics (products directly in each category)
WITH DirectCategoryMetrics AS (
SELECT
pdc.cat_id,
-- Counts
pc.cat_id,
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 - Only include products with actual sales in each period
-- 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,
@@ -67,178 +33,141 @@ BEGIN
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,
-- Data for KPIs - Only average stock for products with stock
SUM(CASE WHEN pm.avg_stock_units_30d > 0 THEN pm.avg_stock_units_30d ELSE 0 END) AS total_avg_stock_units_30d
FROM public.product_metrics pm
JOIN ProductDeepestCategory pdc ON pm.pid = pdc.pid
GROUP BY pdc.cat_id
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
),
-- Build a category lookup table for parent relationships
CategoryHierarchyPaths AS (
WITH RECURSIVE ParentPaths AS (
-- Base case: All categories with their immediate parents
SELECT
cat_id,
cat_id as leaf_id, -- Every category is its own leaf initially
ARRAY[cat_id] as path
FROM public.categories
UNION ALL
-- Recursive step: Walk up the parent chain
SELECT
c.parent_id as cat_id,
pp.leaf_id, -- Keep the original leaf_id
c.parent_id || pp.path as path
FROM ParentPaths pp
JOIN public.categories c ON pp.cat_id = c.cat_id
WHERE c.parent_id IS NOT NULL -- Stop at root categories
)
-- Select distinct paths to avoid duplication
SELECT DISTINCT cat_id, leaf_id
FROM ParentPaths
),
-- Aggregate metrics from leaf categories to their ancestors without duplication
-- These are the rolled-up metrics (including all child categories)
RollupMetrics AS (
-- Calculate rolled-up metrics (including all descendant categories)
RolledUpMetrics AS (
SELECT
chp.cat_id,
-- For each parent category, count distinct products to avoid duplication
COUNT(DISTINCT dcm.cat_id) AS child_categories_count,
SUM(dcm.product_count) AS rollup_product_count,
SUM(dcm.active_product_count) AS rollup_active_product_count,
SUM(dcm.replenishable_product_count) AS rollup_replenishable_product_count,
SUM(dcm.current_stock_units) AS rollup_current_stock_units,
SUM(dcm.current_stock_cost) AS rollup_current_stock_cost,
SUM(dcm.current_stock_retail) AS rollup_current_stock_retail,
SUM(dcm.sales_7d) AS rollup_sales_7d,
SUM(dcm.revenue_7d) AS rollup_revenue_7d,
SUM(dcm.sales_30d) AS rollup_sales_30d,
SUM(dcm.revenue_30d) AS rollup_revenue_30d,
SUM(dcm.cogs_30d) AS rollup_cogs_30d,
SUM(dcm.profit_30d) AS rollup_profit_30d,
SUM(dcm.sales_365d) AS rollup_sales_365d,
SUM(dcm.revenue_365d) AS rollup_revenue_365d,
SUM(dcm.lifetime_sales) AS rollup_lifetime_sales,
SUM(dcm.lifetime_revenue) AS rollup_lifetime_revenue,
SUM(dcm.total_avg_stock_units_30d) AS rollup_total_avg_stock_units_30d
FROM CategoryHierarchyPaths chp
JOIN DirectCategoryMetrics dcm ON chp.leaf_id = dcm.cat_id
GROUP BY chp.cat_id
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
),
-- Combine direct and rollup metrics
CombinedMetrics AS (
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,
-- Direct metrics (just this category)
COALESCE(dcm.product_count, 0) AS direct_product_count,
COALESCE(dcm.active_product_count, 0) AS direct_active_product_count,
COALESCE(dcm.replenishable_product_count, 0) AS direct_replenishable_product_count,
COALESCE(dcm.current_stock_units, 0) AS direct_current_stock_units,
COALESCE(dcm.current_stock_cost, 0) AS direct_current_stock_cost,
COALESCE(dcm.current_stock_retail, 0) AS direct_current_stock_retail,
COALESCE(dcm.sales_7d, 0) AS direct_sales_7d,
COALESCE(dcm.revenue_7d, 0) AS direct_revenue_7d,
COALESCE(dcm.sales_30d, 0) AS direct_sales_30d,
COALESCE(dcm.revenue_30d, 0) AS direct_revenue_30d,
COALESCE(dcm.cogs_30d, 0) AS direct_cogs_30d,
COALESCE(dcm.profit_30d, 0) AS direct_profit_30d,
COALESCE(dcm.sales_365d, 0) AS direct_sales_365d,
COALESCE(dcm.revenue_365d, 0) AS direct_revenue_365d,
COALESCE(dcm.lifetime_sales, 0) AS direct_lifetime_sales,
COALESCE(dcm.lifetime_revenue, 0) AS direct_lifetime_revenue,
COALESCE(dcm.total_avg_stock_units_30d, 0) AS direct_avg_stock_units_30d,
-- Rolled up metrics (this category + all children)
COALESCE(rm.rollup_product_count, 0) AS product_count,
COALESCE(rm.rollup_active_product_count, 0) AS active_product_count,
COALESCE(rm.rollup_replenishable_product_count, 0) AS replenishable_product_count,
COALESCE(rm.rollup_current_stock_units, 0) AS current_stock_units,
COALESCE(rm.rollup_current_stock_cost, 0) AS current_stock_cost,
COALESCE(rm.rollup_current_stock_retail, 0) AS current_stock_retail,
COALESCE(rm.rollup_sales_7d, 0) AS sales_7d,
COALESCE(rm.rollup_revenue_7d, 0) AS revenue_7d,
COALESCE(rm.rollup_sales_30d, 0) AS sales_30d,
COALESCE(rm.rollup_revenue_30d, 0) AS revenue_30d,
COALESCE(rm.rollup_cogs_30d, 0) AS cogs_30d,
COALESCE(rm.rollup_profit_30d, 0) AS profit_30d,
COALESCE(rm.rollup_sales_365d, 0) AS sales_365d,
COALESCE(rm.rollup_revenue_365d, 0) AS revenue_365d,
COALESCE(rm.rollup_lifetime_sales, 0) AS lifetime_sales,
COALESCE(rm.rollup_lifetime_revenue, 0) AS lifetime_revenue,
COALESCE(rm.rollup_total_avg_stock_units_30d, 0) AS total_avg_stock_units_30d
c.parent_id
FROM public.categories c
LEFT JOIN DirectCategoryMetrics dcm ON c.cat_id = dcm.cat_id
LEFT JOIN RollupMetrics rm ON c.cat_id = rm.cat_id
WHERE c.status = 'active'
)
INSERT INTO public.category_metrics (
category_id, category_name, category_type, parent_id, last_calculated,
-- Store all direct and rolled up metrics
-- 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,
-- Also store direct metrics with direct_ prefix
-- 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_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, stock_turn_30d
avg_margin_30d,
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
)
SELECT
cm.cat_id,
cm.name,
cm.type,
cm.parent_id,
ac.cat_id,
ac.name,
ac.type,
ac.parent_id,
_start_time,
-- Rolled-up metrics (total including children)
cm.product_count,
cm.active_product_count,
cm.replenishable_product_count,
cm.current_stock_units,
cm.current_stock_cost,
cm.current_stock_retail,
cm.sales_7d, cm.revenue_7d,
cm.sales_30d, cm.revenue_30d, cm.profit_30d, cm.cogs_30d,
cm.sales_365d, cm.revenue_365d,
cm.lifetime_sales, cm.lifetime_revenue,
-- Direct metrics (just this category)
cm.direct_product_count,
cm.direct_active_product_count,
cm.direct_replenishable_product_count,
cm.direct_current_stock_units,
cm.direct_current_stock_cost,
cm.direct_current_stock_retail,
cm.direct_sales_7d, cm.direct_revenue_7d,
cm.direct_sales_30d, cm.direct_revenue_30d, cm.direct_profit_30d, cm.direct_cogs_30d,
cm.direct_sales_365d, cm.direct_revenue_365d,
cm.direct_lifetime_sales, cm.direct_lifetime_revenue,
-- 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 cm.revenue_30d >= _min_revenue THEN
((cm.revenue_30d - cm.cogs_30d) / cm.revenue_30d) * 100.0
ELSE NULL -- No margin for low/no revenue categories
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,
-- Stock Turn calculation
CASE
WHEN cm.total_avg_stock_units_30d > 0 THEN
cm.sales_30d / cm.total_avg_stock_units_30d
ELSE NULL -- No stock turn if no average stock
END
FROM CombinedMetrics cm
-- 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,
-- Update rolled-up metrics
-- Rolled-up metrics
product_count = EXCLUDED.product_count,
active_product_count = EXCLUDED.active_product_count,
replenishable_product_count = EXCLUDED.replenishable_product_count,
@@ -250,7 +179,7 @@ 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,
-- Update direct metrics
-- 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,
@@ -262,9 +191,18 @@ BEGIN
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,
-- Update KPIs
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)
@@ -272,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;

View File

@@ -44,6 +44,21 @@ BEGIN
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
GROUP BY p.vendor
),
PreviousPeriodVendorMetrics AS (
-- Get previous period metrics for growth calculation
SELECT
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
@@ -78,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,
@@ -102,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,
@@ -124,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)
@@ -132,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;

View File

@@ -86,7 +86,14 @@ BEGIN
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
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)
@@ -201,4 +208,15 @@ BEGIN
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

View File

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

View File

@@ -171,6 +171,85 @@ 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 (
@@ -187,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,7 +282,13 @@ BEGIN
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,
status -- Add status field for calculated status
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,
@@ -227,27 +312,33 @@ BEGIN
sa.received_qty_30d, sa.received_cost_30d,
-- 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,
COALESCE(
-- Option 1: Use 30-day average price if available
CASE WHEN sa.sales_30d > 0 THEN
ci.historical_total_sold * (sa.revenue_30d / NULLIF(sa.sales_30d, 0))
ELSE NULL END,
-- Option 2: Try 365-day average price if available
CASE WHEN sa.sales_365d > 0 THEN
ci.historical_total_sold * (sa.revenue_365d / NULLIF(sa.sales_365d, 0))
ELSE NULL END,
-- Option 3: Use current price as a reasonable estimate
ci.historical_total_sold * ci.current_price,
-- Option 4: Use regular price if current price might be zero
ci.historical_total_sold * ci.current_regular_price,
-- Final fallback: Use accumulated revenue (this is less accurate for old products)
sa.total_net_revenue
) AS lifetime_revenue,
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,
@@ -262,317 +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
-- CRITICAL FIX: Use safer velocity calculation to prevent extreme values
-- Original problematic calculation: (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0))
-- Use available days (not stockout days) as denominator with a minimum safety value
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d, -- Standard calculation
CASE
WHEN sa.sales_30d > 0 THEN 14.0 -- If we have sales, ensure at least 14 days denominator
ELSE 30.0 -- If no sales, use full period
END
),
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,
-- Apply the same fix to all derived calculations
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time AS lead_time_forecast_units,
calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time AS lead_time_forecast_units,
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * 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_days_of_stock AS days_of_stock_forecast_units,
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_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) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * 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)) AS lead_time_closing_stock,
((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time))) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock) AS days_of_stock_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,
(((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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) + (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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0) AS stock_cover_in_days,
COALESCE(ooi.on_order_qty, 0) / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0) AS po_cover_in_days,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) > 0
THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / (sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)))::int AS overstocked_units,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
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
@@ -592,66 +425,18 @@ BEGIN
ELSE
CASE
-- Check for overstock first
WHEN GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock))) > 0 THEN 'Overstock'
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((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) <= 0 THEN 'Critical'
(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((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) 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((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) + 7) THEN
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((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) 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'
ELSE 'Reorder Soon'
END
@@ -672,15 +457,7 @@ BEGIN
END) > 180 THEN 'At Risk'
-- Very high stock cover is at risk too
WHEN (ci.current_stock / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) > 365 THEN 'At Risk'
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
@@ -693,7 +470,30 @@ BEGIN
-- If none of the above, assume Healthy
ELSE 'Healthy'
END
END AS status
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
@@ -701,6 +501,11 @@ 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
@@ -718,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,
@@ -734,7 +539,39 @@ BEGIN
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,
status = EXCLUDED.status
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
@@ -744,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;

View File

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

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

View File

@@ -203,12 +203,8 @@ router.get('/vendors', async (req, res) => {
0
) as stock_turnover,
product_count,
-- Use an estimate of growth based on 7-day vs 30-day revenue
CASE
WHEN revenue_30d > 0
THEN ((revenue_7d * 4.0) / revenue_30d - 1) * 100
ELSE 0
END as growth
-- 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

View File

@@ -26,6 +26,9 @@ const COLUMN_MAP = {
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

View File

@@ -31,6 +31,9 @@ const COLUMN_MAP = {
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' },
};

View File

@@ -193,6 +193,33 @@ router.get('/:type/progress', (req, 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;
@@ -259,7 +286,21 @@ router.post('/full-reset', async (req, res) => {
router.get('/history/import', async (req, res) => {
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(`
// 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,
@@ -267,11 +308,19 @@ router.get('/history/import', async (req, res) => {
status,
error_message,
records_added::integer,
records_updated::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);
@@ -288,7 +337,8 @@ router.get('/history/calculate', async (req, res) => {
id,
start_time,
end_time,
duration_minutes,
EXTRACT(EPOCH FROM (COALESCE(end_time, NOW()) - start_time)) / 60 as duration_minutes,
duration_seconds,
status,
error_message,
total_products,

View File

@@ -143,7 +143,33 @@ const COLUMN_MAP = {
// Yesterday
yesterdaySales: 'pm.yesterday_sales',
// Map status column - directly mapped now instead of calculated on frontend
status: 'pm.status'
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'
};
// Define column types for use in sorting/filtering
@@ -173,7 +199,15 @@ const COLUMN_TYPES = {
'overstockedCost', 'overstockedRetail', 'yesterdaySales',
// New numeric columns
'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height',
'baskets', 'notifies', 'preorderCount', 'notionsInvCount'
'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: [
@@ -185,7 +219,9 @@ const COLUMN_TYPES = {
'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status',
// New string columns
'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference',
'line', 'subline', 'artist', 'countryOfOrigin', 'location'
'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']
@@ -208,6 +244,12 @@ const SPECIAL_SORT_COLUMNS = {
// Velocity columns
salesVelocityDaily: true,
// Growth rate columns
salesGrowth30dVsPrev: 'abs',
revenueGrowth30dVsPrev: 'abs',
salesGrowthYoy: 'abs',
revenueGrowthYoy: 'abs',
// Status column needs special ordering
status: 'priority'
};

View File

@@ -30,6 +30,9 @@ const COLUMN_MAP = {
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' },

View File

@@ -52,9 +52,11 @@
"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",
@@ -75,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",
@@ -5732,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",
@@ -6104,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",
@@ -8316,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
}
}
}
}
}

View File

@@ -54,9 +54,11 @@
"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",
@@ -77,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",

View File

@@ -1,24 +1,41 @@
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 { Brands } from '@/pages/Brands';
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();
function App() {
@@ -72,67 +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">
<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>

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,94 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />)
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props} />
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
(<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}>
{char}
{hasFakeCaret && (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>)
);
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,119 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,108 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
(<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />)
);
}
export { Skeleton }

View File

@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,86 @@
"use client";
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props} />
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
return (
(<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props} />)
);
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props} />
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction };

View File

@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/dashboard/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
(<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
(<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>)
);
})}
<ToastViewport />
</ToastProvider>)
);
}

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/dashboard/ui/toggle"
const ToggleGroupContext = React.createContext({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
(<ToggleGroupPrimitive.Item
ref={ref}
className={cn(toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}), className)}
{...props}>
{children}
</ToggleGroupPrimitive.Item>)
);
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props} />
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -6,9 +6,11 @@ import {
ClipboardList,
LogOut,
Tags,
Plus,
PackagePlus,
ShoppingBag,
Truck,
MessageCircle,
LayoutDashboard,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -16,6 +18,7 @@ import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarFooter,
SidebarMenu,
@@ -27,12 +30,21 @@ import {
import { useLocation, useNavigate, Link } from "react-router-dom";
import { Protected } from "@/components/auth/Protected";
const items = [
const dashboardItems = [
{
title: "Dashboard",
icon: LayoutDashboard,
url: "/dashboard",
permission: "access:dashboard"
}
];
const inventoryItems = [
{
title: "Overview",
icon: Home,
url: "/",
permission: "access:dashboard"
permission: "access:overview"
},
{
title: "Products",
@@ -75,15 +87,27 @@ const items = [
icon: IconCrystalBall,
url: "/forecasting",
permission: "access:forecasting"
},
}
];
const productSetupItems = [
{
title: "Create Products",
icon: Plus,
icon: PackagePlus,
url: "/import",
permission: "access:import"
}
];
const chatItems = [
{
title: "Chat Archive",
icon: MessageCircle,
url: "/chat",
permission: "access:chat"
}
];
export function AppSidebar() {
const location = useLocation();
const navigate = useNavigate();
@@ -95,6 +119,46 @@ export function AppSidebar() {
navigate('/login');
};
const renderMenuItems = (items: typeof inventoryItems) => {
return items.map((item) => {
const isActive =
location.pathname === item.url ||
(item.url !== "/" && item.url !== "#" && location.pathname.startsWith(item.url));
return (
<Protected
key={item.title}
permission={item.permission}
fallback={null}
>
<SidebarMenuItem>
<SidebarMenuButton
asChild={item.url !== "#"}
tooltip={item.title}
isActive={isActive}
disabled={item.url === "#"}
>
{item.url === "#" ? (
<div>
<item.icon className="h-4 w-4" />
<span className="group-data-[collapsible=icon]:hidden">
{item.title}
</span>
</div>
) : (
<Link to={item.url}>
<item.icon className="h-4 w-4" />
<span className="group-data-[collapsible=icon]:hidden">
{item.title}
</span>
</Link>
)}
</SidebarMenuButton>
</SidebarMenuItem>
</Protected>
);
});
};
return (
<Sidebar collapsible="icon" variant="sidebar">
<SidebarHeader>
@@ -115,42 +179,53 @@ export function AppSidebar() {
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
{/* Dashboard Section */}
<SidebarGroup>
<SidebarGroupLabel>Dashboard</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const isActive =
location.pathname === item.url ||
(item.url !== "/" && location.pathname.startsWith(item.url));
return (
<Protected
key={item.title}
permission={item.permission}
fallback={null}
>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={isActive}
>
<Link to={item.url}>
<item.icon className="h-4 w-4" />
<span className="group-data-[collapsible=icon]:hidden">
{item.title}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</Protected>
);
})}
{renderMenuItems(dashboardItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Inventory Section */}
<SidebarGroup>
<SidebarGroupLabel>Inventory</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(inventoryItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Product Setup Section */}
<SidebarGroup>
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(productSetupItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Chat Section */}
<SidebarGroup>
<SidebarGroupLabel>Chat</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(chatItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* Settings Section */}
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<Protected

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