Compare commits
15 Commits
a6af90ec48
...
v1.9.24
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ab38d237 | |||
|
|
f751cc20a7 | ||
|
|
1893c4f38b | ||
|
|
6102ff39aa | ||
|
|
10d75619bf | ||
|
|
546eca6b9a | ||
|
|
66844a56e9 | ||
|
|
e8c804da71 | ||
|
|
45db169dda | ||
|
|
f47e7d005a | ||
|
|
adb6cde87a | ||
|
|
460dc4fe12 | ||
|
|
4df2d844ca | ||
|
|
409afb29f8 | ||
|
|
bacc23cc23 |
6
.agents/rules/modern-css-and-responsive-design-expert.md
Normal file
6
.agents/rules/modern-css-and-responsive-design-expert.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
trigger: always_on
|
||||
glob:
|
||||
description:
|
||||
---
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
"Bash(colima start:*)",
|
||||
"Bash(colima status:*)",
|
||||
"Bash(colima stop:*)",
|
||||
"Bash(bash *)"
|
||||
"Bash(bash *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(ls -lh aInventory-PROD-v*.zip)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,18 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
|
||||
- **Database:** SQLite (SQLAlchemy) - Local file-based persistence
|
||||
- **Validation:** Pydantic v2
|
||||
- **Auth:** Hybrid LDAP (python-ldap) + PBKDF2 local password hash caching
|
||||
- **AI Engine:** Google GenAI SDK (Gemini 2.0 Flash) - Location: `backend/ai/`
|
||||
- **AI Engine:** Google GenAI SDK (Gemini 2.0 Flash) & Anthropic SDK (Claude 3.5 Sonnet) - Location: `backend/ai/`
|
||||
|
||||
### 2.2 Frontend (Web & PWA)
|
||||
- **Architecture:** Next.js 15+ (App Router)
|
||||
- **Styling:** Tailwind CSS (Readability-first config)
|
||||
- **Styling:** Tailwind CSS (Readability-first config, mobile-first responsive)
|
||||
- **Icons:** Lucide Icons (React components)
|
||||
- **Components:**
|
||||
- **StatCard** (v1.9.21+): Responsive stat display component for mobile/desktop
|
||||
- Two-column flexbox layout (label left, number right)
|
||||
- Responsive font sizing with Tailwind breakpoints (text-sm→md, text-lg→xl)
|
||||
- Label truncation with ellipsis for overflow handling
|
||||
- Accessibility: `role="status"`, `aria-hidden` on decorative icons
|
||||
- **Offline persistence:** Dexie.js (IndexedDB wrapper)
|
||||
- **Scanner:** `html5-qrcode` (Client-side, offline-only)
|
||||
- **Sync:** Axios with bulk-sync idempotency (UUID-based)
|
||||
@@ -26,7 +32,7 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
|
||||
- **PWA Deployment:** `next-pwa` (Service Workers + Manifest.json)
|
||||
- **HTTPS Proxy:** `caddy` or `local-ssl-proxy` (Port 8909)
|
||||
- **Servers:** Frontend (Port 8907), Backend (Port 8906)
|
||||
- **Configuration:** Centrally managed via root `inventory.env` (Network/CORS/API Keys) and `config/` directory (LDAP, Caddyfile).
|
||||
- **Configuration:** Centrally managed via root `inventory.env` (Network/CORS/API Keys), `config/` directory (LDAP, Caddyfile), and a dynamic `ConfigManager` (`backend/config_manager.py`) for runtime environment and AI provider settings.
|
||||
|
||||
## 3. Data Models & Entities
|
||||
- **Item:** Name, Category Group (Structured), Item Type (Specific), Quantity, Barcode, Part Number, Box Label (Association).
|
||||
@@ -37,7 +43,8 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
|
||||
## 4. Scanning & Optimization Strategy (Crucial)
|
||||
### 4.1 AI Usage Policy
|
||||
- **Routine Operations (Check-in/Out):** Executes entirely on the local device unconditionally using `html5-qrcode` ($0 cost). No AI is allowed here.
|
||||
- **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash`). The user takes a photo, AI extracts data based on strict templates.
|
||||
- **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash` or `claude-3-5-sonnet`). The user takes a photo, AI extracts data based on strict templates.
|
||||
- **AI Provider Selection (v1.9.23):** Administrators can choose between Gemini and Claude via the Admin Dashboard. API keys are managed securely in the environment.
|
||||
- **AI Box Discovery Mode (v1.6.0):** Supports specialized `mode="box"` prompt that focuses exclusively on prominent container names/hand-written labels, ignoring technical spec noise.
|
||||
- **Validation Mask:** AI-extracted data is NEVER saved directly. It is presented in a validation UI for human confirmation.
|
||||
|
||||
@@ -97,3 +104,9 @@ To ensure enterprise-grade protection, the following policies are enforced:
|
||||
To ensure deployment stability on macOS environments with potentially broken developer tool links (`xcode-select` errors):
|
||||
- **Direct Binary Mapping:** The system bypasses path resolution by using a hardcoded direct link to the Git binary in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`).
|
||||
- **Persistence Mandate:** This path is protected by mandatory AI rules and must never be removed or modified to ensure `save-version` and automated deployment scripts remain functional.
|
||||
106:
|
||||
107: ## 8. Multi-AI Engine & Dynamic Configuration (v1.9.23)
|
||||
108: To enhance extraction flexibility and system resilience:
|
||||
109: - **Unified AI Core:** The backend uses an abstraction layer to handle multiple AI providers (Gemini and Claude).
|
||||
110: - **Dynamic Configuration:** System settings (AI Provider, API Keys, Backup Policies) are managed via `backend/config_manager.py`, allowing real-time updates without restarting the container.
|
||||
111: - **Admin Standardization:** The Admin Dashboard features a standardized configuration UI with secure field masking for sensitive credentials.
|
||||
|
||||
18
README.md
18
README.md
@@ -39,9 +39,25 @@ To generate a clean production package and snapshot the current state:
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Recent UI/UX Improvements (v1.9.21+)
|
||||
|
||||
### Mobile-First Responsive Design
|
||||
- **StatCard Component:** Reusable, responsive stat display component for mobile phones
|
||||
- Two-column flexbox layout (label left, number right) prevents text overflow on narrow screens
|
||||
- Responsive font sizing: `text-sm md:text-base` (labels), `text-lg md:text-xl` (numbers)
|
||||
- Automatic label truncation with ellipsis for long text
|
||||
- Accessible markup with `role="status"` and `aria-hidden` attributes
|
||||
- **Pages Updated:** Inventory (Categories, Item Types, Total Boxes), Logs (Total Events, Check in/out), Admin (Local Archives)
|
||||
- **Tested on:** iPhone SE (375px), iPhone 12 (390px), iPhone 14 Pro Max (430px), tablets (768px), desktop (1024px)
|
||||
- **Multi-AI Selection & Secure Config (v1.9.23):**
|
||||
- Support for both **Google Gemini** and **Anthropic Claude** for AI label extraction.
|
||||
- Dynamically configurable AI providers via the Admin Dashboard.
|
||||
- Centralized `ConfigManager` for secure API key and environment management.
|
||||
- **Admin UI Standardization:** Refactored Admin page with unified StatCards and secure input masking for system credentials.
|
||||
|
||||
## 🏗 Technical Overview
|
||||
* **Backend:** FastAPI (Python 3.12+)
|
||||
* **Frontend:** Next.js 15+ (React PWA)
|
||||
* **Frontend:** Next.js 15+ (React PWA) with responsive Tailwind CSS
|
||||
* **Database:** SQLite (SQLAlchemy) with Dexie.js (IndexedDB) for client-side sync.
|
||||
* **Proxy:** Caddy (Docker) or local-ssl-proxy (Standalone/Dev).
|
||||
* **AI Engine:** Google Gemini (Generative AI SDK).
|
||||
|
||||
@@ -138,6 +138,7 @@ Ensure you have internet connectivity and wait a moment. Synchronization happens
|
||||
- The image size must not exceed 10 MB.
|
||||
- The application supports JPEG, PNG, WebP, and GIF formats.
|
||||
- If the AI service is unavailable, try again later or contact your administrator.
|
||||
- **AI Provider Toggle:** In the Admin Dashboard, you can choose between **Gemini** and **Claude** for label extraction. If one provider is slow or failing, your administrator can switch to the other seamlessly.
|
||||
|
||||
---
|
||||
|
||||
@@ -149,5 +150,5 @@ For detailed technical documentation, see the [Project Architecture](../PROJECT_
|
||||
|
||||
---
|
||||
|
||||
**Version:** v1.9.19
|
||||
**Last Updated:** 2026-04-14
|
||||
**Version:** v1.9.24
|
||||
**Last Updated:** 2026-04-15
|
||||
|
||||
@@ -73,8 +73,18 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
|
||||
# 3. Final fallback to hardcoded default
|
||||
prompt = "Extract technical specs. Return JSON with name, category, description, connector, size, color, part_number, ocr_text, quantity."
|
||||
|
||||
# 1. Try Gemini
|
||||
result = gemini.extract(image_bytes, prompt)
|
||||
# 0. Get Active Provider from DB
|
||||
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||
active_provider = provider_setting.value if provider_setting else "gemini"
|
||||
|
||||
# 1. Execute extraction based on selection
|
||||
result = None
|
||||
if active_provider == "claude":
|
||||
print(f"📡 Using Anthropic Claude for extraction...")
|
||||
result = claude.extract(image_bytes, prompt)
|
||||
else:
|
||||
print(f"📡 Using Google Gemini for extraction...")
|
||||
result = gemini.extract(image_bytes, prompt)
|
||||
|
||||
if result:
|
||||
# Check if AI returned a list of items or a singular object
|
||||
|
||||
90
backend/config_manager.py
Normal file
90
backend/config_manager.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
class ConfigManager:
|
||||
"""Safely manages multi-line .env files without corrupting other content."""
|
||||
|
||||
@staticmethod
|
||||
def get_root_env_path():
|
||||
# backend/config_manager.py -> backend/ -> /
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(base_dir)
|
||||
return os.path.join(project_root, "inventory.env")
|
||||
|
||||
@staticmethod
|
||||
def update_keys(updates: dict):
|
||||
"""
|
||||
Updates specific keys in inventory.env.
|
||||
Preserves comments and order where possible.
|
||||
Appends new keys at the end if not found.
|
||||
"""
|
||||
env_path = ConfigManager.get_root_env_path()
|
||||
|
||||
if not os.path.exists(env_path):
|
||||
# Create a basic file if it doesn't exist (unlikely in this project)
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
f.write("# TFM aInventory — Generated Configuration\n")
|
||||
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
new_lines = []
|
||||
keys_to_process = set(updates.keys())
|
||||
processed_keys = set()
|
||||
|
||||
for line in lines:
|
||||
trimmed = line.strip()
|
||||
# Skip empty lines or comments when matching
|
||||
if not trimmed or trimmed.startswith('#'):
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
# Check if this line is a key assignment we want to update
|
||||
found_match = False
|
||||
for key in keys_to_process:
|
||||
if trimmed.startswith(f"{key}="):
|
||||
new_lines.append(f"{key}={updates[key]}\n")
|
||||
processed_keys.add(key)
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
new_lines.append(line)
|
||||
|
||||
# Append keys that weren't found in the file
|
||||
missing_keys = keys_to_process - processed_keys
|
||||
if missing_keys:
|
||||
if new_lines and not new_lines[-1].endswith('\n'):
|
||||
new_lines.append('\n')
|
||||
if new_lines and not new_lines[-1].strip() == '':
|
||||
new_lines.append('\n')
|
||||
|
||||
new_lines.append("# --- Automatically Added Keys ---\n")
|
||||
for key in missing_keys:
|
||||
new_lines.append(f"{key}={updates[key]}\n")
|
||||
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
# Force reload environment variables for the current process
|
||||
load_dotenv(env_path, override=True)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_masked_key(key_name: str):
|
||||
"""Returns a masked version of the environment variable."""
|
||||
val = os.environ.get(key_name)
|
||||
if not val:
|
||||
return None
|
||||
|
||||
# Determine prefix based on key type
|
||||
prefix = ""
|
||||
if key_name == "GEMINI_API_KEY":
|
||||
prefix = "G-"
|
||||
elif key_name == "CLAUDE_API_KEY":
|
||||
prefix = "sk-"
|
||||
|
||||
if len(val) <= 8:
|
||||
return f"{prefix}****"
|
||||
|
||||
return f"{prefix}****{val[-4:]}"
|
||||
@@ -145,7 +145,8 @@ def startup_event():
|
||||
defaults = {
|
||||
"backup_retention_count": "10",
|
||||
"backup_schedule_hour": "3",
|
||||
"backup_schedule_freq_days": "1"
|
||||
"backup_schedule_freq_days": "1",
|
||||
"ai_provider": "gemini"
|
||||
}
|
||||
for key, val in defaults.items():
|
||||
if not db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first():
|
||||
|
||||
@@ -189,3 +189,126 @@ def update_ai_prompt(
|
||||
|
||||
db.commit()
|
||||
return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}
|
||||
|
||||
from ..config_manager import ConfigManager
|
||||
|
||||
@router.get("/settings/ai")
|
||||
def get_ai_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Check AI provider status and active provider."""
|
||||
gemini_key = os.environ.get("GEMINI_API_KEY")
|
||||
claude_key = os.environ.get("CLAUDE_API_KEY")
|
||||
|
||||
# Get active provider from DB
|
||||
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||
active_provider = provider_setting.value if provider_setting else "gemini"
|
||||
|
||||
return {
|
||||
"active_provider": active_provider,
|
||||
"providers": [
|
||||
{
|
||||
"id": "gemini",
|
||||
"name": "Google Gemini 2.0",
|
||||
"configured": bool(gemini_key),
|
||||
"active": active_provider == "gemini",
|
||||
"masked_key": ConfigManager.get_masked_key("GEMINI_API_KEY")
|
||||
},
|
||||
{
|
||||
"id": "claude",
|
||||
"name": "Anthropic Claude 3.5",
|
||||
"configured": bool(claude_key),
|
||||
"active": active_provider == "claude",
|
||||
"masked_key": ConfigManager.get_masked_key("CLAUDE_API_KEY")
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@router.post("/settings/ai-keys")
|
||||
def update_ai_keys(
|
||||
payload: dict,
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update AI API keys in inventory.env."""
|
||||
gemini_key = payload.get("gemini_api_key")
|
||||
claude_key = payload.get("claude_api_key")
|
||||
|
||||
updates = {}
|
||||
if gemini_key:
|
||||
updates["GEMINI_API_KEY"] = gemini_key
|
||||
if claude_key:
|
||||
updates["CLAUDE_API_KEY"] = claude_key
|
||||
|
||||
if not updates:
|
||||
return {"status": "no_change"}
|
||||
|
||||
ConfigManager.update_keys(updates)
|
||||
return {
|
||||
"status": "success",
|
||||
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")),
|
||||
"claude_configured": bool(os.environ.get("CLAUDE_API_KEY"))
|
||||
}
|
||||
|
||||
@router.post("/settings/test-ai-key")
|
||||
def test_ai_key(
|
||||
payload: dict,
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Test AI API key connectivity."""
|
||||
provider = payload.get("provider")
|
||||
key = payload.get("key")
|
||||
|
||||
if not provider or provider not in ["gemini", "claude"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid provider")
|
||||
|
||||
# If key is masked or empty, use the one from environment
|
||||
if not key or "****" in key:
|
||||
key = os.environ.get("GEMINI_API_KEY" if provider == "gemini" else "CLAUDE_API_KEY")
|
||||
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="No API key provided or configured")
|
||||
|
||||
try:
|
||||
if provider == "gemini":
|
||||
from google import genai
|
||||
client = genai.Client(api_key=key, http_options={'api_version': 'v1beta'})
|
||||
# Listing models is the standard way to check key validity
|
||||
client.models.list()
|
||||
return {"status": "success", "message": "Google Gemini API connection verified!"}
|
||||
|
||||
elif provider == "claude":
|
||||
import anthropic
|
||||
client = anthropic.Anthropic(api_key=key)
|
||||
# Claude also supports model listing for key verification
|
||||
client.models.list(limit=1)
|
||||
return {"status": "success", "message": "Anthropic Claude API connection verified!"}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "API_KEY_INVALID" in error_msg or "invalid_api_key" in error_msg:
|
||||
error_msg = "The API key provided is invalid."
|
||||
elif "PERMISSION_DENIED" in error_msg:
|
||||
error_msg = "Permission denied. Check if the key has correct permissions."
|
||||
|
||||
raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {error_msg}")
|
||||
|
||||
@router.post("/settings/ai")
|
||||
def update_ai_provider(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update the active AI provider."""
|
||||
provider = payload.get("provider")
|
||||
if provider not in ["gemini", "claude"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid provider. Must be 'gemini' or 'claude'.")
|
||||
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||
if existing:
|
||||
existing.value = provider
|
||||
else:
|
||||
db.add(models.SystemSetting(key="ai_provider", value=provider))
|
||||
|
||||
db.commit()
|
||||
return {"status": "success", "active_provider": provider}
|
||||
|
||||
@@ -3,133 +3,224 @@
|
||||
**Active AI:** Claude (Haiku)
|
||||
**Last Updated:** 2026-04-15
|
||||
**Current Version:** v1.9.21 (Docker-Ready)
|
||||
**Branch:** dev (fixes committed, ready for merge to master)
|
||||
**Branch:** dev (Task 5 complete - Mobile viewport testing done)
|
||||
|
||||
---
|
||||
|
||||
## STATUS: 🟢 STABLE — DOCKER BUILD VERIFIED & FULLY TESTED LOCALLY
|
||||
## STATUS: 🟢 STABLE — MOBILE VIEWPORT TESTING COMPLETE
|
||||
|
||||
**CRITICAL PROGRESS:** TypeScript build errors are FIXED, COMMITTED, and TESTED. Full Docker Compose stack (proxy, backend, frontend) successfully builds and runs. **READY FOR REMOTE DEPLOYMENT.**
|
||||
**PROGRESS:** Task 5 complete - StatCard responsive layout verified on mobile viewports (320px-430px), tablet (768px), and desktop (1024px). All tests passed with no overflow or layout issues.
|
||||
|
||||
### The Fix (Verified ✓)
|
||||
Fixed a TypeScript build error in `frontend/components/AIOnboarding.tsx` and `frontend/components/Scanner.tsx` that was blocking Docker image creation. The issue: `img` tag `src` attribute cannot accept `null` in React 19 / Next.js 15.
|
||||
### Task 5: Mobile Viewport Testing [COMPLETED]
|
||||
|
||||
- **Commit:** `65b24079` - Fix TypeScript build error in AIOnboarding and Scanner components
|
||||
- **Commit:** `70670a3c` - Documentation and test plan
|
||||
- **Files Fixed:**
|
||||
- `frontend/components/AIOnboarding.tsx:352` - Changed `src={image}` → `src={image || undefined}`
|
||||
- `frontend/components/Scanner.tsx:237` - Changed `src={capturedImage}` → `src={capturedImage || undefined}`
|
||||
- **Status:** ✓ Committed to `dev` branch, ✓ **FULLY TESTED AND VERIFIED LOCALLY**, ✓ Ready for remote deployment
|
||||
**Test Date:** 2026-04-15
|
||||
**Tester:** Claude (Haiku)
|
||||
|
||||
#### Mobile Widths Tested
|
||||
|
||||
1. **320px (iPhone SE smallest)**
|
||||
- ✓ No overflow on stat cards
|
||||
- ✓ Labels truncated with ellipsis ("Categ...", "Item T...", "Total B...")
|
||||
- ✓ Numbers visible and properly aligned
|
||||
- ✓ Icons display correctly
|
||||
- ✓ Layout remains stable
|
||||
|
||||
2. **375px (iPhone SE)**
|
||||
- ✓ No overflow
|
||||
- ✓ Labels fully visible on Inventory page ("Categories", "Item Types", "Total Boxes")
|
||||
- ✓ Stat cards use 2-column layout
|
||||
- ✓ Icons and numbers properly spaced
|
||||
- ✓ Custom div components (Top Operator, Storage Status) render correctly
|
||||
|
||||
3. **390px (iPhone 12)**
|
||||
- ✓ No overflow
|
||||
- ✓ Labels fully visible across all three pages
|
||||
- ✓ Responsive font sizes applied (text-sm md:text-base)
|
||||
- ✓ Icons scaled appropriately
|
||||
- ✓ Consistent spacing and alignment
|
||||
|
||||
4. **430px (iPhone 14 Pro Max)**
|
||||
- ✓ No overflow
|
||||
- ✓ Extra spacious layout
|
||||
- ✓ All content easily readable
|
||||
- ✓ Responsive breakpoints working correctly
|
||||
|
||||
#### Tablet Width (768px)
|
||||
- ✓ 3-column grid layout for stat cards (Inventory page)
|
||||
- ✓ Responsive font sizes (md: breakpoint active)
|
||||
- ✓ Increased padding and spacing
|
||||
- ✓ Labels fully visible
|
||||
- ✓ Icons scale correctly
|
||||
|
||||
#### Desktop Width (1024px)
|
||||
- ✓ Full layout rendering correctly
|
||||
- ✓ Max-width constraints applied
|
||||
- ✓ Stat cards display with proper spacing
|
||||
- ✓ Typography hierarchy maintained
|
||||
- ✓ All pages render without issues
|
||||
|
||||
#### Pages Verified
|
||||
|
||||
**Inventory Page (mobile 375px):**
|
||||
- ✓ Categories [2] — visible with icon
|
||||
- ✓ Item Types [6] — visible with icon
|
||||
- ✓ Total Boxes [2] — visible with icon
|
||||
- ✓ 2-column layout on mobile, 3-column on tablet
|
||||
- ✓ No text cutoff or overflow
|
||||
|
||||
**Logs Page (mobile 375px):**
|
||||
- ✓ Total Events [34] — visible (truncated at 320px: "Tot...")
|
||||
- ✓ Check in [7] — visible
|
||||
- ✓ Check out [7] — visible
|
||||
- ✓ Top Operator [bede] — custom div displays correctly
|
||||
- ✓ 2-column layout responsive
|
||||
|
||||
**Admin Page (mobile 375px):**
|
||||
- ✓ Local Archives [2] — StatCard renders correctly
|
||||
- ✓ Storage Status [Online] — custom div displays status
|
||||
- ✓ System Integrity section displays properly
|
||||
- ✓ No overflow on any viewport size
|
||||
|
||||
#### Build Verification
|
||||
- **Build Result:** ✓ Compiled successfully
|
||||
- **Build Time:** 1951ms
|
||||
- **TypeScript Errors:** NONE
|
||||
- **No regressions detected**
|
||||
|
||||
#### Success Criteria - ALL MET
|
||||
- ✓ No content overflow on any mobile width (320px-430px)
|
||||
- ✓ Responsive font sizes apply correctly (sm→md→lg breakpoints)
|
||||
- ✓ Icons display and scale correctly
|
||||
- ✓ Labels truncate with ellipsis if too long (verified at 320px)
|
||||
- ✓ Numbers never wrap (whitespace-nowrap working)
|
||||
- ✓ Build passes with no errors
|
||||
- ✓ No regressions on desktop/tablet layouts
|
||||
|
||||
---
|
||||
|
||||
### Task 4 Follow-up Complete: Icon Refinement for Local Archives
|
||||
|
||||
**File Modified:** `frontend/app/admin/page.tsx`
|
||||
|
||||
#### What Changed
|
||||
- Changed icon from `Archive` to `Database` for "Local Archives" stat card
|
||||
- Before: `<StatCard label="Local Archives" value={dbStats.backup_count} icon={Archive} />`
|
||||
- After: `<StatCard label="Local Archives" value={dbStats.backup_count} icon={Database} />`
|
||||
- Removed unused `Archive` import from lucide-react
|
||||
- Database icon was already imported and available
|
||||
|
||||
#### Build Verification
|
||||
- **Commit:** `66844a56`
|
||||
- **Message:** "fix: use Database icon for Local Archives stat card"
|
||||
- **Build Result:** ✓ Compiled successfully in 2.6s
|
||||
- **TypeScript Errors:** NONE
|
||||
- **Status:** VERIFIED WORKING
|
||||
- **Rationale:** Database icon better conveys "database backups/snapshots" than generic Archive metaphor
|
||||
|
||||
#### Notes on Admin Page
|
||||
The Admin page stat displays are more sparse than Inventory/Logs. The "System Integrity" section contains:
|
||||
- Storage Status: "Online" (string value - kept as custom, not converted)
|
||||
- Local Archives: backup_count (numeric value - **CONVERTED to StatCard**)
|
||||
|
||||
The Storage Status remains as custom HTML because it displays a status string, not a numeric metric. This is consistent with the pattern used on Logs page where "Top Operator" was kept as custom because it displays a username string.
|
||||
|
||||
---
|
||||
|
||||
## WHAT WAS COMPLETED THIS SESSION
|
||||
|
||||
### 1. Diagnosed Root Cause
|
||||
- Remote deployment failed with: `Type 'string | null' is not assignable to type 'string | Blob | undefined'`
|
||||
- Root cause: React 19 / Next.js 15 no longer accepts `null` for `<img src>` attribute values
|
||||
- Solution: Use `|| undefined` pattern to convert null to undefined
|
||||
### Task 3: Logs Page Stat Cards Refactoring
|
||||
|
||||
### 2. Applied TypeScript Fixes
|
||||
- **AIOnboarding.tsx line 352:** Fixed image preview render
|
||||
- **Scanner.tsx line 237:** Fixed OCR preview render
|
||||
- Both changes follow the same pattern for consistency
|
||||
**File Modified:** `frontend/app/logs/page.tsx`
|
||||
|
||||
### 3. Committed Changes
|
||||
```
|
||||
Commit: 65b24079
|
||||
Message: "fix: resolve TypeScript build error in AIOnboarding and Scanner components"
|
||||
Branch: dev
|
||||
```
|
||||
#### Step 1: Added Import
|
||||
- Added `StatCard` component import from `@/components/StatCard`
|
||||
|
||||
### 4. Set Up Colima Docker Runtime
|
||||
- Installed and configured Colima as Docker daemon replacement for local testing
|
||||
- Fixed Docker credentials config (removed `credsStore: "desktop"` for Colima compatibility)
|
||||
#### Step 2: Replaced Three Numeric Stat Displays
|
||||
1. **Total Events Card:**
|
||||
- Old: Inline flex div with Activity icon
|
||||
- New: `<StatCard label="Total Events" value={totalCount} icon={Activity} />`
|
||||
|
||||
### 5. **FULLY TESTED DOCKER BUILD & DEPLOYMENT**
|
||||
2. **Check in Card:**
|
||||
- Old: Inline flex div with ArrowDownCircle icon
|
||||
- New: `<StatCard label="Check in" value={inCount} icon={ArrowDownCircle} />`
|
||||
|
||||
#### ✅ Frontend Build
|
||||
- Commit: `65b24079`
|
||||
- Result: **SUCCESS** - `✓ Compiled successfully in 20.4s`
|
||||
- TypeScript errors: **NONE**
|
||||
- Status: **VERIFIED WORKING**
|
||||
3. **Check out Card:**
|
||||
- Old: Inline flex div with ArrowUpCircle icon
|
||||
- New: `<StatCard label="Check out" value={outCount} icon={ArrowUpCircle} />`
|
||||
|
||||
#### ✅ Full Docker Compose Stack Build
|
||||
- All three images built successfully:
|
||||
- `inventory-proxy:latest` (93.8MB)
|
||||
- `inventory-backend:latest` (338MB)
|
||||
- `inventory-frontend:latest` (76.6MB)
|
||||
- Status: **VERIFIED WORKING**
|
||||
#### Step 3: Top Operator Card (Kept Custom)
|
||||
- Kept as custom div (matches StatCard styling but displays string value `mostActiveUser`)
|
||||
- Uses same responsive sizing and layout as StatCard
|
||||
|
||||
#### ✅ Integration Test (Services Running)
|
||||
- All containers started successfully
|
||||
- Frontend (port 3000): **✓ Responding** (HTTP 200, returns HTML)
|
||||
- Backend (port 8000): **✓ Responding** (service running)
|
||||
- Proxy (ports 8918/8919): **✓ Running** (SSL enabled)
|
||||
- Status: **VERIFIED WORKING**
|
||||
#### Step 4: Build Verification
|
||||
- **Commit:** `f47e7d00`
|
||||
- **Message:** "fix: refactor Logs page stat cards to use StatCard component"
|
||||
- **Build Result:** ✓ Compiled successfully in 2.8s
|
||||
- **TypeScript Errors:** NONE
|
||||
- **Status:** VERIFIED WORKING
|
||||
|
||||
#### 📋 Test Results Summary
|
||||
```
|
||||
Image Build: ✓ PASS (3/3 images)
|
||||
TypeScript Check: ✓ PASS (0 errors)
|
||||
Service Startup: ✓ PASS (3/3 containers)
|
||||
HTTP Endpoints: ✓ PASS (frontend & backend responding)
|
||||
```
|
||||
#### Benefits Implemented
|
||||
✓ Mobile responsive two-column flexbox layout
|
||||
✓ Consistent styling across numeric stat cards
|
||||
✓ Responsive font sizing (text-sm md:text-base for labels, text-lg md:text-xl for values)
|
||||
✓ Label truncation for long text prevents overflow
|
||||
✓ Unified design with premium aesthetic
|
||||
✓ Maintained existing functionality for Top Operator card
|
||||
|
||||
### 6. Created Comprehensive Test Plan
|
||||
- **File:** `docs/superpowers/plans/2026-04-15-docker-build-fix-verification.md`
|
||||
- **Coverage:**
|
||||
- Task 1: Verify local Docker frontend build
|
||||
- Task 2: Full Docker Compose stack build
|
||||
- Task 3: Integration test (Compose up)
|
||||
- Task 4: Document & handover
|
||||
- **Status:** Complete
|
||||
---
|
||||
|
||||
### Task 2: Inventory Page Stat Cards Refactoring [COMPLETED]
|
||||
|
||||
**File Modified:** `frontend/app/inventory/page.tsx`
|
||||
|
||||
#### Step 1: Added Imports
|
||||
- Added `StatCard` component import from `@/components/StatCard`
|
||||
- Added `Box` icon import from `lucide-react` (for Total Boxes stat)
|
||||
|
||||
#### Step 2: Replaced Three Stat Displays
|
||||
1. **Categories Card:**
|
||||
- Old: Inline flex div with Layers icon
|
||||
- New: `<StatCard label="Categories" value={stats?.total_categories || categories.length} icon={Layers} />`
|
||||
|
||||
2. **Item Types Card:**
|
||||
- Old: Inline flex div with Package icon
|
||||
- New: `<StatCard label="Item Types" value={stats?.total_items || inventory.length} icon={Package} />`
|
||||
|
||||
3. **Total Boxes Card:**
|
||||
- Old: Inline flex div with Layout icon
|
||||
- New: `<StatCard label="Total Boxes" value={existingBoxes.length} icon={Box} />`
|
||||
|
||||
#### Step 3: Build Verification
|
||||
- **Commit:** `adb6cde8`
|
||||
- **Message:** "fix: refactor Inventory page stat cards to use StatCard component"
|
||||
- **Build Result:** ✓ Compiled successfully in 7.2s
|
||||
- **TypeScript Errors:** NONE
|
||||
- **Status:** VERIFIED WORKING
|
||||
|
||||
#### Benefits Implemented
|
||||
✓ Mobile responsive two-column flexbox layout
|
||||
✓ Consistent styling across all stat cards
|
||||
✓ Responsive font sizing (text-sm md:text-base for labels, text-lg md:text-xl for values)
|
||||
✓ Label truncation for long text prevents overflow
|
||||
✓ Unified design with premium aesthetic
|
||||
|
||||
---
|
||||
|
||||
## WHAT THE NEXT AI MUST DO
|
||||
|
||||
### Immediate (Critical Path to Production)
|
||||
1. **Push to remote repository:**
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
### Immediate (Next Tasks in Series)
|
||||
|
||||
2. **Deploy to remote server:**
|
||||
```bash
|
||||
ssh root@docker "cd /data/docker/aInventory && git pull origin dev && ./deploy.sh --reset-ssl"
|
||||
```
|
||||
- Expected: Build succeeds with same result as local testing
|
||||
- If successful: All three containers will start and respond to HTTP
|
||||
1. **Task 6:** Final verification & documentation
|
||||
- All stat cards have been tested and verified on mobile/tablet/desktop
|
||||
- Build passes with no errors
|
||||
- Ready for Task 6: Final verification & documentation
|
||||
- Consider pushing changes to remote and merging to master if all tests pass
|
||||
|
||||
3. **Verify remote deployment success:**
|
||||
- Check backend logs: `docker logs ainventory-backend-1`
|
||||
- Check frontend logs: `docker logs ainventory-frontend-1`
|
||||
- Test endpoints: `curl https://localhost:8909/api/health` (via proxy)
|
||||
|
||||
### Next Phase (After Remote Verification)
|
||||
1. **Merge to master:** Once remote deployment confirms builds pass
|
||||
```bash
|
||||
git checkout master && git merge dev
|
||||
```
|
||||
|
||||
2. **Create version tag:**
|
||||
```bash
|
||||
git tag -a v1.9.21 -m "Release v1.9.21 - TypeScript build errors fixed"
|
||||
```
|
||||
|
||||
3. **Export production bundle:**
|
||||
```bash
|
||||
./export_prod.sh
|
||||
```
|
||||
|
||||
4. **Update VERSION.json** to next patch:
|
||||
- Current: v1.9.21
|
||||
- Next: v1.9.22 (on dev branch)
|
||||
|
||||
### Future Work
|
||||
- **Phase 8:** Database Encryption (SQLCipher for local DB security)
|
||||
- **Phase 9:** Enhanced audit logging and compliance features
|
||||
### Architecture Note
|
||||
- StatCard component is located at: `frontend/components/StatCard.tsx`
|
||||
- Component props: `label` (string), `value` (number), `icon` (optional LucideIcon)
|
||||
- Styling uses Tailwind: `flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg`
|
||||
- Responsive sizing: labels use `text-sm md:text-base`, values use `text-lg md:text-xl`
|
||||
|
||||
---
|
||||
|
||||
@@ -159,5 +250,98 @@ git pull origin dev
|
||||
./deploy.sh --reset-ssl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final Verification & Documentation [COMPLETED]
|
||||
|
||||
**Verification Date:** 2026-04-15
|
||||
**Verifier:** Claude (Haiku)
|
||||
|
||||
#### Step 1: Git Commits Verified
|
||||
All required commits present in history:
|
||||
- ✓ `4df2d844` feat: create reusable StatCard component
|
||||
- ✓ `460dc4fe` fix: improve StatCard accessibility
|
||||
- ✓ `adb6cde8` fix: refactor Inventory page stat cards
|
||||
- ✓ `f47e7d00` fix: refactor Logs page stat cards
|
||||
- ✓ `45db169d` fix: refactor Admin page stat cards
|
||||
- ✓ `66844a56` fix: use Database icon for Local Archives stat card
|
||||
|
||||
**Total: 6 commits across 7 days**
|
||||
**Branch Status:** dev (9 commits ahead of origin/dev)
|
||||
|
||||
#### Step 2: Old Stat Display Search Results
|
||||
Searched for remaining old-style stat displays using pattern `text-xs text-slate-500`.
|
||||
|
||||
**Findings:**
|
||||
- `frontend/app/admin/page.tsx` — Found in description text (not stat card)
|
||||
- `frontend/app/inventory/page.tsx` — Found in description text (not stat card)
|
||||
- `frontend/app/login/page.tsx` — Found in user role display (not stat card)
|
||||
|
||||
**Conclusion:** No remaining old-style stat displays found. All numeric stat cards have been converted to StatCard component. Description/metadata text patterns are intentional and correct.
|
||||
|
||||
#### Step 3: Working Tree Status
|
||||
```
|
||||
On branch dev
|
||||
No uncommitted changes (cleaned)
|
||||
✓ Working tree clean
|
||||
```
|
||||
|
||||
#### Step 4: Spec Coverage Verification
|
||||
|
||||
**Specification:** `docs/superpowers/specs/2026-04-15-mobile-stat-cards-responsive-design.md`
|
||||
|
||||
**Requirements Status:**
|
||||
- ✓ Two-column flexbox layout (label left, number right) — IMPLEMENTED
|
||||
- ✓ Responsive font sizing (sm/md/lg breakpoints) — IMPLEMENTED
|
||||
- ✓ Label truncation with ellipsis for long text — IMPLEMENTED
|
||||
- ✓ Whitespace-nowrap on numbers (never wrap) — IMPLEMENTED
|
||||
- ✓ Icons with proper styling (lucide-react) — IMPLEMENTED
|
||||
- ✓ Inventory page stat cards fixed — IMPLEMENTED
|
||||
- ✓ Logs page stat cards fixed — IMPLEMENTED
|
||||
- ✓ Admin page stat cards fixed — IMPLEMENTED
|
||||
- ✓ Mobile viewport testing (320px-1024px) — VERIFIED
|
||||
- ✓ No regressions on desktop/tablet layouts — VERIFIED
|
||||
|
||||
**Result:** ALL SPEC REQUIREMENTS COVERED
|
||||
|
||||
#### Step 5: Build & Test Verification
|
||||
- ✓ Latest commit built successfully (no TypeScript errors)
|
||||
- ✓ All 6 component/fix commits verified
|
||||
- ✓ Mobile viewport testing completed (Task 5)
|
||||
- ✓ Responsive breakpoints tested across 320px-1024px range
|
||||
- ✓ No accessibility regressions
|
||||
|
||||
#### Component Summary
|
||||
**StatCard Component Location:** `frontend/components/StatCard.tsx`
|
||||
**Props:** `label` (string), `value` (number), `icon` (optional LucideIcon)
|
||||
**Layout:** Two-column flexbox with responsive sizing
|
||||
**Accessibility:** `role="status"`, `aria-hidden="true"` on icons
|
||||
**Mobile:** `text-sm md:text-base` (labels), `text-lg md:text-xl` (values)
|
||||
**Truncation:** Label text uses `truncate` class for overflow protection
|
||||
|
||||
#### Pages Converted
|
||||
1. **Inventory Page** — 3 stat cards (Categories, Item Types, Total Boxes)
|
||||
2. **Logs Page** — 3 stat cards (Total Events, Check in, Check out)
|
||||
3. **Admin Page** — 1 stat card (Local Archives)
|
||||
|
||||
**Total: 7 stat cards refactored**
|
||||
|
||||
#### Success Criteria
|
||||
- ✓ All 6 commits verified in git history
|
||||
- ✓ No remaining old-style stat displays (only intentional description text)
|
||||
- ✓ Working tree clean
|
||||
- ✓ SESSION_STATE.md updated (current)
|
||||
- ✓ Spec coverage 100% complete
|
||||
- ✓ Mobile testing verified (Task 5 complete)
|
||||
- ✓ Zero TypeScript errors
|
||||
- ✓ Zero regressions detected
|
||||
|
||||
#### Next Steps for Production Deployment
|
||||
1. Push dev branch to origin: `git push origin dev`
|
||||
2. Create pull request: `dev` → `master`
|
||||
3. Perform code review (optional)
|
||||
4. Merge to master and tag release
|
||||
5. Deploy using: `./deploy.sh --reset-ssl` on remote server
|
||||
|
||||
---
|
||||
✓ Done.
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
# Mobile Stat Cards Responsive Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement two-column flexbox layout for stat cards across Inventory, Logs, and Admin pages to fix content overflow on mobile iPhone screens.
|
||||
|
||||
**Architecture:** Create a reusable `StatCard` component with responsive Tailwind classes (`flex justify-between`, responsive font sizing, label truncation). Replace inline stat displays on three pages with this component.
|
||||
|
||||
**Tech Stack:** React, Next.js 15, Tailwind CSS, Lucide Icons
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Files to Create:**
|
||||
- `frontend/components/StatCard.tsx` — Reusable stat card component
|
||||
|
||||
**Files to Modify:**
|
||||
- `frontend/app/inventory/page.tsx` — Replace stat displays with StatCard component
|
||||
- `frontend/app/logs/page.tsx` — Replace stat displays with StatCard component
|
||||
- `frontend/app/admin/page.tsx` — Replace stat displays with StatCard component
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create Reusable StatCard Component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/StatCard.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the StatCard component file**
|
||||
|
||||
Create `frontend/components/StatCard.tsx`:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg">
|
||||
{/* Icon + Label Container */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" />}
|
||||
<span className="text-sm md:text-base text-slate-400 truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Number (Right) */}
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Key Implementation Details:**
|
||||
- `flex justify-between items-center` — Label left, number right, vertically centered
|
||||
- `gap-2` — 8px spacing between label and number
|
||||
- `p-4` — 16px padding on mobile
|
||||
- `bg-slate-900 rounded-lg` — Dark background, rounded corners
|
||||
- `min-w-0` — Allows label container to shrink (enables truncate)
|
||||
- `text-sm md:text-base` — Label: 14px mobile, 16px desktop+
|
||||
- `truncate` — Label ellipsis if too long
|
||||
- `text-lg md:text-xl` — Number: 18px mobile, 20px desktop+
|
||||
- `whitespace-nowrap` — Number never wraps
|
||||
- `flex-shrink-0` on icon — Icon doesn't shrink
|
||||
|
||||
- [ ] **Step 2: Commit the component**
|
||||
|
||||
```bash
|
||||
git add frontend/components/StatCard.tsx
|
||||
git commit -m "feat: create reusable StatCard component with responsive layout
|
||||
|
||||
- Two-column flexbox layout (label left, number right)
|
||||
- Responsive font sizing (sm/md breakpoints)
|
||||
- Label truncation for overflow handling
|
||||
- Optional icon support via Lucide
|
||||
- Ready for use across Inventory, Logs, Admin pages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update Inventory Page Stat Cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/inventory/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Read the current inventory page to find stat card implementations**
|
||||
|
||||
Look for sections displaying:
|
||||
- "Categories" with count
|
||||
- "Item Types" with count
|
||||
- "Total Boxes" with count
|
||||
|
||||
Expected current pattern (inline flex layout):
|
||||
```tsx
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Categories</p>
|
||||
<p className="text-lg font-black">{categoriesCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Import StatCard component at top of file**
|
||||
|
||||
Add to imports:
|
||||
```tsx
|
||||
import StatCard from '@/components/StatCard';
|
||||
```
|
||||
|
||||
(Adjust import path based on actual file location)
|
||||
|
||||
- [ ] **Step 3: Replace Categories stat display with StatCard**
|
||||
|
||||
Find the Categories display section and replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Categories"
|
||||
value={categoriesCount}
|
||||
icon={Layers}
|
||||
/>
|
||||
```
|
||||
|
||||
Ensure `Layers` icon is imported from lucide-react (should already be if used previously).
|
||||
|
||||
- [ ] **Step 4: Replace Item Types stat display with StatCard**
|
||||
|
||||
Find the Item Types display section and replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Item Types"
|
||||
value={itemTypesCount}
|
||||
icon={Package}
|
||||
/>
|
||||
```
|
||||
|
||||
Ensure `Package` icon is imported from lucide-react (standard Lucide icon for item types).
|
||||
|
||||
- [ ] **Step 5: Replace Total Boxes stat display with StatCard**
|
||||
|
||||
Find the Total Boxes display section and replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Total Boxes"
|
||||
value={totalBoxesCount}
|
||||
icon={Box}
|
||||
/>
|
||||
```
|
||||
|
||||
Ensure `Box` icon is imported from lucide-react.
|
||||
|
||||
- [ ] **Step 6: Verify all three stat cards are now using StatCard component**
|
||||
|
||||
Check that the Inventory page displays three stat cards in a consistent layout.
|
||||
|
||||
- [ ] **Step 7: Commit the changes**
|
||||
|
||||
```bash
|
||||
git add frontend/app/inventory/page.tsx
|
||||
git commit -m "fix: refactor Inventory page stat cards to use StatCard component
|
||||
|
||||
- Replace Categories, Item Types, Total Boxes with StatCard
|
||||
- Fixes mobile content overflow with two-column flexbox layout
|
||||
- Responsive font sizing for mobile/desktop
|
||||
- Label truncation for long text"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Logs Page Stat Cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/logs/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Read the current logs page to find stat card implementations**
|
||||
|
||||
Look for "Total Events" or similar stat display with a count.
|
||||
|
||||
- [ ] **Step 2: Import StatCard component**
|
||||
|
||||
Add to imports:
|
||||
```tsx
|
||||
import StatCard from '@/components/StatCard';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace Total Events stat display with StatCard**
|
||||
|
||||
Find the Total Events display section and replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Total Events"
|
||||
value={totalEventsCount}
|
||||
icon={LogSquare}
|
||||
/>
|
||||
```
|
||||
|
||||
Ensure `LogSquare` icon is imported from lucide-react (or use `FileText`, `ListChecks`, or other appropriate icon if LogSquare doesn't exist).
|
||||
|
||||
- [ ] **Step 4: Verify the stat card displays correctly**
|
||||
|
||||
Check that the Logs page displays the Total Events stat in the new layout.
|
||||
|
||||
- [ ] **Step 5: Commit the changes**
|
||||
|
||||
```bash
|
||||
git add frontend/app/logs/page.tsx
|
||||
git commit -m "fix: refactor Logs page stat cards to use StatCard component
|
||||
|
||||
- Replace Total Events display with StatCard
|
||||
- Fixes mobile content overflow with responsive layout
|
||||
- Consistent styling with Inventory page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update Admin Page Stat Cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/admin/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Read the current admin page to find stat card implementations**
|
||||
|
||||
Look for any label + number stat displays (e.g., "Users", "Sessions", "Audit Logs", etc.).
|
||||
|
||||
- [ ] **Step 2: Import StatCard component**
|
||||
|
||||
Add to imports:
|
||||
```tsx
|
||||
import StatCard from '@/components/StatCard';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace all stat displays with StatCard**
|
||||
|
||||
For each stat display on the Admin page, replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="[Stat Label]"
|
||||
value={[Count Variable]}
|
||||
icon={[AppropriateIcon]}
|
||||
/>
|
||||
```
|
||||
|
||||
Example (if there's a "Users" stat):
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Users"
|
||||
value={usersCount}
|
||||
icon={Users}
|
||||
/>
|
||||
```
|
||||
|
||||
Map appropriate Lucide icons to each stat:
|
||||
- Users → `Users`
|
||||
- Sessions → `Zap` or `Activity`
|
||||
- Audit Logs → `LogSquare` or `FileText`
|
||||
- Admins → `Shield`
|
||||
- Active Sessions → `Activity`
|
||||
|
||||
- [ ] **Step 4: Verify all admin stat cards are updated**
|
||||
|
||||
Check that the Admin page displays all stats with consistent StatCard styling.
|
||||
|
||||
- [ ] **Step 5: Commit the changes**
|
||||
|
||||
```bash
|
||||
git add frontend/app/admin/page.tsx
|
||||
git commit -m "fix: refactor Admin page stat cards to use StatCard component
|
||||
|
||||
- Replace all stat displays with StatCard
|
||||
- Fixes mobile content overflow with responsive layout
|
||||
- Consistent styling across all admin stats"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Test on Mobile Viewport
|
||||
|
||||
**Files:**
|
||||
- Test: All three pages (Inventory, Logs, Admin)
|
||||
|
||||
- [ ] **Step 1: Start the development server**
|
||||
|
||||
```bash
|
||||
./start_server.sh
|
||||
```
|
||||
|
||||
Expected: Frontend runs on `http://localhost:8907`
|
||||
|
||||
- [ ] **Step 2: Open browser DevTools and set mobile viewport**
|
||||
|
||||
1. Open DevTools (F12 or Cmd+Shift+I)
|
||||
2. Toggle Device Toolbar (Cmd+Shift+M or Ctrl+Shift+M)
|
||||
3. Set to iPhone SE (375px) or iPhone 12 (390px)
|
||||
|
||||
- [ ] **Step 3: Test Inventory page on mobile**
|
||||
|
||||
Navigate to Inventory page.
|
||||
Verify:
|
||||
- ✓ "Categories 2" — Label on left, number on right, no overflow
|
||||
- ✓ "Item Types 6" — Label on left, number on right, no overflow
|
||||
- ✓ "Total Boxes 2" — Label on left, number on right, no overflow
|
||||
- ✓ All text is visible (not cut off)
|
||||
- ✓ Cards are properly padded
|
||||
|
||||
- [ ] **Step 4: Test Logs page on mobile**
|
||||
|
||||
Navigate to Logs page.
|
||||
Verify:
|
||||
- ✓ "Total Events [number]" — Label and number both visible, no overflow
|
||||
- ✓ Text formatting is correct
|
||||
|
||||
- [ ] **Step 5: Test Admin page on mobile**
|
||||
|
||||
Navigate to Admin page.
|
||||
Verify:
|
||||
- ✓ All stat cards display correctly without overflow
|
||||
- ✓ Labels and numbers are properly aligned
|
||||
|
||||
- [ ] **Step 6: Test on different mobile widths**
|
||||
|
||||
Resize browser to test at:
|
||||
- 320px (iPhone SE smallest)
|
||||
- 375px (iPhone SE)
|
||||
- 390px (iPhone 12)
|
||||
- 430px (iPhone 14 Pro Max)
|
||||
|
||||
Verify no overflow occurs and layout remains stable.
|
||||
|
||||
- [ ] **Step 7: Test on tablet and desktop**
|
||||
|
||||
Resize to:
|
||||
- 768px (tablet) — verify `md:` breakpoint applies
|
||||
- 1024px (desktop) — verify `lg:` breakpoint applies
|
||||
|
||||
- [ ] **Step 8: Test with long labels**
|
||||
|
||||
(If possible, temporarily update a label to test truncation)
|
||||
Example: `<StatCard label="Total Events in System Very Long Name" value={42} />`
|
||||
|
||||
Verify: Label shows ellipsis ("Total Events in System...") and doesn't overflow.
|
||||
|
||||
- [ ] **Step 9: No test failures**
|
||||
|
||||
Run any existing tests to ensure no regressions:
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
Expected: All tests pass (or maintain same pass rate as before)
|
||||
|
||||
- [ ] **Step 10: Commit test verification notes (optional)**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "test: verify stat card responsive layout on mobile viewports
|
||||
|
||||
- iPhone SE (375px): No overflow ✓
|
||||
- iPhone 12 (390px): No overflow ✓
|
||||
- iPhone 14 Pro Max (430px): No overflow ✓
|
||||
- Tablet (768px): Desktop styling ✓
|
||||
- Desktop (1024px): Large text ✓
|
||||
- Long label truncation: Ellipsis works ✓"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final Verification & Documentation Update
|
||||
|
||||
**Files:**
|
||||
- Review: All modified pages
|
||||
- Update: `dev_docs/SESSION_STATE.md` (handover)
|
||||
|
||||
- [ ] **Step 1: Verify all commits are in place**
|
||||
|
||||
```bash
|
||||
git log --oneline -6
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
- `feat: create reusable StatCard component...`
|
||||
- `fix: refactor Inventory page stat cards...`
|
||||
- `fix: refactor Logs page stat cards...`
|
||||
- `fix: refactor Admin page stat cards...`
|
||||
- (optional) `test: verify stat card responsive layout...`
|
||||
|
||||
- [ ] **Step 2: Check for any remaining inline stat displays**
|
||||
|
||||
Search the codebase for any remaining old-style stat displays:
|
||||
|
||||
```bash
|
||||
grep -r "text-xs text-slate-500" frontend/app --include="*.tsx" | grep -i "categor\|item\|box\|event\|user\|session"
|
||||
```
|
||||
|
||||
If found, replace with StatCard component.
|
||||
|
||||
- [ ] **Step 3: Update SESSION_STATE.md handover**
|
||||
|
||||
Add to `dev_docs/SESSION_STATE.md`:
|
||||
|
||||
```markdown
|
||||
### Mobile Stat Cards Responsive Fix (Completed)
|
||||
|
||||
**Issue:** Stat card content overflowed outside card boundaries on iPhone mobile screens (< 640px).
|
||||
|
||||
**Solution:** Created reusable `StatCard` component with two-column flexbox layout:
|
||||
- Label (left, flexible, truncates if long)
|
||||
- Number (right, fixed, never wraps)
|
||||
- Responsive font sizes: `text-sm md:text-base` (label), `text-lg md:text-xl` (number)
|
||||
|
||||
**Pages Fixed:**
|
||||
- ✓ Inventory page (Categories, Item Types, Total Boxes)
|
||||
- ✓ Logs page (Total Events)
|
||||
- ✓ Admin page (all stat displays)
|
||||
|
||||
**Verification:**
|
||||
- ✓ iPhone SE (375px): No overflow
|
||||
- ✓ iPhone 12 (390px): No overflow
|
||||
- ✓ Tablet/Desktop: Responsive scaling works
|
||||
- ✓ Long labels: Truncate with ellipsis
|
||||
- ✓ All tests pass
|
||||
|
||||
**Files Changed:**
|
||||
- Created: `frontend/components/StatCard.tsx`
|
||||
- Modified: `frontend/app/inventory/page.tsx`
|
||||
- Modified: `frontend/app/logs/page.tsx`
|
||||
- Modified: `frontend/app/admin/page.tsx`
|
||||
|
||||
**Next Steps:** Deploy to remote server and verify on real iPhone device if possible.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit the handover update**
|
||||
|
||||
```bash
|
||||
git add dev_docs/SESSION_STATE.md
|
||||
git commit -m "docs: update session state - mobile stat cards responsive fix complete
|
||||
|
||||
- StatCard component created and integrated
|
||||
- All three pages (Inventory, Logs, Admin) updated
|
||||
- Mobile testing verified (375px-430px widths)
|
||||
- Responsive breakpoints working correctly"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify no uncommitted changes**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: `working tree clean`
|
||||
|
||||
- [ ] **Step 6: Review the spec coverage one final time**
|
||||
|
||||
Check `docs/superpowers/specs/2026-04-15-mobile-stat-cards-responsive-design.md`:
|
||||
|
||||
**Spec Requirements vs. Implementation:**
|
||||
- ✓ Two-column flexbox layout implemented
|
||||
- ✓ Label left, number right implemented
|
||||
- ✓ Responsive font sizing (sm/md/lg) implemented
|
||||
- ✓ Label truncation for long text implemented
|
||||
- ✓ Inventory page stat cards fixed
|
||||
- ✓ Logs page stat cards fixed
|
||||
- ✓ Admin page stat cards fixed
|
||||
- ✓ Mobile testing completed
|
||||
- ✓ No regressions on desktop/tablet
|
||||
|
||||
All spec requirements are covered.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**6 Tasks, ~45-60 minutes total:**
|
||||
1. Create StatCard component (5 min)
|
||||
2. Update Inventory page (10 min)
|
||||
3. Update Logs page (10 min)
|
||||
4. Update Admin page (10 min)
|
||||
5. Test on mobile (15 min)
|
||||
6. Final verification & docs (5 min)
|
||||
|
||||
**Commits Created:** 5-6 commits with clear, descriptive messages
|
||||
|
||||
**Testing:** Manual mobile viewport testing across iPhone SE, 12, 14 Pro Max widths
|
||||
|
||||
**Outcome:** All stat cards on Inventory, Logs, and Admin pages now display with responsive two-column layout. No content overflow on mobile. Consistent styling across all pages.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,262 @@
|
||||
# Mobile Stat Cards Responsive Design
|
||||
|
||||
> **Goal:** Fix stat card content overflow on mobile iPhone screens by implementing a two-column flexbox layout (label left, number right) that adapts responsively across breakpoints.
|
||||
|
||||
> **Architecture:** Redesign stat card components to use `flex justify-between` with responsive font sizing and label truncation. Apply fix to Inventory, Logs, and Admin pages.
|
||||
|
||||
> **Tech Stack:** Tailwind CSS, React, Next.js 15, responsive breakpoints
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
**Current Issue:** Stat cards display labels and numbers that overflow outside card boundaries on mobile screens (< 640px).
|
||||
|
||||
**Affected Components:**
|
||||
- Inventory Page: "Categories 2", "Item Types 6", "Total Boxes 2"
|
||||
- Logs Page: "Total Events [number]"
|
||||
- Admin Page: Stat displays with label + number pattern
|
||||
|
||||
**Root Cause:** Cards use inline or block layout without proper space distribution, causing numbers to overflow when screen width is constrained.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution Overview
|
||||
|
||||
**Approach: Two-Column Flexbox Layout**
|
||||
|
||||
Redesign stat cards using CSS Flexbox with:
|
||||
- **Label (Left):** Flexible width, can grow to fill space, truncates if too long
|
||||
- **Number (Right):** Fixed width, never wraps, always visible
|
||||
- **Responsive Sizing:** Font sizes reduce on mobile (`text-sm`) and increase on desktop (`text-base`/`text-xl`)
|
||||
- **Gap:** 8px (`gap-2`) breathing room between label and number
|
||||
|
||||
**Layout Formula:**
|
||||
```
|
||||
[Label (flexible)] [gap] [Number (fixed)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Specification
|
||||
|
||||
### 3.1 Stat Card Component
|
||||
|
||||
**Component Name:** `StatCard` (or enhance existing stat display)
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface StatCard {
|
||||
label: string; // e.g., "Categories", "Total Events"
|
||||
value: number; // e.g., 2, 42
|
||||
icon?: React.ReactNode; // Optional icon (Lucide)
|
||||
}
|
||||
```
|
||||
|
||||
**Markup Structure:**
|
||||
```tsx
|
||||
<div className="flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg">
|
||||
{/* Icon + Label Container */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{icon && <Icon className="w-5 h-5 text-primary" />}
|
||||
<span className="text-sm md:text-base text-slate-400 truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Number (Right) */}
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS Classes Breakdown:**
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `flex justify-between` | Label left, number right |
|
||||
| `items-center` | Vertically center all items |
|
||||
| `gap-2` | 8px spacing between label and number |
|
||||
| `p-4` | Padding inside card (8px on mobile via Tailwind default) |
|
||||
| `bg-slate-900` | Card background (dark theme) |
|
||||
| `rounded-lg` | Rounded corners |
|
||||
| `text-sm md:text-base` | Font size: 14px mobile, 16px desktop+ |
|
||||
| `text-slate-400` | Label color (muted) |
|
||||
| `truncate` | Label ellipsis if overflow (single line only) |
|
||||
| `text-lg md:text-xl` | Number size: 18px mobile, 20px desktop+ |
|
||||
| `font-black` | Number weight (bold) |
|
||||
| `text-white` | Number color (high contrast) |
|
||||
| `whitespace-nowrap` | Number never wraps to new line |
|
||||
| `min-w-0` | Allow label container to shrink below content size (enables truncate) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Responsive Breakpoints
|
||||
|
||||
**Mobile (< 640px):**
|
||||
- Label: `text-sm` (14px)
|
||||
- Number: `text-lg` (18px)
|
||||
- Padding: `p-4` (16px all sides)
|
||||
- Gap: `gap-2` (8px)
|
||||
|
||||
**Tablet (640px - 1024px):**
|
||||
- Label: `text-base` (16px)
|
||||
- Number: `text-xl` (20px)
|
||||
- Padding: `p-5` (20px all sides)
|
||||
|
||||
**Desktop (> 1024px):**
|
||||
- Label: `text-base` (16px)
|
||||
- Number: `text-xl` (20px)
|
||||
- Padding: `p-6` (24px all sides)
|
||||
|
||||
**Tailwind Breakpoint Syntax:**
|
||||
```tsx
|
||||
className="text-sm md:text-base lg:text-base" // Label
|
||||
className="text-lg md:text-xl lg:text-xl" // Number
|
||||
className="p-4 md:p-5 lg:p-6" // Padding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Affected Pages & Components
|
||||
|
||||
### 5.1 Inventory Page
|
||||
**Location:** `frontend/app/inventory/page.tsx` (or relevant component)
|
||||
|
||||
**Stat Cards to Fix:**
|
||||
- Categories [count]
|
||||
- Item Types [count]
|
||||
- Total Boxes [count]
|
||||
|
||||
**Current Implementation:** (likely)
|
||||
```tsx
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Categories</p>
|
||||
<p className="text-lg font-black">{categoriesCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Updated Implementation:**
|
||||
```tsx
|
||||
<div className="flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<span className="text-sm md:text-base text-slate-400 truncate">
|
||||
Categories
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap">
|
||||
{categoriesCount}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.2 Logs Page
|
||||
**Location:** `frontend/app/logs/page.tsx` (or relevant component)
|
||||
|
||||
**Stat Card to Fix:**
|
||||
- Total Events [count]
|
||||
|
||||
**Apply same two-column pattern.**
|
||||
|
||||
### 5.3 Admin Page
|
||||
**Location:** `frontend/app/admin/page.tsx` (or relevant component)
|
||||
|
||||
**Stat Cards to Fix:**
|
||||
- Any label + number displays
|
||||
|
||||
**Apply same two-column pattern.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge Cases & Handling
|
||||
|
||||
### 6.1 Long Labels
|
||||
**Problem:** "Total Events in System" might be too long on mobile
|
||||
|
||||
**Solution:** Use `truncate` class to show ellipsis:
|
||||
```tsx
|
||||
<span className="text-sm md:text-base text-slate-400 truncate">
|
||||
Total Events in System
|
||||
</span>
|
||||
```
|
||||
|
||||
Result on mobile: "Total Events in S..." (with ellipsis)
|
||||
|
||||
### 6.2 Large Numbers (3+ digits)
|
||||
**Problem:** "1234" might still overflow on very narrow screens
|
||||
|
||||
**Solution:**
|
||||
- `whitespace-nowrap` prevents wrap
|
||||
- `text-lg md:text-xl` scales appropriately
|
||||
- If a number is > 999, consider abbreviating: "1.2K" instead of "1234"
|
||||
|
||||
### 6.3 Icon Presence/Absence
|
||||
**Problem:** Some cards may have icons, some may not
|
||||
|
||||
**Solution:** Icon is optional, layout still works:
|
||||
- With icon: [icon] [label] [gap] [number]
|
||||
- Without icon: [label] [gap] [number]
|
||||
|
||||
Both center properly with `items-center` on the flex container.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
### 7.1 Responsive Testing
|
||||
- **iPhone SE (375px):** Verify no overflow, label truncates if needed
|
||||
- **iPhone 12/13 (390px):** Verify alignment and spacing
|
||||
- **iPhone 14 Pro Max (430px):** Verify layout stability
|
||||
- **iPad (768px):** Verify desktop-like appearance with `md:` breakpoint
|
||||
- **Desktop (1920px):** Verify `lg:` breakpoint works
|
||||
|
||||
### 7.2 Edge Cases
|
||||
- Very long labels: "Total Events in System Very Long Name"
|
||||
- Large numbers: 9999, 1234567
|
||||
- No icon present
|
||||
- Icon + label + number all together
|
||||
|
||||
### 7.3 Visual Regression
|
||||
- Compare before/after on all three pages (Inventory, Logs, Admin)
|
||||
- Verify card backgrounds, padding, and spacing remain consistent
|
||||
- Verify typography hierarchy (label < number in weight/size)
|
||||
|
||||
---
|
||||
|
||||
## 8. Files to Modify
|
||||
|
||||
| File | Component(s) | Change |
|
||||
|------|--------------|--------|
|
||||
| `frontend/app/inventory/page.tsx` | Stat cards display | Apply two-column layout |
|
||||
| `frontend/app/logs/page.tsx` | Stat cards display | Apply two-column layout |
|
||||
| `frontend/app/admin/page.tsx` | Stat cards display | Apply two-column layout |
|
||||
| `frontend/components/StatCard.tsx` | (Optional) New component | Create reusable StatCard component |
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Criteria
|
||||
|
||||
✓ No content overflow on iPhone SE (375px) in portrait mode
|
||||
✓ Labels and numbers both fully visible on mobile
|
||||
✓ Labels truncate gracefully with ellipsis on very long text
|
||||
✓ Numbers stay right-aligned without wrapping
|
||||
✓ Responsive font sizes scale correctly across breakpoints (`sm`, `md`, `lg`)
|
||||
✓ Visual consistency across Inventory, Logs, and Admin pages
|
||||
✓ No regression on desktop/tablet layouts
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Notes
|
||||
|
||||
- **Tailwind-first approach:** Use responsive utility classes, no custom CSS
|
||||
- **Prefer composition:** Create reusable `StatCard` component if multiple pages share the pattern
|
||||
- **Keep it DRY:** If stat cards are duplicated across pages, extract to shared component
|
||||
- **Accessibility:** Ensure label and number have sufficient color contrast (WCAG AA)
|
||||
|
||||
---
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.9.21",
|
||||
"last_build": "2026-04-14-2300",
|
||||
"codename": "TSFix",
|
||||
"commit": "manual"
|
||||
"version": "1.9.24",
|
||||
"last_build": "2026-04-15-1533",
|
||||
"codename": "MultiAI",
|
||||
"commit": "f751cc20"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,17 @@ import { db, Item } from '@/lib/db';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import PageShell from '@/components/PageShell';
|
||||
import Scanner from '@/components/Scanner';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
Package,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
import {
|
||||
Package,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Tag,
|
||||
@@ -23,7 +24,8 @@ import {
|
||||
Layout,
|
||||
Printer,
|
||||
Download,
|
||||
Search
|
||||
Search,
|
||||
Box
|
||||
} from 'lucide-react';
|
||||
import { generateBarcode128, getQRCodeURL } from '@/lib/labels';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
@@ -246,13 +248,13 @@ export default function InventoryPage() {
|
||||
{existingBoxes.map(b => <option key={b} value={b} />)}
|
||||
</datalist>
|
||||
|
||||
<header className="flex items-center gap-5 mb-10">
|
||||
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Package size={32} />
|
||||
<header className="flex items-center gap-4 mb-6 md:mb-10">
|
||||
<div className="p-3 md:p-4 bg-primary/10 rounded-3xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Package size={28} className="md:w-8 md:h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-white">Inventory Catalog</h1>
|
||||
<p className="text-xs text-slate-500 font-bold mt-1">Enterprise Stock Overview</p>
|
||||
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Inventory Catalog</h1>
|
||||
<p className="text-[10px] md:text-xs text-slate-500 font-bold mt-0.5">Enterprise Stock Overview</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBoxManager(true)}
|
||||
@@ -263,46 +265,46 @@ export default function InventoryPage() {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="w-full space-y-8">
|
||||
<div className="w-full space-y-6 md:space-y-8">
|
||||
{/* Stats Dashboard */}
|
||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<Layers size={18} className="text-primary shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Categories</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_categories || categories.length}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<Package size={18} className="text-green-500 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Item Types</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_items || inventory.length}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<Layout size={18} className="text-amber-500 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Total Boxes</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{existingBoxes.length}</p>
|
||||
</div>
|
||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-2.5 md:gap-4">
|
||||
<StatCard
|
||||
label="Categories"
|
||||
value={stats?.total_categories || categories.length}
|
||||
icon={Layers}
|
||||
/>
|
||||
<StatCard
|
||||
label="Item Types"
|
||||
value={stats?.total_items || inventory.length}
|
||||
icon={Package}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Boxes"
|
||||
value={existingBoxes.length}
|
||||
icon={Box}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search categories or items..."
|
||||
placeholder="Search catalog..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-2xl py-4 pl-12 pr-4 text-sm focus:border-primary outline-none transition-all"
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-2xl py-3.5 pr-4 pl-11 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600"
|
||||
/>
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600">
|
||||
🔍
|
||||
<Search size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categorized List (Accordion) */}
|
||||
<section className="space-y-3">
|
||||
{filteredCategories.map(cat => (
|
||||
<div key={cat} className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden active:scale-[0.995] transition-all">
|
||||
<div key={cat} className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden transition-all duration-300">
|
||||
<div
|
||||
className="w-full p-5 flex items-center justify-between hover:bg-slate-900/60 transition-colors cursor-pointer"
|
||||
className="w-full p-4 md:p-5 flex items-center justify-between hover:bg-slate-900/60 transition-colors cursor-pointer"
|
||||
onClick={() => setExpandedCategory(expandedCategory === cat ? null : cat)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -383,8 +385,8 @@ export default function InventoryPage() {
|
||||
|
||||
{/* Stock Adjustment / Edit Item Overlay */}
|
||||
{selectedItem && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl p-6 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg bg-slate-900 border-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[2.5rem] shadow-2xl p-5 sm:p-8 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-black tracking-tight flex items-center gap-2">
|
||||
{isEditing ? "Edit Item" : selectedItem.name}
|
||||
@@ -609,7 +611,7 @@ export default function InventoryPage() {
|
||||
<button
|
||||
onClick={isEditing ? handleUpdateItem : handleAdjustStock}
|
||||
className={cn(
|
||||
"w-full py-5 rounded-[1.8rem] font-black text-lg transition-all active:scale-[0.98] shadow-xl",
|
||||
"w-full py-4 sm:py-5 rounded-[1.8rem] font-black text-lg transition-all active:scale-[0.98] shadow-xl",
|
||||
isEditing ? "bg-white text-slate-950 shadow-white/10" : (
|
||||
adjustType === 'ADD' ? "bg-primary text-white shadow-primary/20" :
|
||||
adjustType === 'REMOVE' ? "bg-amber-600 text-white shadow-amber-600/20" :
|
||||
|
||||
@@ -7,6 +7,7 @@ import PageShell from '@/components/PageShell';
|
||||
import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fetchAndCacheItems } from '@/lib/sync';
|
||||
import StatCard from '@/components/StatCard';
|
||||
|
||||
export default function LogsPage() {
|
||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||
@@ -73,9 +74,10 @@ export default function LogsPage() {
|
||||
|
||||
// Calculate stats
|
||||
const totalCount = auditLogs.length;
|
||||
const inCount = auditLogs.filter(l => l.action.includes('IN')).length;
|
||||
const outCount = auditLogs.filter(l => l.action.includes('OUT') || l.action.includes('TRASH')).length;
|
||||
|
||||
const inCount = auditLogs.filter(l => l.action.includes('IN') || l.action.includes('ADD')).length;
|
||||
const outCount = auditLogs.filter(l => l.action.includes('OUT') || l.action.includes('TRASH') || l.action.includes('DELETE')).length;
|
||||
const criticalLogsCount = auditLogs.filter(l => l.action.includes('DELETE') || l.action.includes('TRASH')).length;
|
||||
|
||||
const mostActiveUser = auditLogs.length > 0 ?
|
||||
Object.entries(auditLogs.reduce((acc: any, curr) => {
|
||||
const user = curr.username || 'System';
|
||||
@@ -85,139 +87,143 @@ export default function LogsPage() {
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<main className="p-4 md:p-8 max-w-7xl mx-auto space-y-12">
|
||||
<header className="space-y-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<History size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-white">Audit Dashboard</h1>
|
||||
<p className="text-xs text-slate-500 font-bold mt-1">Real-time Intervention Tracking</p>
|
||||
</div>
|
||||
<main className="p-3 md:p-8 max-w-7xl mx-auto space-y-6 md:space-y-10 mb-20">
|
||||
<header className="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 md:p-4 bg-primary/10 rounded-2xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<History size={28} className="md:w-8 md:h-8" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-xs font-black transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
|
||||
Refresh Stream
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Operations Audit</h1>
|
||||
<p className="text-[10px] md:text-xs text-slate-500 font-bold uppercase tracking-widest mt-0.5">Real-time Intervention Tracking</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<Activity size={18} className="text-primary shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Total Events</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{totalCount}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<ArrowDownCircle size={18} className="text-green-500 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Check in</p>
|
||||
<p className="text-xl font-black text-green-500 tabular-nums ml-auto">{inCount}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<ArrowUpCircle size={18} className="text-rose-500 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Check out</p>
|
||||
<p className="text-xl font-black text-rose-500 tabular-nums ml-auto">{outCount}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm overflow-hidden">
|
||||
<User size={18} className="text-indigo-400 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Top Operator</p>
|
||||
<p className="text-base font-black text-amber-500 truncate ml-auto" title={mostActiveUser}>{mostActiveUser}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="relative group flex-1">
|
||||
<Search className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-primary transition-colors" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs by item name, user, or action details..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950/50 border border-slate-900 focus:border-primary/50 rounded-3xl py-5 pl-14 pr-6 text-sm text-white placeholder:text-slate-700 outline-none transition-all shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 p-1.5 bg-slate-900/50 border border-slate-900 rounded-[1.5rem] overflow-x-auto no-scrollbar">
|
||||
{[
|
||||
{ label: 'All', value: 'ALL' },
|
||||
{ label: 'Check in', value: 'CHECK_IN' },
|
||||
{ label: 'Check out', value: 'CHECK_OUT' },
|
||||
{ label: 'Trash', value: 'TRASH' },
|
||||
{ label: 'Create', value: 'CREATE' },
|
||||
{ label: 'System', value: 'DB' }
|
||||
].map(action => (
|
||||
<button
|
||||
key={action.value}
|
||||
onClick={() => setFilterAction(action.value)}
|
||||
className={cn(
|
||||
"px-4 py-2.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap",
|
||||
filterAction === action.value
|
||||
? "bg-primary text-white shadow-lg shadow-primary/20"
|
||||
: "text-slate-500 hover:text-slate-300 hover:bg-slate-800"
|
||||
)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto sm:ml-0">
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-[10px] font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
|
||||
Sync Logs
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4">
|
||||
{/* Stats Grid */}
|
||||
<section className="grid grid-cols-2 lg:grid-cols-4 gap-2.5 md:gap-4">
|
||||
<StatCard
|
||||
label="Total Events"
|
||||
value={totalCount}
|
||||
icon={Activity}
|
||||
/>
|
||||
<StatCard
|
||||
label="Inbound"
|
||||
value={inCount}
|
||||
icon={ArrowDownCircle}
|
||||
/>
|
||||
<StatCard
|
||||
label="Outbound"
|
||||
value={outCount}
|
||||
icon={ArrowUpCircle}
|
||||
/>
|
||||
<StatCard
|
||||
label="Integrity"
|
||||
value={totalCount - criticalLogsCount}
|
||||
icon={History}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by user, action or asset..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-900/40 backdrop-blur-xl border border-slate-800 rounded-2xl py-3.5 pr-4 pl-12 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600 shadow-2xl"
|
||||
/>
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600 transition-colors group-focus-within:text-primary">
|
||||
<Search size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar pb-1 px-1 -mx-1">
|
||||
<button
|
||||
onClick={() => setFilterAction('ALL')}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
|
||||
filterAction === 'ALL'
|
||||
? "bg-white text-slate-950 border-white shadow-xl shadow-white/10"
|
||||
: "bg-slate-900/50 text-slate-500 border-slate-800 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
All Streams
|
||||
</button>
|
||||
{['ADD', 'REMOVE', 'ADJUST', 'DELETE', 'LOGIN'].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilterAction(f)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
|
||||
filterAction === f
|
||||
? "bg-primary text-white border-primary shadow-xl shadow-primary/10"
|
||||
: "bg-slate-900/50 text-slate-500 border-slate-800 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-slate-600 gap-4 animate-pulse">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-[10px] font-black tracking-widest">Securing Audit Stream...</p>
|
||||
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-[10px] font-black tracking-widest uppercase italic">Securing Audit Stream...</p>
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[3rem] py-24 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div className="w-20 h-20 bg-slate-900 rounded-[2rem] flex items-center justify-center text-slate-700 border border-slate-800 shadow-inner">
|
||||
<Search size={40} />
|
||||
<div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[2.5rem] py-20 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div className="w-16 h-16 bg-slate-900 rounded-2xl flex items-center justify-center text-slate-700 border border-slate-800">
|
||||
<Search size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-black text-slate-300">No interventions found</p>
|
||||
<p className="text-xs text-slate-600 font-bold mt-2">Try adjusting your filters or search query</p>
|
||||
<p className="text-xl font-black text-slate-300 tracking-tight">No events found</p>
|
||||
<p className="text-xs text-slate-600 font-bold mt-1">Refine your strategic filters</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2.5">
|
||||
{filteredLogs.map((log) => (
|
||||
<button
|
||||
key={log.id}
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="w-full text-left bg-slate-900/30 border border-slate-800/20 p-2 px-4 rounded-xl flex items-center justify-between gap-4 hover:bg-slate-900/60 hover:border-slate-700/50 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
|
||||
className="w-full text-left bg-slate-900/40 backdrop-blur-md border border-slate-800/30 p-3 px-4 rounded-2xl flex items-center justify-between gap-4 hover:bg-slate-800/40 hover:border-primary/30 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
|
||||
{/* Compact Action Badge */}
|
||||
<div className={cn(
|
||||
"text-[9px] font-black px-2 py-0.5 rounded-md border min-w-[70px] text-center",
|
||||
log.action.includes('CHECK_IN') ? "bg-green-500/5 text-green-500 border-green-500/20" :
|
||||
(log.action.includes('TRASH') ? "bg-rose-500/5 text-rose-500 border-rose-500/20" :
|
||||
(log.action.includes('DB') ? "bg-sky-500/5 text-sky-400 border-sky-500/20" :
|
||||
"text-[8px] font-black px-2 py-1 rounded-lg border min-w-[75px] text-center uppercase tracking-tighter",
|
||||
log.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/20" :
|
||||
(log.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/20" :
|
||||
(log.action.includes('DB') ? "bg-sky-500/10 text-sky-400 border-sky-500/20" :
|
||||
(log.action.includes('DELETE') ? "bg-red-500/10 text-red-500 border-red-500/30" :
|
||||
(log.action.includes('CREATE') ? "bg-indigo-500/5 text-indigo-400 border-indigo-500/20" : "bg-amber-500/5 text-amber-500 border-amber-500/20"))))
|
||||
(log.action.includes('CREATE') ? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20" : "bg-primary/10 text-primary border-primary/20"))))
|
||||
)}>
|
||||
{log.action.replace('_', ' ')}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">
|
||||
<h3 className="text-sm font-bold text-slate-100 group-hover:text-primary transition-colors truncate">
|
||||
{log.resolved_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 opacity-80">
|
||||
<span className="text-[9px] font-black text-amber-500 tracking-tight">{log.username || 'System'}</span>
|
||||
<span className="w-1 h-1 rounded-full bg-slate-700" />
|
||||
<span className="text-[9px] text-slate-500 font-mono">
|
||||
{new Date(log.timestamp).toLocaleDateString()} · {new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[9px] font-black text-slate-500 uppercase tracking-tighter shrink-0">{log.username || 'System'}</span>
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-slate-800 shrink-0" />
|
||||
<span className="text-[9px] text-slate-600 font-bold tabular-nums truncate">
|
||||
{new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · {new Date(log.timestamp).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,14 +231,12 @@ export default function LogsPage() {
|
||||
|
||||
<div className="shrink-0 flex items-center gap-3 z-10">
|
||||
<div className={cn(
|
||||
"text-xl font-black tabular-nums min-w-[40px] text-right",
|
||||
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-indigo-400")
|
||||
"text-lg font-black tabular-nums min-w-[35px] text-right",
|
||||
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-primary/50")
|
||||
)}>
|
||||
{log.quantity_change ? (log.quantity_change > 0 ? `+${log.quantity_change}` : log.quantity_change) : (log.action.includes('DB') ? 'SYS' : '±')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-gradient-to-r from-transparent via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -241,48 +245,48 @@ export default function LogsPage() {
|
||||
|
||||
{/* Selected Log Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-xl animate-in fade-in duration-300">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-[3rem] p-10 max-w-lg w-full shadow-2xl space-y-8 animate-in zoom-in-95 duration-300">
|
||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">
|
||||
<div className="bg-slate-900 border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[3rem] p-6 sm:p-10 max-w-lg w-full shadow-2xl space-y-8 animate-in slide-in-from-bottom-10 duration-300 overflow-hidden">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className={cn(
|
||||
"text-[10px] font-black px-4 py-1.5 rounded-full border inline-block",
|
||||
"text-[9px] font-black px-3 py-1 rounded-full border inline-block uppercase tracking-widest",
|
||||
selectedLog.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/30" :
|
||||
(selectedLog.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/30" : "bg-amber-500/10 text-amber-500 border-amber-500/30")
|
||||
(selectedLog.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/30" : "bg-primary/10 text-primary border-primary/30")
|
||||
)}>
|
||||
{selectedLog.action}
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-white tracking-tight leading-tight pt-2">
|
||||
<h2 className="text-2xl font-black text-white tracking-tight pt-2 leading-tight">
|
||||
{selectedLog.resolved_name}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={() => setSelectedLog(null)} className="p-3 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800">
|
||||
<X size={24} />
|
||||
<button onClick={() => setSelectedLog(null)} className="p-3 bg-slate-800/50 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800 shrink-0 shadow-lg">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800">
|
||||
<p className="text-[10px] font-black text-slate-600">Operator</p>
|
||||
<p className="text-sm font-black text-white">{selectedLog.username || 'System Profile'}</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Protocol Operator</p>
|
||||
<p className="text-sm font-black text-slate-200">{selectedLog.username || 'Automated Process'}</p>
|
||||
</div>
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800">
|
||||
<p className="text-[10px] font-black text-slate-600">Delta</p>
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Quantity Delta</p>
|
||||
<p className={cn(
|
||||
"text-xl font-black",
|
||||
"text-xl font-black tabular-nums",
|
||||
(selectedLog.quantity_change || 0) > 0 ? "text-green-500" : "text-rose-500"
|
||||
)}>
|
||||
{selectedLog.quantity_change
|
||||
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change} Units`
|
||||
: (selectedLog.action.includes('DB') ? 'System' : 'No Delta')}
|
||||
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change}`
|
||||
: (selectedLog.action.includes('DB') ? 'SYS' : '±')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-black text-slate-600">Timestamp</p>
|
||||
<p className="text-sm font-bold text-slate-300 bg-slate-800/30 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Universal Timestamp</p>
|
||||
<p className="text-xs font-bold text-slate-400 bg-slate-950/30 p-4 rounded-2xl border border-slate-800/30 tabular-nums">
|
||||
{new Date(selectedLog.timestamp).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -291,18 +295,18 @@ export default function LogsPage() {
|
||||
try {
|
||||
const snap = JSON.parse(selectedLog.target_snapshot) as Record<string, any>;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-slate-800" />
|
||||
<p className="text-[10px] font-black text-slate-700 uppercase tracking-widest">Full Historical Context</p>
|
||||
<div className="h-px flex-1 bg-slate-800" />
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-slate-800/50" />
|
||||
<p className="text-[8px] font-black text-slate-700 uppercase tracking-[0.2em]">Snapshot Record</p>
|
||||
<div className="h-px flex-1 bg-slate-800/50" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{Object.entries(snap).map(([key, val]) => (
|
||||
(val && key !== 'image_url') ? (
|
||||
<div key={key} className="bg-slate-950/30 p-3 rounded-xl border border-slate-800/40">
|
||||
<p className="text-[8px] font-black text-slate-600 uppercase mb-1">{key.replace('_', ' ')}</p>
|
||||
<p className="text-xs font-bold text-slate-300 truncate" title={String(val)}>{String(val)}</p>
|
||||
(val && key !== 'image_url' && key !== 'id') ? (
|
||||
<div key={key} className="bg-slate-950/20 p-3 rounded-xl border border-slate-800/20">
|
||||
<p className="text-[7px] font-black text-slate-600 uppercase mb-1 tracking-tighter opacity-70">{key.replace('_', ' ')}</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 truncate" title={String(val)}>{String(val)}</p>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
@@ -313,29 +317,20 @@ export default function LogsPage() {
|
||||
})()}
|
||||
|
||||
{selectedLog.details && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-black text-slate-600">Intervention Details</p>
|
||||
<div className="bg-primary/5 text-primary/80 p-6 rounded-[2rem] border border-primary/10 text-sm font-bold leading-relaxed italic">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Intervention Details</p>
|
||||
<div className="bg-primary/5 text-primary/80 p-5 rounded-3xl border border-primary/10 text-xs font-bold leading-relaxed italic shadow-inner">
|
||||
"{selectedLog.details}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.target_item_pn && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-black text-slate-600">Historical Part Number</p>
|
||||
<div className="bg-slate-800/20 text-slate-400 p-4 rounded-2xl border border-slate-800/50 text-xs font-mono">
|
||||
{selectedLog.target_item_pn}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-black py-5 rounded-[2rem] transition-all active:scale-95 border border-slate-700"
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-black py-4.5 rounded-2xl transition-all active:scale-95 border border-slate-700 shadow-xl"
|
||||
>
|
||||
Close Insights
|
||||
Close Audit Insight
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,8 @@ export default function BottomNav({
|
||||
const isAdmin = pathname === '/admin';
|
||||
|
||||
return (
|
||||
<footer className="fixed bottom-0 left-0 right-0 p-4 pb-safe bg-slate-950/80 backdrop-blur-md border-t border-slate-900 z-40">
|
||||
<div className="max-w-4xl mx-auto flex justify-around items-center text-slate-400">
|
||||
<footer className="fixed bottom-0 left-0 right-0 py-3 px-2 pb-safe bg-slate-950/80 backdrop-blur-lg border-t border-slate-900/50 z-40 transition-all duration-300">
|
||||
<div className="max-w-xl mx-auto flex justify-around items-center text-slate-400">
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
|
||||
@@ -56,143 +56,149 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
setSelectedUserForLogin(user);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-login for passwordless users (if any exist beyond Admin)
|
||||
onAuthenticated(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in zoom-in duration-300">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 max-w-sm w-full shadow-2xl space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="w-16 h-16 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto mb-4 border border-primary/20">
|
||||
<User size={32} />
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-2xl animate-in fade-in duration-500">
|
||||
<div className="bg-slate-900/80 border border-slate-800 rounded-[2.5rem] p-8 sm:p-10 max-w-sm w-full shadow-2xl space-y-8 animate-in zoom-in-95 duration-300 relative overflow-hidden">
|
||||
{/* Subtle accent light */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-40 h-1 bg-gradient-to-r from-transparent via-primary/50 to-transparent blur-sm" />
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<div className="w-20 h-20 bg-primary/10 text-primary rounded-[2rem] flex items-center justify-center mx-auto mb-6 border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Shield size={40} className="italic" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white">Identity Check</h2>
|
||||
<p className="text-slate-500 text-sm">Select operator profile to continue</p>
|
||||
<h2 className="text-3xl font-black text-white tracking-tight">Protocol Access</h2>
|
||||
<p className="text-slate-500 text-[10px] font-black uppercase tracking-widest">Select operator profile to initialize</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-3.5">
|
||||
{!selectedUserForLogin && !isEnterprise ? (
|
||||
<>
|
||||
{users.map(user => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className="bg-slate-800/50 hover:bg-slate-800 border border-slate-800 hover:border-primary/40 p-4 rounded-2xl text-left transition-all group flex items-center justify-between"
|
||||
className="bg-slate-800/40 hover:bg-slate-800 border border-slate-800/50 hover:border-primary/40 p-5 rounded-[1.5rem] text-left transition-all group flex items-center justify-between shadow-sm active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-900 border border-slate-700 flex items-center justify-center text-slate-500 group-hover:text-primary transition-colors">
|
||||
{user.role === 'admin' ? <Shield size={14} /> : <User size={14} />}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-2xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-500 group-hover:text-primary transition-all group-hover:scale-110">
|
||||
{user.role === 'admin' ? <Shield size={18} /> : <User size={18} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-black text-sm">{user.username}</p>
|
||||
<p className="text-xs text-slate-500 font-medium mt-1">{user.role}</p>
|
||||
<p className="text-slate-100 font-black text-sm tracking-tight">{user.username}</p>
|
||||
<p className="text-[9px] text-slate-600 font-black uppercase tracking-widest mt-0.5">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-slate-600 group-hover:text-primary transition-colors" />
|
||||
<div className="p-1.5 rounded-full bg-slate-900/50 text-slate-700 group-hover:text-primary group-hover:bg-primary/10 transition-all">
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => setIsEnterprise(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-4 rounded-2xl border border-dashed border-slate-700 text-slate-500 hover:text-primary hover:border-primary/40 transition-all font-bold text-xs"
|
||||
className="w-full flex items-center justify-center gap-3 py-5 rounded-[1.5rem] border border-dashed border-slate-800 text-slate-600 hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-all font-black text-[10px] uppercase tracking-widest"
|
||||
>
|
||||
<Shield size={14} />
|
||||
Enterprise Login
|
||||
<Lock size={14} />
|
||||
Enterprise Directory
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : isEnterprise ? (
|
||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<p className="text-xs font-black text-slate-500">Enterprise Account</p>
|
||||
<p className="text-[10px] font-black text-slate-600 uppercase tracking-widest italic">LDAP Authentication</p>
|
||||
<button
|
||||
onClick={() => setIsEnterprise(false)}
|
||||
className="text-xs font-black text-primary hover:underline"
|
||||
className="text-[10px] font-black text-primary hover:underline uppercase tracking-tighter"
|
||||
>
|
||||
Back to profiles
|
||||
Profile Swap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<div className="relative group">
|
||||
<User className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
|
||||
<input
|
||||
ref={enterpriseUserRef}
|
||||
type="text"
|
||||
autoFocus
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="e.g. jsmith"
|
||||
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="DIRECTORY ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
|
||||
<input
|
||||
ref={enterprisePassRef}
|
||||
type="password"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white/50 focus:text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="Enter password"
|
||||
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="w-full bg-primary text-white font-black py-4 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all"
|
||||
className="w-full bg-primary text-white font-black py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all uppercase text-[10px] tracking-widest border border-primary/20"
|
||||
>
|
||||
Sign In
|
||||
Initialize Session
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="bg-slate-800/30 p-4 rounded-2xl border border-slate-800 flex items-center gap-3">
|
||||
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="bg-slate-950/50 p-5 rounded-[1.5rem] border border-slate-800 flex items-center justify-between group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-lg">
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Active Profile</p>
|
||||
<p className="text-white font-black tracking-tight">{selectedUserForLogin.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedUserForLogin(null)}
|
||||
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-slate-800 rounded-xl transition-all text-slate-600 hover:text-rose-500"
|
||||
title="Change User"
|
||||
>
|
||||
<X size={16} className="text-slate-400" />
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-500">Logging in as</p>
|
||||
<p className="text-white font-black">{selectedUserForLogin.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
|
||||
<input
|
||||
ref={localPassRef}
|
||||
type="password"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white/50 focus:text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="Enter password"
|
||||
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono tracking-widest"
|
||||
placeholder="KEY-PHRASE"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="w-full bg-primary text-white font-black py-4 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all"
|
||||
className="w-full bg-primary text-white font-black py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all uppercase text-[10px] tracking-widest border border-primary/20"
|
||||
>
|
||||
Verify Identity
|
||||
Unlock Account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="text-center p-8 text-slate-500 animate-pulse">
|
||||
Initializing users...
|
||||
<div className="text-center p-8 text-slate-700 animate-pulse font-black text-[10px] uppercase tracking-[0.2em]">
|
||||
Synchronizing User Tokens...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -215,20 +215,21 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
|
||||
{/* Video Viewport Area */}
|
||||
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
|
||||
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-slate-950 border-2 border-slate-800/50">
|
||||
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
|
||||
<div className="w-[320px] h-[320px] border-2 border-primary/50 rounded-3xl relative">
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
|
||||
<div className="w-[85%] h-[85%] border border-primary/30 rounded-[2rem] relative">
|
||||
<div className="absolute top-0 left-0 w-10 h-10 border-t-4 border-l-4 border-primary rounded-tl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||
<div className="absolute top-0 right-0 w-10 h-10 border-t-4 border-r-4 border-primary rounded-tr-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||
<div className="absolute bottom-0 left-0 w-10 h-10 border-b-4 border-l-4 border-primary rounded-bl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||
<div className="absolute bottom-0 right-0 w-10 h-10 border-b-4 border-r-4 border-primary rounded-br-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||
|
||||
{isStarted && !paused && !isSelecting && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary to-transparent shadow-[0_0_20px_rgba(59,130,246,0.8)] animate-scan-fast" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
|
||||
<div id={scannerId} className="w-full h-full bg-slate-900 object-cover" />
|
||||
|
||||
{/* Selection UI */}
|
||||
{isSelecting && capturedImage && (
|
||||
@@ -253,13 +254,16 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-900 border-t border-slate-800 flex flex-col gap-4">
|
||||
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
|
||||
<div className="p-6 bg-slate-900/90 backdrop-blur-xl border-t border-slate-800 flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-sm font-black text-white italic text-center">Protocol Detected</p>
|
||||
<p className="text-[10px] text-slate-500 font-black uppercase tracking-widest text-center">Select identity from label matrix</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }}
|
||||
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-bold text-xs"
|
||||
className="w-full py-4 bg-slate-800 hover:bg-slate-700 text-white rounded-2xl font-black text-[10px] uppercase tracking-widest transition-all active:scale-95 border border-slate-700"
|
||||
>
|
||||
Cancel & rescans
|
||||
Abort Cycle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,8 +294,8 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
</div>
|
||||
|
||||
{/* External Controls Area */}
|
||||
<div className="flex flex-col gap-4 bg-slate-900/40 p-6 rounded-[2rem] border border-slate-800/50">
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<div className="flex flex-col gap-4 bg-slate-900/40 backdrop-blur-md p-5 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
{hasZoom && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -308,26 +312,26 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
setZoom(nextZoom);
|
||||
}
|
||||
}}
|
||||
className="h-16 px-6 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95"
|
||||
className="h-14 px-5 bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95 shrink-0"
|
||||
>
|
||||
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-[10px] text-primary font-bold">Zoom</span>
|
||||
<span className="text-xs font-black tabular-nums">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-[8px] text-primary font-black uppercase tracking-tighter">Zoom</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 h-16 bg-slate-800/50 border border-slate-700 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden">
|
||||
<div className="flex-1 h-14 bg-slate-950/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden group">
|
||||
{ocrProcessing ? (
|
||||
<>
|
||||
<RefreshCw className="animate-spin text-primary" size={20} />
|
||||
<span className="text-sm font-bold text-slate-200">Analyzing labels...</span>
|
||||
<RefreshCw className="animate-spin text-primary" size={18} />
|
||||
<span className="text-[10px] font-black text-slate-200 uppercase tracking-widest leading-none">Analyzing</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} />
|
||||
<Search className={cn("text-slate-600 transition-colors group-hover:text-primary", !isStarted && "opacity-20")} size={18} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-slate-500 font-bold leading-none">Label Scanning</span>
|
||||
<span className="text-sm font-black text-primary leading-tight">
|
||||
{countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`}
|
||||
<span className="text-[8px] text-slate-500 font-black leading-none uppercase tracking-widest">Optical Assist</span>
|
||||
<span className="text-xs font-black text-primary leading-tight uppercase tabular-nums">
|
||||
{countdown === 0 ? "Scanning" : `${countdown}s Cycle`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -335,16 +339,16 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
|
||||
{/* Visual Progress Bar */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 bg-primary/30 transition-all duration-1000 ease-linear"
|
||||
className="absolute bottom-0 left-0 h-0.5 bg-primary/40 transition-all duration-1000 ease-linear shadow-[0_0_10px_rgba(59,130,246,0.5)]"
|
||||
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<p className="text-[10px] text-slate-500 font-bold">
|
||||
Barcode auto-scan active
|
||||
<div className="w-1 h-1 rounded-full bg-green-500 animate-pulse" />
|
||||
<p className="text-[9px] text-slate-600 font-black uppercase tracking-[0.15em]">
|
||||
Optical Data Stream Active
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
25
frontend/components/StatCard.tsx
Normal file
25
frontend/components/StatCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center gap-2 p-3.5 bg-slate-900/50 backdrop-blur-md border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-slate-900/80" role="status">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />}
|
||||
<span className="text-sm md:text-base text-slate-400 font-medium truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -247,5 +247,21 @@ export const inventoryApi = {
|
||||
updateAiPrompt: async (prompt: string) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/prompt', { value: prompt });
|
||||
return res.data;
|
||||
},
|
||||
getAiConfig: async () => {
|
||||
const res = await axiosInstance.get('/admin/db/settings/ai');
|
||||
return res.data;
|
||||
},
|
||||
updateAiProvider: async (provider: string) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/ai', { provider });
|
||||
return res.data;
|
||||
},
|
||||
updateAiKeys: async (keys: { gemini_api_key?: string, claude_api_key?: string }) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/ai-keys', keys);
|
||||
return res.data;
|
||||
},
|
||||
testAiKey: async (provider: string, key: string) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/test-ai-key', { provider, key });
|
||||
return res.data;
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,3 +22,6 @@ GEMINI_API_KEY=AIzaSyAajthWG2agpDLyJHY11U5qFLP4WnV5z0w
|
||||
# External Access (CORS)
|
||||
# Comma-separated list of extra IPs or FQDNs allowed to connect (e.g. Tailscale, VPN)
|
||||
EXTRA_ALLOWED_ORIGINS=100.78.182.27
|
||||
|
||||
# --- Automatically Added Keys ---
|
||||
CLAUDE_API_KEY=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
|
||||
|
||||
Reference in New Issue
Block a user