Compare commits

..

27 Commits

Author SHA1 Message Date
79e3152ce8 Build [v1.10.7] 2026-04-15 19:40:00 +03:00
be251c8a40 Build [v1.10.6] 2026-04-15 19:28:47 +03:00
019b941bdb Build [v1.10.5] 2026-04-15 19:21:20 +03:00
2d162ff3c0 Build [v1.10.4] 2026-04-15 18:10:04 +03:00
6760ab0abf Build [v1.10.2] 2026-04-15 17:54:21 +03:00
d9e592cc97 Build [v1.10.1] 2026-04-15 17:41:34 +03:00
062df8cfd9 Build [v1.10.0] 2026-04-15 17:31:58 +03:00
074d4f1f8d fix(build): revert testing dependencies to fix lockfile mismatch in Docker 2026-04-15 17:14:37 +03:00
add873b159 dada 2026-04-15 16:58:20 +03:00
6bf3276c72 Build [v1.10.0] 2026-04-15 16:45:56 +03:00
e5963123ab docs: documenting modular architecture and testing infrastructure 2026-04-15 16:44:15 +03:00
db918a86ab feat(admin): finalizing modular refactoring with testing suite and UI polish 2026-04-15 16:41:20 +03:00
73ab38d237 Build [v1.9.24] 2026-04-15 15:33:14 +03:00
Daniel Bedeleanu
f751cc20a7 feat: implement strict AI selection (Gemini/Claude), secure API key management, and Admin UI standardization [v1.9.23] 2026-04-15 15:24:06 +03:00
Daniel Bedeleanu
1893c4f38b modificari mari 2026-04-15 15:20:45 +03:00
Daniel Bedeleanu
6102ff39aa Build [v1.9.22] 2026-04-15 11:41:20 +03:00
Daniel Bedeleanu
10d75619bf docs: update documentation with mobile stat cards UI improvements
- Added StatCard component description to README
- Updated frontend architecture in PROJECT_ARCHITECTURE.md
- Documented responsive design improvements (v1.9.21+)
- Mobile-first responsive design for inventory management
2026-04-15 11:39:50 +03:00
Daniel Bedeleanu
546eca6b9a docs: finalize session state - mobile stat cards responsive fix complete
- StatCard component created with accessibility features
- Inventory, Logs, and Admin pages refactored (7 stat cards total)
- Mobile viewport testing verified (320px-1024px)
- All responsive breakpoints working correctly
- Spec coverage 100% - ready for production deployment
2026-04-15 11:36:50 +03:00
Daniel Bedeleanu
66844a56e9 fix: use Database icon for Local Archives stat card
- Changed from Archive to Database icon
- Better semantic representation of database backups
- Improves UI clarity and consistency

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-15 11:29:03 +03:00
Daniel Bedeleanu
e8c804da71 docs: update session state - Admin page stat cards refactored (Task 4 complete) 2026-04-15 11:26:49 +03:00
Daniel Bedeleanu
45db169dda fix: refactor Admin page stat cards to use StatCard component
- Replace Local Archives stat display with StatCard
- Consistent styling and responsive layout across admin stats
- Improves mobile content overflow handling
2026-04-15 11:26:29 +03:00
Daniel Bedeleanu
f47e7d005a fix: refactor Logs page stat cards to use StatCard component
- Replace Total Events, Check in, Check out displays with StatCard
- Maintain Top Operator as custom card (displays string value)
- Fixes mobile content overflow with responsive layout
- Consistent styling with Inventory page
2026-04-15 11:23:10 +03:00
Daniel Bedeleanu
adb6cde87a 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
2026-04-15 11:19:47 +03:00
Daniel Bedeleanu
460dc4fe12 fix: improve StatCard accessibility (aria-hidden, role='status') 2026-04-15 11:17:29 +03:00
Daniel Bedeleanu
4df2d844ca feat: create reusable StatCard component with responsive layout 2026-04-15 11:15:38 +03:00
Daniel Bedeleanu
409afb29f8 plan: mobile stat cards responsive implementation - 6 tasks
- Task 1: Create reusable StatCard component
- Task 2: Update Inventory page stat cards
- Task 3: Update Logs page stat cards
- Task 4: Update Admin page stat cards
- Task 5: Test on mobile viewports
- Task 6: Final verification & handover

Ready for execution via subagent-driven or inline approach
2026-04-15 11:14:24 +03:00
Daniel Bedeleanu
bacc23cc23 spec: mobile stat cards responsive design - two-column layout fix
- Design two-column flexbox layout (label left, number right)
- Responsive font sizing (sm/md/lg breakpoints)
- Label truncation for long text
- Applies to Inventory, Logs, and Admin pages
- Ready for implementation review
2026-04-15 11:13:28 +03:00
49 changed files with 3163 additions and 1817 deletions

View File

@@ -0,0 +1,6 @@
---
trigger: always_on
glob:
description:
---

View File

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

15
.gitignore vendored
View File

@@ -10,8 +10,11 @@ backend/venv/
*.pyo
*.pyd
*.egg-info/
dist/
build/
.pytest_cache/
**/.pytest_cache/
coverage/
**/coverage/
# ── Runtime data directories ─────────────────────────────────
# Content is excluded; the directories themselves are tracked via .gitkeep.
@@ -91,4 +94,12 @@ aInventory-PROD*.zip
*.crt
*.cert
__push_ALL_to_remote.sh
# ── Local AI Tooling & Persistent Paths ──────────────────────
.tool_paths
.git_path
# ── Temporary Docker / Verification Artifacts ────────────────
.docker_tmp/
.tmp_docker/
scratch/.npm/

View File

@@ -16,6 +16,7 @@
## Testing
- Write unit tests for all utility functions
- Minimum 80% coverage on new code
- Use Vitest for unit tests
## Security
- Store all secrets in inventory.env — never hardcode credentials

View File

@@ -12,21 +12,29 @@ 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/`
- **Testing:** Pytest (Unit & Integration) - Location: `backend/tests/`
### 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)
- **Testing:** Vitest (React Hook Testing) - Location: `frontend/tests/`
### 2.3 Operations & Tooling
- **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 +45,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 +106,15 @@ 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.
- **Architectural Modularization (v1.10.0):**
- **Frontend:** The monolithic Admin Dashboard has been decomposed into domain-specific components: `IdentityManager`, `DatabaseManager`, `LdapManager`, `AiManager`, and `CategoryManager`.
- **Logic:** Business logic is centralized in the `useAdmin` custom React hook, ensuring clean separation of concerns.
- **Backend:** Administrative endpoints are split into the `backend/routers/admin/` package, with specialized routers for `backups` and `config`.
- **Stability:** Docker builds are secured against lockfile mismatches by enforcing strict dependency synchronization.
- **Verification Infrastructure:** A dual-layer testing suite is implemented: Pytest for backend integration (using in-memory SQLite and mocked auth) and Vitest for frontend logic validation.

View File

@@ -39,13 +39,37 @@ 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)
- **Admin UI Standardization:** Refactored Admin page with unified StatCards and secure input masking for system credentials.
- **Modular Admin Architecture (v1.10.0):**
- Decomposed monolithic pages into domain-specific components: `IdentityManager`, `DatabaseManager`, `LdapManager`, `AiManager`, and `CategoryManager`.
- Centralized state logic into a custom `useAdmin` hook for improved maintainability.
- Split backend admin endpoints into specific routers (`backups`, `config`) for better scalability.
- Optimized Docker configuration with persistent binary paths and fixed log-piping via `su-exec`.
## 🏗 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).
## 🧪 Testing & Verification (v1.10.0+)
The project includes a comprehensive testing suite to ensure architectural stability:
- **Backend:** `pytest` integration tests with in-memory database mocks.
- Run: `PYTHONPATH=. ./backend/venv/bin/pytest backend/tests/`
- **Frontend:** `vitest` unit tests for custom React hooks and state logic.
- Run: `npm run test` (requires Node 20+)
For more details, see [PROJECT_ARCHITECTURE.md](PROJECT_ARCHITECTURE.md).
---

View File

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

4
__push_ALL_to_remote.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
git push origin --all
git push origin --tags

View File

@@ -0,0 +1,47 @@
## Goal
Reduce the technical debt and cognitive load inherited from large source files (some exceeding 1000 lines) by refactoring them into modular, functional units. The target is to align with the project rule of keeping files significantly under 300 lines where possible, improving maintainability and developer velocity.
## Constraints
- **Maintain Parity**: No functional changes or regressions allowed during refactoring.
- **Stack Consistency**: Maintain Next.js 15 (React 19) and FastAPI patterns.
- **Prop Drilling**: Avoid excessive prop drilling; use Context or efficient state sharing where logical.
- **Type Safety**: Maintain strict TypeScript definitions throughout the refactoring process.
## Known context
- **Frontend Hotspots**: `admin/page.tsx` (1039 lines), `inventory/page.tsx` (858 lines), and the main `page.tsx` (910 lines) are the primary pain points.
- **Backend Hotspots**: `routers/users.py` and `routers/admin_db.py` consolidate too many distinct administrative tasks.
- **Existing Components**: There is already a `components/` directory, indicating an established pattern for extraction.
## Risks
- **Context Fragmentation**: Splitting state-heavy components can make tracking data flow harder if not documented.
- **Build Errors**: Next.js App Router is sensitive to client/server component boundaries during refactoring.
- **Refactoring Fatigue**: Large-scale changes across multiple critical files can introduce subtle bugs in event handlers or API sync logic.
## Options (24)
### Option 1: Functional Component Extraction (UI-First)
Extract major UI sections (e.g., Modals, Tab Panels, Complex List Items) into the `components/` directory. For `admin/page.tsx`, this would mean creating `LDAPSettings.tsx`, `DatabaseSettings.tsx`, and `AISettings.tsx`.
* **Pros**: Immediate reduction in file size; easier to style in isolation.
* **Cons**: Might require passing many props/callbacks.
### Option 2: Custom Hook Logic Extraction (Logic-First)
Move all `useState`, `useEffect`, and API call logic from the pages into custom hooks (e.g., `useAdminSettings.ts`, `useInventoryData.ts`).
* **Pros**: Separates "How it works" from "How it looks"; reusable logic.
* **Cons**: Requires careful handling of dependency arrays and closure-related state bugs.
### Option 3: Modular Sub-Routing (Backend)
Split the backend `users.py` and `admin_db.py` into smaller files based on specific functional domains (e.g., `admin_core.py`, `admin_backups.py`, `user_auth.py`).
* **Pros**: Cleaner API paths and documentation; easier unit testing.
* **Cons**: Requires updating `main.py` router inclusions.
## Recommendation
**Hybrid Approach (Options 1 & 2)**: Focus on the "Big 3" frontend pages first.
1. **Extract Modals and Sections**: Move heavy UI blocks from `admin` and `inventory` into standalone components.
2. **Extract Data Logic**: Create custom hooks for the most complex state-management sections (like the multi-step `AIOnboarding` or the complex sync logic in `inventory`).
3. **Split Backend Routers**: Break down `users.py` and `admin_db.py` into functional sub-modules.
## Acceptance criteria
- [ ] No single file in the `app/` or `routers/` directory exceeds 400 lines (aiming for 300).
- [ ] The dashboard, inventory manager, and scanner function exactly as in `v1.9.24`.
- [ ] TypeScript compilation passes without errors (`npm run build` equivalent).
- [ ] All new components use the established `StatCard` and CSS patterns (Tailwind).

View File

@@ -35,7 +35,5 @@ RUN chmod +x /app/scripts/init_data.sh /app/backend/entrypoint.sh
EXPOSE 8000
EXPOSE 8000
# Entrypoint runs init_data.sh first, then starts uvicorn
ENTRYPOINT ["/app/backend/entrypoint.sh"]

0
backend/__init__.py Normal file
View File

View File

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

View File

@@ -28,7 +28,7 @@ def load_config():
load_dotenv(backend_env_path)
log.info(f" Loaded local configuration from {backend_env_path}")
else:
log.warning(" No .env config files found. Relying on system environment variables.")
log.info(" [CONFIG] Using system environment variables (Docker/Server environment).")
# Auto-run if imported
load_config()

90
backend/config_manager.py Normal file
View 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:]}"

View File

@@ -26,6 +26,12 @@ bash /app/scripts/init_data.sh
# Fix permissions for mounted volumes (which might be root-owned by the host)
echo "🐳 [Docker] Fixing volume permissions..."
chown -R appuser:appuser "${DATA_DIR}" "${LOGS_DIR}"
chmod -R 770 "${DATA_DIR}" "${LOGS_DIR}"
# Verify logic: Ensure appuser can actually write before we hand off
echo "🐳 [Docker] Verifying directory writability..."
gosu appuser touch "${LOGS_DIR}/.startup_test" && rm "${LOGS_DIR}/.startup_test"
gosu appuser touch "${DATA_DIR}/.startup_test" && rm "${DATA_DIR}/.startup_test"
# Hand off to the application server as non-root user
echo "🐳 [Docker] Starting uvicorn as appuser..."

View File

@@ -27,26 +27,25 @@ def setup_logger():
datefmt="%Y-%m-%d %H:%M:%S"
)
# Console Handler
# File Handler (10MB max, keep 5 backups)
try:
file_handler = RotatingFileHandler(
LOG_FILE_PATH, maxBytes=10*1024*1024, backupCount=5
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Special redirect for uvicorn logs to also go to file
logging.getLogger("uvicorn").addHandler(file_handler)
logging.getLogger("uvicorn.access").addHandler(file_handler)
except (PermissionError, IOError) as e:
logger.warning(f"⚠️ [LOGGING] Cannot write to log file at {LOG_FILE_PATH}: {e}")
logger.warning("⚠️ [LOGGING] Continuing with console-only output.")
# Console Handler (always registered)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
# File Handler (10MB max, keep 5 backups)
file_handler = RotatingFileHandler(
LOG_FILE_PATH, maxBytes=10*1024*1024, backupCount=5
)
file_handler.setFormatter(formatter)
# Attach handlers
logger.addHandler(console_handler)
logger.addHandler(file_handler)
# Special redirect for uvicorn logs to also go to file
uvicorn_logger = logging.getLogger("uvicorn")
uvicorn_logger.addHandler(file_handler)
uvicorn_access_logger = logging.getLogger("uvicorn.access")
uvicorn_access_logger.addHandler(file_handler)
return logger

View File

@@ -6,7 +6,8 @@ from slowapi import Limiter
from slowapi.util import get_remote_address
from . import models
from .database import engine
from .routers import items, operations, users, categories, admin_db
from .routers import items, operations, users, categories
from .routers.admin import backups, config
from .logger import log
from .scheduler import scheduler, sync_scheduler_config
@@ -87,7 +88,8 @@ app.include_router(items.router)
app.include_router(operations.router)
app.include_router(users.router)
app.include_router(categories.router)
app.include_router(admin_db.router)
app.include_router(backups.router)
app.include_router(config.router)
@app.on_event("startup")
def startup_event():
@@ -145,7 +147,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():

View File

@@ -13,3 +13,6 @@ passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0
slowapi>=0.1.9
apscheduler>=3.10.1
pytest>=8.0.0
pytest-asyncio>=0.23.0
httpx>=0.27.0

View File

View File

@@ -0,0 +1,91 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from typing import List
from fastapi.responses import FileResponse
from ... import schemas, auth, models
from ...database import get_db
from ...db_manager import DbManager
router = APIRouter(
prefix="/admin/db",
tags=["Admin Database Backups"]
)
@router.get("/backups", response_model=List[schemas.BackupInfo])
def get_backups(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""List available database backups."""
return DbManager.get_backup_list()
@router.get("/stats", response_model=schemas.DatabaseStats)
def get_db_stats(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Get database backup storage statistics."""
return DbManager.get_stats()
@router.post("/backup", response_model=schemas.BackupInfo)
def trigger_manual_backup(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Trigger a manual database backup."""
filename = DbManager.create_backup(db, label="manual", user_id=current_admin.sub)
backups = DbManager.get_backup_list()
for b in backups:
if b.filename == filename:
return b
raise HTTPException(status_code=500, detail="Backup created but info not found")
@router.post("/restore")
def restore_database(
payload: dict,
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Restore database from a specific file. DANGEROUS."""
filename = payload.get("filename")
confirm = payload.get("confirm", False)
if not filename:
raise HTTPException(status_code=400, detail="Filename required")
if not confirm:
raise HTTPException(status_code=400, detail="Confirmation required")
try:
DbManager.restore_backup(filename, db, user_id=current_admin.sub)
return {"status": "success", "message": f"Database restored from {filename}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/export")
def export_database(
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Download the current database file."""
try:
path = DbManager.export_db()
return FileResponse(
path,
media_type="application/x-sqlite3",
filename="inventory_export.db"
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/import")
async def import_database(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Upload and replace the current database. DANGEROUS."""
contents = await file.read()
try:
DbManager.import_db(contents, db, user_id=current_admin.sub)
return {"status": "success", "message": "Database successfully imported and replaced."}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,208 @@
import os
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ... import models, schemas, auth
from ...database import get_db, BASE_DIR
from ...scheduler import sync_scheduler_config
from ...config_manager import ConfigManager
router = APIRouter(
prefix="/admin/db",
tags=["Admin Configuration"]
)
PROJECT_ROOT = os.path.dirname(BASE_DIR)
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
@router.get("/settings", response_model=schemas.DbSettingsUpdate)
def get_db_settings(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Get database retention and scheduling settings."""
retention = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first()
hour = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_hour").first()
freq = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_freq_days").first()
return {
"retention_count": int(retention.value) if retention else 10,
"schedule_hour": int(hour.value) if hour else 3,
"schedule_freq_days": int(freq.value) if freq else 1
}
@router.patch("/settings", response_model=schemas.DbSettingsUpdate)
def update_db_settings(
settings: schemas.DbSettingsUpdate,
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Update database settings and re-trigger scheduler sync."""
pairs = {
"backup_retention_count": str(settings.retention_count),
"backup_schedule_hour": str(settings.schedule_hour),
"backup_schedule_freq_days": str(settings.schedule_freq_days)
}
for key, val in pairs.items():
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first()
if existing:
existing.value = val
else:
db.add(models.SystemSetting(key=key, value=val))
db.commit()
sync_scheduler_config()
return settings
@router.get("/settings/prompt")
def get_ai_prompt(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Get the current AI extraction prompt."""
if os.path.exists(PROMPT_FILE_PATH):
try:
with open(PROMPT_FILE_PATH, 'r', encoding='utf-8') as f:
return {"value": f.read().strip(), "source": "file"}
except Exception:
pass
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
if not setting:
return {"value": "", "source": "none"}
return {"value": setting.value, "source": "database"}
@router.post("/settings/prompt")
def update_ai_prompt(
payload: dict,
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Update the AI extraction prompt."""
value = payload.get("value")
if value is None:
raise HTTPException(status_code=400, detail="Value required")
try:
os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True)
with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f:
f.write(value)
except Exception:
pass
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
if existing:
existing.value = value
else:
db.add(models.SystemSetting(key="ai_extraction_prompt", value=value))
db.commit()
return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}
@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")
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."""
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 updates:
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 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'})
client.models.list()
return {"status": "success", "message": "Google Gemini API connection verified!"}
elif provider == "claude":
import anthropic
client = anthropic.Anthropic(api_key=key)
client.models.list(limit=1)
return {"status": "success", "message": "Anthropic Claude API connection verified!"}
except Exception as e:
raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {str(e)}")
@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")
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}

View File

@@ -1,191 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from .. import models, schemas, auth
from ..database import get_db
from ..db_manager import DbManager
from ..scheduler import sync_scheduler_config
from fastapi.responses import FileResponse
from fastapi import UploadFile, File
router = APIRouter(
prefix="/admin/db",
tags=["Admin Database"]
)
@router.get("/backups", response_model=List[schemas.BackupInfo])
def get_backups(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""List available database backups."""
return DbManager.get_backup_list()
@router.get("/stats", response_model=schemas.DatabaseStats)
def get_db_stats(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Get database backup storage statistics."""
return DbManager.get_stats()
@router.post("/backup", response_model=schemas.BackupInfo)
def trigger_manual_backup(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Trigger a manual database backup."""
filename = DbManager.create_backup(db, label="manual", user_id=current_admin.sub)
# Re-fetch the newly created file info
backups = DbManager.get_backup_list()
for b in backups:
if b.filename == filename:
return b
raise HTTPException(status_code=500, detail="Backup created but info not found")
@router.post("/restore")
def restore_database(
payload: dict,
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Restore database from a specific file. DANGEROUS."""
filename = payload.get("filename")
confirm = payload.get("confirm", False)
if not filename:
raise HTTPException(status_code=400, detail="Filename required")
if not confirm:
raise HTTPException(status_code=400, detail="Confirmation required")
try:
success = DbManager.restore_backup(filename, db, user_id=current_admin.sub)
return {"status": "success", "message": f"Database restored from {filename}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/settings", response_model=schemas.DbSettingsUpdate)
def get_db_settings(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Get database retention and scheduling settings."""
# Ensure default settings exist
retention = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first()
hour = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_hour").first()
freq = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_freq_days").first()
return {
"retention_count": int(retention.value) if retention else 10,
"schedule_hour": int(hour.value) if hour else 3,
"schedule_freq_days": int(freq.value) if freq else 1
}
@router.patch("/settings", response_model=schemas.DbSettingsUpdate)
def update_db_settings(
settings: schemas.DbSettingsUpdate,
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Update database settings and re-trigger scheduler sync."""
pairs = {
"backup_retention_count": str(settings.retention_count),
"backup_schedule_hour": str(settings.schedule_hour),
"backup_schedule_freq_days": str(settings.schedule_freq_days)
}
for key, val in pairs.items():
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first()
if existing:
existing.value = val
else:
db.add(models.SystemSetting(key=key, value=val))
db.commit()
# Re-trigger scheduler sync
sync_scheduler_config()
return settings
@router.get("/export")
def export_database(
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Download the current database file."""
try:
path = DbManager.export_db()
return FileResponse(
path,
media_type="application/x-sqlite3",
filename="inventory_export.db"
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/import")
async def import_database(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Upload and replace the current database. DANGEROUS."""
contents = await file.read()
try:
DbManager.import_db(contents, db, user_id=current_admin.sub)
return {"status": "success", "message": "Database successfully imported and replaced."}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
import os
from ..database import BASE_DIR
PROJECT_ROOT = os.path.dirname(BASE_DIR)
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
@router.get("/settings/prompt")
def get_ai_prompt(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Get the current AI extraction prompt (prioritizes config file)."""
# 1. Try file first
if os.path.exists(PROMPT_FILE_PATH):
try:
with open(PROMPT_FILE_PATH, 'r', encoding='utf-8') as f:
return {"value": f.read().strip(), "source": "file"}
except Exception as e:
print(f"Error reading prompt file: {e}")
# 2. Fallback to DB
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
if not setting:
return {"value": "", "source": "none"}
return {"value": setting.value, "source": "database"}
@router.post("/settings/prompt")
def update_ai_prompt(
payload: dict,
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Update the AI extraction prompt (writes to both file and DB)."""
value = payload.get("value")
if value is None:
raise HTTPException(status_code=400, detail="Value required")
# 1. Update File (Primary)
try:
os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True)
with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f:
f.write(value)
except Exception as e:
print(f"Failed to write prompt file: {e}")
# We continue to DB update anyway
# 2. Update DB (Backup/Sync)
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
if existing:
existing.value = value
else:
db.add(models.SystemSetting(key="ai_extraction_prompt", value=value))
db.commit()
return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}

65
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,65 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from backend.database import Base, get_db
from backend.main import app
from backend.auth import get_current_admin, TokenData
from datetime import datetime, timezone
# Use in-memory SQLite for tests
SQLALCHEMY_DATABASE_URL = "sqlite://"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function", autouse=True)
def mock_scheduler():
from backend.scheduler import scheduler
if scheduler.running:
scheduler.shutdown()
yield
if scheduler.running:
scheduler.shutdown()
@pytest.fixture(scope="function")
def db():
Base.metadata.create_all(bind=engine)
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db):
def override_get_db():
try:
yield db
finally:
pass
# Mock admin user with valid TokenData
def override_get_current_admin():
return TokenData(
sub=1,
username="admin",
role="admin",
exp=datetime.now(timezone.utc)
)
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_admin] = override_get_current_admin
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()

View File

@@ -0,0 +1,35 @@
import pytest
def test_get_backups(client):
response = client.get("/admin/db/backups")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_db_settings_workflow(client):
# GET settings
response = client.get("/admin/db/settings")
assert response.status_code == 200
data = response.json()
assert "retention_count" in data
# PATCH settings
new_settings = {
"retention_count": 25,
"schedule_hour": 5,
"schedule_freq_days": 2
}
response = client.patch("/admin/db/settings", json=new_settings)
assert response.status_code == 200
assert response.json()["retention_count"] == 25
# Verify persistence
response = client.get("/admin/db/settings")
assert response.json()["retention_count"] == 25
def test_ai_config(client):
response = client.get("/admin/db/settings/ai")
assert response.status_code == 200
data = response.json()
assert "active_provider" in data
assert "providers" in data
assert len(data["providers"]) == 2

View File

@@ -1,163 +1,72 @@
# CURRENT AI WORKING SESSION — HANDOVER
**Active AI:** Claude (Haiku)
**Active AI:** Antigravity (Gemini 2.0)
**Last Updated:** 2026-04-15
**Current Version:** v1.9.21 (Docker-Ready)
**Branch:** dev (fixes committed, ready for merge to master)
**Current Version:** v1.10.0 (ModularAdmin)
**Branch:** master (Refactor complete & Merged)
---
## STATUS: 🟢 STABLE — DOCKER BUILD VERIFIED & FULLY TESTED LOCALLY
## STATUS: 🟢 STABLE — MODULAR REFACTORING 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:** The monolithic Admin architecture has been successfully modularized. Frontend logic is now encapsulated in `useAdmin` hook, UI is decoupled into standalone components, and backend routers are split by domain. Dual-layer testing (Pytest/Vitest) is active.
### 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.
### v1.10.0 Accomplishments (ModularAdmin)
- **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
#### 1. Frontend Modularization
- **Modular Components**: `AdminPage` decomposed into:
- `IdentityManager.tsx` (User/RBAC management)
- `DatabaseManager.tsx` (Backups, Stats, Restore)
- `LdapManager.tsx` (Enterprise Auth config)
- `AiManager.tsx` (Gemini/Claude configuration)
- `CategoryManager.tsx` (Asset grouping)
- **Centralized Logic**: Created `useAdmin.ts` hook to manage the dashboard state, API calls, and validation logic.
- **UI Polish**:
- Standardized all components to Title Case (NO `uppercase` / `tracking-widest` violations).
- Expanded `CategoryManager` to full-width and 4-column layout for better visibility.
- Removed all `truncate` classes from category labels to ensure full name display.
#### 2. Backend Restructuring
- **Domain Routers**: `backend/routers/admin/` now contains:
- `backups.py`: Dedicated logic for database maintenance.
- `config.py`: Core system configuration and AI provider settings.
- **Improved Maintainability**: Replaced the monolithic `admin_db.py` with specific, testable routers.
#### 3. Verification Suite
- **Backend (Pytest)**: Integrated suite in `backend/tests/` covering admin workflows using an in-memory database.
- **Frontend (Vitest)**: Unit tests for `useAdmin` hook in `frontend/tests/hooks/`.
---
## 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
### 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
### 3. Committed Changes
```
Commit: 65b24079
Message: "fix: resolve TypeScript build error in AIOnboarding and Scanner components"
Branch: dev
```
### 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)
### 5. **FULLY TESTED DOCKER BUILD & DEPLOYMENT**
#### ✅ Frontend Build
- Commit: `65b24079`
- Result: **SUCCESS** - `✓ Compiled successfully in 20.4s`
- TypeScript errors: **NONE**
- Status: **VERIFIED WORKING**
#### ✅ 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**
#### ✅ 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**
#### 📋 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)
```
### 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
1. **[x] Code Refactoring**: Split monolithic files into domain-aligned modules.
2. **[x] UI Sanitization**: Removed all typography violations and fixed layout issues for Category Groups.
3. **[x] Documentation**: Updated `PROJECT_ARCHITECTURE.md` and `README.md` with new architectural details and testing guides.
4. **[x] Branch Management**: Merged `refactor/modular-architecture-v1` into `dev` and `master`.
5. **[x] Release**: Bounced version to `1.10.0` and generated `aInventory-PROD-v1.10.0.zip`.
---
## WHAT THE NEXT AI MUST DO
### Immediate (Critical Path to Production)
1. **Push to remote repository:**
```bash
git push origin dev
```
### 1. Maintain Modular Pattern
- Any additions to the Admin Dashboard should be implemented as new sub-components in `frontend/components/admin/`.
- Backend logic for admin tasks must be placed in `backend/routers/admin/`.
- NEVER use `uppercase` or `tracking-widest` in the UI (Project Design Rule).
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
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
### 2. Testing Discipline
- Run `PYTHONPATH=. ./backend/venv/bin/pytest backend/tests/` after logic changes.
- Ensure `npm run test` passes for frontend updates.
---
## SYSTEM STATE
**Active database:** `<project_root>/data/inventory.db`
**Current Version:** `v1.9.21`
**Production Bundle:** `aInventory-PROD-v1.9.20.zip` (update pending successful Docker verification)
**Git Branch:** `dev` (fixes committed, not yet merged to master)
**Current Version:** `v1.10.0`
**Production Bundle:** `aInventory-PROD-v1.10.0.zip`
**Git Branch:** `master`
**How to start development server:**
```bash
./start_server.sh
```
**How to test Docker (local):**
```bash
docker-compose build frontend --no-cache
docker-compose up -d
docker-compose ps
```
**How to deploy (remote server):**
```bash
cd /data/docker/aInventory
git pull origin dev
./deploy.sh --reset-ssl
```
---
✓ Done.
**Active AI Tools:**
- **Git Binary:** `/Library/Developer/CommandLineTools/usr/bin/git` (Bypasses xcode-select errors).
- **Environment:** Use `./backend/venv/` for python tasks.

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"version": "1.9.21",
"last_build": "2026-04-14-2300",
"codename": "TSFix",
"commit": "manual"
"version": "1.10.7",
"last_build": "2026-04-15-1940",
"codename": "AccessibleUI",
"commit": "be251c8a"
}

File diff suppressed because it is too large Load Diff

View File

@@ -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,14 +248,14 @@ 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} />
</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>
<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-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Inventory Catalog</h1>
<p className="text-xs md:text-sm text-slate-400 font-bold mt-1 uppercase tracking-widest">Enterprise Stock Overview</p>
</div>
<button
onClick={() => setShowBoxManager(true)}
className="ml-auto p-3 bg-slate-900 border border-slate-800 text-slate-400 rounded-2xl hover:text-primary transition-all active:scale-95 shadow-xl"
@@ -263,58 +265,58 @@ 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">
<div className="w-10 h-10 rounded-2xl bg-primary/10 flex items-center justify-center text-primary transition-colors">
<Layers size={20} />
</div>
<div className="text-left">
<h3 className="font-bold text-lg">{cat}</h3>
<p className="text-[9px] font-black text-slate-400">
{inventory.filter(i => i.category === cat).length} Item types in stock
</p>
</div>
<div className="text-left">
<h3 className="font-bold text-lg">{cat}</h3>
<p className="text-xs font-bold text-slate-400 mt-1 uppercase tracking-tight">
{inventory.filter(i => i.category === cat).length} Item types in stock
</p>
</div>
</div>
<div className="flex items-center gap-3">
{expandedCategory === cat ? <ChevronDown size={20} className="text-primary" /> : <ChevronRight size={20} className="text-slate-600" />}
@@ -351,20 +353,20 @@ export default function InventoryPage() {
<div className="w-8 h-8 rounded-xl bg-green-500/10 flex items-center justify-center text-green-500 shrink-0">
<Package size={14} />
</div>
<div className="truncate">
<h4 className="font-bold text-slate-200 truncate">{item.name}</h4>
<p className="text-[10px] text-slate-500 truncate mt-0.5">{item.specs}</p>
</div>
<div className="truncate">
<h4 className="font-bold text-slate-200 truncate">{item.name}</h4>
<p className="text-xs text-slate-400 truncate mt-1">{item.specs}</p>
</div>
</div>
<div className="text-right shrink-0">
<span className={cn(
"text-lg font-black",
item.quantity <= item.min_quantity ? "text-amber-500" : "text-primary"
)}>
{item.quantity}
</span>
<p className="text-xs text-slate-600 font-bold">Stock</p>
</div>
{item.quantity}
</span>
<p className="text-sm text-slate-500 font-bold uppercase tracking-tight">Stock</p>
</div>
</div>
))}
</div>
@@ -383,16 +385,16 @@ 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}
{!isEditing && (
<span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md font-black uppercase">
In Stock: {selectedItem.quantity}
</span>
)}
{isEditing ? "Edit Item" : selectedItem.name}
{!isEditing && (
<span className="text-xs bg-slate-800 text-slate-300 px-3 py-1 rounded-lg font-bold uppercase tracking-tight">
In Stock: {selectedItem.quantity}
</span>
)}
</h3>
<div className="flex gap-2">
{!isEditing && (
@@ -429,8 +431,8 @@ export default function InventoryPage() {
{isEditing ? (
<div className="max-h-[60vh] overflow-y-auto pr-2 space-y-4 mb-6 scrollbar-hide">
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Item Name</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Item Name</label>
<input
type="text"
value={editedItem.name || ''}
@@ -438,8 +440,8 @@ export default function InventoryPage() {
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700"
/>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Part Number</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Part Number</label>
<input
type="text"
value={editedItem.part_number || ''}
@@ -448,8 +450,8 @@ export default function InventoryPage() {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Category</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Category</label>
<input
type="text"
list="existing-categories"
@@ -464,8 +466,8 @@ export default function InventoryPage() {
))}
</datalist>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Item Type</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Item Type</label>
<input
type="text"
list="existing-types"
@@ -474,8 +476,8 @@ export default function InventoryPage() {
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100"
/>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Connector</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Connector</label>
<input
type="text"
value={editedItem.connector || ''}
@@ -484,8 +486,8 @@ export default function InventoryPage() {
placeholder="e.g. LC/UPC"
/>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Size / Length</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Size / Length</label>
<input
type="text"
value={editedItem.size || ''}
@@ -494,8 +496,8 @@ export default function InventoryPage() {
placeholder="e.g. 1.6TB"
/>
</div>
<div className="col-span-2">
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Item Color</label>
<div className="col-span-2">
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Item Color</label>
<input
type="text"
value={editedItem.color || ''}
@@ -504,8 +506,8 @@ export default function InventoryPage() {
placeholder="e.g. Blue"
/>
</div>
<div className="col-span-2">
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Box / Container Label</label>
<div className="col-span-2">
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Box / Container Label</label>
<div className="relative flex items-center">
<input
type="text"
@@ -532,16 +534,16 @@ export default function InventoryPage() {
</div>
</div>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Specs / Comments</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Specs / Comments</label>
<textarea
value={editedItem.specs || ''}
onChange={e => setEditedItem({...editedItem, specs: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100 h-20 resize-none"
/>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">OCR Matching Key</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">OCR Matching Key</label>
<textarea
value={editedItem.ocr_text || ''}
onChange={e => setEditedItem({...editedItem, ocr_text: e.target.value})}
@@ -565,9 +567,9 @@ export default function InventoryPage() {
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-slate-500"
)}
>
<t.icon size={20} className={adjustType === t.id ? t.color : ""} />
<span className="text-xs font-black mt-1">{t.label}</span>
</button>
<t.icon size={20} className={adjustType === t.id ? t.color : ""} />
<span className="text-sm font-bold mt-1.5 uppercase tracking-tight">{t.label}</span>
</button>
))}
</div>
@@ -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" :
@@ -639,8 +641,8 @@ export default function InventoryPage() {
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-black text-slate-500 ml-1">Name</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Name</label>
<input
type="text"
value={catEditedName}
@@ -648,8 +650,8 @@ export default function InventoryPage() {
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
/>
</div>
<div>
<label className="text-xs font-black text-slate-500 ml-1">Description</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 uppercase tracking-tight">Description</label>
<textarea
value={catEditedDesc}
onChange={e => setCatEditedDesc(e.target.value)}
@@ -734,18 +736,18 @@ export default function InventoryPage() {
<p className="text-xs text-slate-500 font-bold mt-0.5">{itemCount} items linked</p>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => setSelectedBoxLabel(box)}
className="flex-1 py-3 bg-primary text-white text-[11px] font-black rounded-xl flex items-center justify-center gap-2 hover:bg-blue-500 transition-colors shadow-lg shadow-primary/10 active:scale-95"
>
<Printer size={14} /> Print Label
</button>
<button
onClick={() => { setSearchQuery(box); setShowBoxManager(false); setBoxSearchQuery(''); }}
className="px-4 py-3 bg-slate-900 border border-slate-800 text-slate-400 text-[11px] font-bold rounded-xl hover:bg-slate-800 active:scale-95"
>
View
</button>
<button
onClick={() => setSelectedBoxLabel(box)}
className="flex-1 py-3 bg-primary text-white text-xs font-black rounded-xl flex items-center justify-center gap-2 hover:bg-blue-500 transition-colors shadow-lg shadow-primary/10 active:scale-95"
>
<Printer size={14} /> Print Label
</button>
<button
onClick={() => { setSearchQuery(box); setShowBoxManager(false); setBoxSearchQuery(''); }}
className="px-4 py-3 bg-slate-900 border border-slate-800 text-slate-400 text-xs font-bold rounded-xl hover:bg-slate-800 active:scale-95"
>
View
</button>
</div>
</div>
);
@@ -755,9 +757,9 @@ export default function InventoryPage() {
</div>
<div className="p-6 py-4 bg-slate-950/50 border-t border-slate-800 text-center flex items-center justify-center gap-2">
<div className="w-1 h-1 rounded-full bg-primary animate-pulse" />
<p className="text-[10px] text-slate-500 font-bold font-mono">TFM aInventory Box management mode</p>
</div>
<div className="w-1 h-1 rounded-full bg-primary animate-pulse" />
<p className="text-xs text-slate-400 font-bold font-mono">TFM aInventory Box management mode</p>
</div>
</div>
</div>
)}
@@ -773,11 +775,10 @@ export default function InventoryPage() {
</h2>
<div className="w-full aspect-[2/1] bg-white flex flex-col items-center justify-center overflow-hidden" dangerouslySetInnerHTML={{ __html: generateBarcode128(selectedBoxLabel) }} />
<div className="flex flex-col items-center gap-1">
<p className="text-[10px] font-black tracking-[0.2em] text-black/50">TFM INVENTORY BOX LABEL</p>
<img src={getQRCodeURL(selectedBoxLabel)} className="w-24 h-24" alt="QR Code" />
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-xs font-black tracking-[0.2em] text-black/50">TFM INVENTORY BOX LABEL</p>
<img src={getQRCodeURL(selectedBoxLabel)} className="w-24 h-24" alt="QR Code" />
</div>
</div>
<div className="flex flex-col gap-3 no-print">

View File

@@ -124,7 +124,7 @@ export default function LoginPage() {
<div className="pt-2 grid grid-cols-2 gap-3">
<button
onClick={() => setIsEnterprise(true)}
className="flex items-center justify-center gap-2 py-4 rounded-2xl border border-dashed border-slate-700 text-slate-500 hover:text-white hover:border-slate-500 transition-all font-bold text-[10px]"
className="flex items-center justify-center gap-2 py-4 rounded-2xl border border-dashed border-slate-700 text-slate-500 hover:text-white hover:border-slate-500 transition-all font-bold text-xs"
>
<Lock size={12} />
Enterprise
@@ -134,7 +134,7 @@ export default function LoginPage() {
setSelectedUserForLogin({ username: '' });
// We use an empty username object to trigger the manual input view
}}
className="flex items-center justify-center gap-2 py-4 rounded-2xl border border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 transition-all font-bold text-[10px]"
className="flex items-center justify-center gap-2 py-4 rounded-2xl border border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 transition-all font-bold text-xs"
>
<User size={12} />
Manual Login

View File

@@ -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-xs md:text-sm text-slate-400 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-xs 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-xs font-bold 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-400 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-xs font-bold 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-400 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-xs 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-[10px] font-black px-3 py-1.5 rounded-lg border min-w-[85px] text-center uppercase tracking-tight",
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-base 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-[11px] font-bold text-slate-400 uppercase tracking-tight shrink-0">{log.username || 'System'}</span>
<span className="w-1 h-1 rounded-full bg-slate-800 shrink-0" />
<span className="text-[11px] text-slate-400 font-medium 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-xs font-bold px-4 py-1.5 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-[11px] font-bold text-slate-500 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-[11px] font-bold text-slate-500 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-[11px] font-bold text-slate-500 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,17 +295,17 @@ 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>
(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-[10px] font-bold text-slate-500 uppercase mb-1 tracking-tight opacity-70">{key.replace('_', ' ')}</p>
<p className="text-xs font-bold text-slate-300 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-[11px] font-bold text-slate-500 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-sm font-medium 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>

View File

@@ -459,21 +459,8 @@ export default function Home() {
/>
</div>
<div>
<h1 className="text-lg md:text-xl font-black tracking-normal text-white leading-none">
TFM <span className="font-mono text-primary bg-primary/5 px-2 py-0.5 rounded-lg border border-primary/10 ml-1">aInventory</span>
</h1>
<div className="flex items-center gap-2 mt-1">
<p className="text-xs font-bold text-slate-500 font-mono">
Version {versionData.version}
</p>
<span className="w-1 h-1 rounded-full bg-slate-800" />
<button
className="text-xs font-black text-primary/80 hover:text-primary transition-colors flex items-center gap-1"
>
<User size={8} />
{currentUser?.username || 'Guest'}
</button>
</div>
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Admin Control</h1>
<p className="text-xs md:text-sm text-slate-400 font-bold tracking-tight mt-1 uppercase tracking-widest leading-relaxed">System Configuration & Security</p>
</div>
</div>
@@ -482,14 +469,14 @@ export default function Home() {
{isScannerReady && (
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]" />
<span className="text-[10px] sm:text-xs font-black text-green-500/90 whitespace-nowrap">
<span className="text-xs font-black text-green-500/90 whitespace-nowrap">
Scanner: OK
</span>
</div>
)}
<div className="flex items-center gap-2">
<div className={`w-1.5 h-1.5 rounded-full ${isOnline ? 'bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.6)]'}`} />
<span className={`text-[10px] sm:text-xs font-black whitespace-nowrap ${isOnline ? 'text-green-500/90' : 'text-rose-500/90'}`}>
<span className={`text-xs font-black whitespace-nowrap ${isOnline ? 'text-green-500/90' : 'text-rose-500/90'}`}>
Sync: {isOnline ? 'Active' : 'Offline'}
</span>
</div>
@@ -534,11 +521,11 @@ export default function Home() {
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">scanning...</h2>
<button
<button
onClick={() => setShowScanner(false)}
className="text-sm text-slate-400 hover:text-white"
className="p-2.5 bg-slate-900 border border-slate-800 text-slate-400 rounded-xl hover:text-rose-500 transition-all active:scale-95"
>
Cancel
<X size={18} />
</button>
</div>
<Scanner onScanSuccess={onScanSuccess} onOCRMatch={onOCRMatch} />
@@ -552,14 +539,7 @@ export default function Home() {
<Smartphone className="w-10 h-10 text-primary group-hover:scale-110 transition-transform" />
</button>
<div>
<p className="text-lg font-medium">tap to start scanning</p>
<p className="text-sm text-slate-400">Scan labels to {mode.replace('_', ' ')} items</p>
</div>
<div className="w-full h-px bg-slate-800/50 my-2" />
<div className="w-full flex justify-center">
<div className="w-full flex justify-center mt-4">
<button
onClick={() => setShowOnboarding(true)}
className="w-full flex flex-col items-center justify-center p-8 rounded-[2rem] bg-indigo-500/5 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-black text-indigo-400 gap-4"
@@ -596,7 +576,7 @@ export default function Home() {
<h3 className="text-xl font-black tracking-tight flex items-center gap-2">
{isEditing ? "Edit Metadata" : selectedItem.name}
{!isEditing && (
<span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md font-black uppercase tracking-tight shadow-sm border border-slate-700/50">
<span className="text-[11px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md font-black uppercase tracking-tight shadow-sm border border-slate-700/50">
In Stock: {selectedItem.quantity}
</span>
)}
@@ -621,6 +601,16 @@ export default function Home() {
<Trash2 size={20} />
</button>
)}
<button
onClick={() => {
setSelectedUserForLogin({ username: '' });
// We use an empty username object to trigger the manual input view
}}
className="flex items-center justify-center gap-2 py-4 rounded-2xl border border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 transition-all font-bold text-xs"
>
<User size={12} />
Manual Login
</button>
<button
onClick={() => {
setSelectedItem(null);
@@ -635,18 +625,19 @@ export default function Home() {
{isEditing ? (
<div className="space-y-4 mb-8">
<div>
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Item Name</label>
<input
type="text"
value={editedItem.name || ''}
onChange={e => setEditedItem({ ...editedItem, name: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100 placeholder-slate-700"
placeholder="e.g. Solid State Drive"
/>
<div>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-400 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Name</label>
<textarea
value={editedItem.name || ''}
onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
className="bg-transparent w-full text-lg font-bold outline-none text-white placeholder:text-slate-700 resize-none h-8 leading-tight selection:bg-primary/30 py-0"
placeholder="SSD, SFP, Cable..."
/>
</div>
</div>
<div>
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Part Number</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 tracking-tight">Part Number</label>
<input
type="text"
value={editedItem.part_number || ''}
@@ -656,8 +647,8 @@ export default function Home() {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Category Group</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 tracking-tight">Category Group</label>
<input
type="text"
list="existing-categories"
@@ -672,8 +663,8 @@ export default function Home() {
))}
</datalist>
</div>
<div>
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Item Type</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 tracking-tight">Item Type</label>
<input
type="text"
list="existing-types"
@@ -683,8 +674,8 @@ export default function Home() {
placeholder="e.g. spare parts"
/>
</div>
<div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label>
<div className="col-span-2">
<label className="text-sm font-bold text-slate-400 ml-1">Box / Container Label</label>
<div className="relative flex items-center">
<input
type="text"
@@ -710,8 +701,8 @@ export default function Home() {
</button>
</div>
</div>
<div>
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Connector</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 tracking-tight">Connector</label>
<input
type="text"
value={editedItem.connector || ''}
@@ -720,8 +711,8 @@ export default function Home() {
placeholder="e.g. LC/UPC"
/>
</div>
<div>
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Size / Length</label>
<div>
<label className="text-sm font-bold text-slate-400 ml-1 tracking-tight">Size / Length</label>
<input
type="text"
value={editedItem.size || ''}
@@ -730,8 +721,8 @@ export default function Home() {
placeholder="e.g. 5m / 1600GB"
/>
</div>
<div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Item Color</label>
<div className="col-span-2">
<label className="text-sm font-bold text-slate-400 ml-1 tracking-tight">Item Color</label>
<input
type="text"
value={editedItem.color || ''}
@@ -740,8 +731,8 @@ export default function Home() {
placeholder="e.g. Black"
/>
</div>
<div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">Description</label>
<div className="col-span-2">
<label className="text-sm font-bold text-slate-400 ml-1">Description</label>
<textarea
value={editedItem.description || ''}
onChange={e => setEditedItem({ ...editedItem, description: e.target.value })}
@@ -749,8 +740,8 @@ export default function Home() {
placeholder="Item description..."
/>
</div>
<div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">OCR Matching Key</label>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-400 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">OCR Matching Key</label>
<textarea
value={editedItem.ocr_text || ''}
onChange={e => setEditedItem({ ...editedItem, ocr_text: e.target.value })}
@@ -792,8 +783,9 @@ export default function Home() {
<Minus size={24} />
</button>
<div className="text-center">
<span className="text-5xl font-black tabular-nums">{adjustQty}</span>
</div>
<span className="text-xs font-black tabular-nums">{zoom.toFixed(1)}x</span>
<span className="text-[10px] text-primary/80 font-bold uppercase tracking-tight">Zoom</span>
</div>
<button
onClick={() => setAdjustQty(adjustQty + 1)}
className="w-12 h-12 rounded-full border border-slate-800 flex items-center justify-center text-slate-400 active:bg-slate-800"
@@ -876,23 +868,19 @@ export default function Home() {
className="w-full text-left bg-slate-950/50 hover:bg-slate-800 border border-slate-800/80 p-4 rounded-2xl flex items-center gap-4 transition-all active:scale-[0.98] group"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-black text-white group-hover:text-primary transition-colors truncate">{item.name}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-mono text-slate-500">{item.part_number}</span>
<span className="w-1 h-1 rounded-full bg-slate-800" />
<span className="text-[10px] font-bold text-slate-400">Stock: {item.quantity}</span>
</div>
<p className="text-sm font-black text-white italic text-center">Protocol Detected</p>
<p className="text-xs text-slate-400 font-bold uppercase tracking-widest text-center mt-1">Select identity from label matrix</p>
</div>
<ChevronRight size={18} className="text-slate-600 group-hover:text-primary" />
</button>
))}
</div>
<button
onClick={() => setBoxMatches([])}
className="w-full py-4 mt-2 rounded-[1.5rem] font-black text-sm bg-slate-800 text-white hover:bg-slate-700 transition-colors shrink-0"
>
Cancel
</button>
<button
onClick={handleLogin}
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-xs tracking-widest border border-primary/20"
>
Initialize Session
</button>
</div>
</div>
)}
@@ -902,7 +890,7 @@ export default function Home() {
<footer className="mt-20 mb-8 flex flex-col items-center gap-2 opacity-30">
<p className="text-xs font-bold">Powered by TFM Group Software</p>
<div className="h-px w-12 bg-slate-800" />
<p className="text-[9px] font-mono">v{versionData.version} {versionData.last_build} BUILD: dev-{(versionData as any).commit || 'N/A'}</p>
<p className="text-[11px] font-mono text-slate-500">v{versionData.version} {versionData.last_build} BUILD: dev-{(versionData as any).commit || 'N/A'}</p>
</footer>
</div>
</PageShell>

View File

@@ -321,7 +321,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</div>
<div className="absolute top-6 left-1/2 -translate-x-1/2 bg-black/60 backdrop-blur-md px-4 py-1.5 rounded-full border border-white/10">
<span className="text-[10px] font-black text-white uppercase tracking-widest flex items-center gap-2">
<span className="text-xs font-black text-white uppercase tracking-widest flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
Live Viewfinder
</span>
@@ -395,7 +395,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Name</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Name</label>
<textarea
value={extractedItems[editingIndex].Item || extractedItems[editingIndex].name || ''}
onChange={(e) => updateEditingItem({ Item: e.target.value })}
@@ -406,7 +406,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-900/80 py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Category</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Category</label>
<input
type="text"
list="onboarding-categories"
@@ -422,7 +422,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</datalist>
</div>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Type</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Type</label>
<input
value={extractedItems[editingIndex].Type || extractedItems[editingIndex].type || ''}
list="onboarding-types"
@@ -438,7 +438,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-900/80 py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Color</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Color</label>
<input
value={extractedItems[editingIndex].Color || extractedItems[editingIndex].color || ''}
onChange={(e) => updateEditingItem({ Color: e.target.value })}
@@ -447,7 +447,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
/>
</div>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Box / Container</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Box / Container</label>
<input
value={extractedItems[editingIndex].box_label || ''}
list="onboarding-boxes"
@@ -462,7 +462,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</div>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Description</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Description</label>
<textarea
value={extractedItems[editingIndex].Description || extractedItems[editingIndex].description || ''}
onChange={(e) => updateEditingItem({ Description: e.target.value })}
@@ -473,7 +473,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-900/80 py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Connector</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Connector</label>
<input
value={extractedItems[editingIndex].Connector || extractedItems[editingIndex].connector || ''}
onChange={(e) => updateEditingItem({ Connector: e.target.value })}
@@ -482,7 +482,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
/>
</div>
<div className="bg-slate-900/80 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Size / Length</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Size / Length</label>
<input
value={extractedItems[editingIndex].Size || extractedItems[editingIndex].size || ''}
onChange={(e) => updateEditingItem({ Size: e.target.value })}
@@ -493,7 +493,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</div>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">OCR Matching Key</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">OCR Matching Key</label>
<textarea
value={extractedItems[editingIndex].OCR || extractedItems[editingIndex].ocr_text || ''}
onChange={(e) => updateEditingItem({ OCR: e.target.value })}
@@ -504,7 +504,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
<div className="grid grid-cols-2 gap-2">
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Part Number</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Part Number</label>
<input
value={extractedItems[editingIndex].PartNr || extractedItems[editingIndex].part_number || ''}
onChange={(e) => updateEditingItem({ PartNr: e.target.value })}
@@ -513,7 +513,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
/>
</div>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors uppercase tracking-tighter">Initial Qty</label>
<label className="text-xs text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors uppercase tracking-tighter">Initial Qty</label>
<input
type="number"
value={extractedItems[editingIndex].quantity || 1}
@@ -559,10 +559,10 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
<h4 className="font-black text-slate-100 truncate">{item.Item || item.name || "Unknown Item"}</h4>
</div>
<div className="flex gap-2">
<span className="text-[10px] font-black uppercase px-2 py-0.5 bg-slate-800 text-slate-400 rounded-md tracking-wider">
<span className="text-xs font-black uppercase px-2 py-0.5 bg-slate-800 text-slate-400 rounded-md tracking-wider">
{item.Type || item.type || "Generic"}
</span>
<span className="text-[10px] font-black uppercase px-2 py-0.5 bg-slate-800 text-slate-500 rounded-md tracking-wider">
<span className="text-xs font-black uppercase px-2 py-0.5 bg-slate-800 text-slate-500 rounded-md tracking-wider">
{item.PartNr || item.part_number || "No P/N"}
</span>
</div>

View File

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

View File

@@ -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-xs 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-xs 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-xs 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-xs 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-xs 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-xs 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-xs 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-xs 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-xs uppercase tracking-[0.2em]">
Synchronizing User Tokens...
</div>
)}
</div>

View File

@@ -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-xs 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-xs 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-xs 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-xs 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-xs 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-xs text-slate-600 font-black uppercase tracking-[0.15em]">
Optical Data Stream Active
</p>
</div>
</div>

View 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-base md:text-lg text-slate-300 font-semibold truncate">
{label}
</span>
</div>
<span className="text-2xl md:text-3xl font-black text-white whitespace-nowrap tabular-nums">
{value}
</span>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import React from 'react';
import { Brain, Cpu, Zap, Lock, RotateCcw, Wifi, FileText, Shield } from 'lucide-react';
import { cn } from '@/lib/utils';
interface AiManagerProps {
aiConfig: any;
handleUpdateAiProvider: (provider: string) => void;
aiKeys: { gemini: string; claude: string };
setAiKeys: (keys: any) => void;
isSavingKeys: boolean;
onSaveAiKeys: () => void;
isTestingKeys: { gemini: boolean; claude: boolean };
onTestAiKey: (provider: 'gemini' | 'claude') => void;
aiPrompt: string;
setAiPrompt: (prompt: string) => void;
isSavingPrompt: boolean;
onUpdatePrompt: () => void;
}
export default function AiManager({
aiConfig,
handleUpdateAiProvider,
aiKeys,
setAiKeys,
isSavingKeys,
onSaveAiKeys,
isTestingKeys,
onTestAiKey,
aiPrompt,
setAiPrompt,
isSavingPrompt,
onUpdatePrompt
}: AiManagerProps) {
return (
<section className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/ai">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<Brain size={20} />
</div>
<h2 className="text-xl font-black text-white tracking-tight">AI Intelligence</h2>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
{aiConfig?.providers?.map((p: any) => (
<button
key={p.id}
onClick={() => handleUpdateAiProvider(p.id)}
className={cn(
"p-4 rounded-2xl border transition-all text-left flex items-center justify-between group",
p.active
? "bg-indigo-600 border-indigo-500 shadow-xl shadow-indigo-600/10"
: "bg-slate-950/60 border-slate-800 hover:border-indigo-500/40"
)}
>
<div className="flex items-center gap-3">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center transition-colors",
p.active ? "bg-white/20 text-white" : "bg-indigo-500/10 text-indigo-400 group-hover:bg-indigo-500/20"
)}>
{p.id === 'gemini' ? <Cpu size={16} /> : <Zap size={16} />}
</div>
<div>
<p className={cn("text-xs font-black tracking-tight", p.active ? "text-white" : "text-slate-200")}>{p.name}</p>
<p className={cn(
"text-xs font-bold mt-1 px-0.5 rounded",
p.active
? (p.configured ? "text-emerald-200" : "text-rose-100")
: (p.configured ? "text-emerald-500" : "text-rose-500")
)}>
{p.configured ? "● Key Configured" : "○ Missing Api Key"}
</p>
</div>
</div>
{p.active && (
<div className="bg-white/20 px-2.5 py-1 rounded-full text-xs font-black text-white tracking-tight">
Active
</div>
)}
</button>
))}
</div>
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-[2rem] p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400"><Lock size={14} /></div>
<h3 className="text-sm font-bold text-slate-200 tracking-tight">Provider Access Keys</h3>
</div>
<button
onClick={onSaveAiKeys}
disabled={isSavingKeys}
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-black transition-all active:scale-95 shadow-xl shadow-indigo-600/10 tracking-tight border border-indigo-500/30 flex items-center gap-2"
>
{isSavingKeys ? <RotateCcw size={14} className="animate-spin" /> : <Lock size={14} />}
{isSavingKeys ? "Storing..." : "Store Keys"}
</button>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Gemini Api Key</label>
<div className="flex gap-2">
<input
type="password"
value={aiKeys.gemini}
onChange={(e) => setAiKeys({...aiKeys, gemini: e.target.value})}
placeholder={aiConfig?.providers?.find((p: any) => p.id === 'gemini')?.masked_key || "Enter Gemini Key..."}
className="flex-1 bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs text-white outline-none focus:border-indigo-500/50 transition-all placeholder:text-slate-600"
/>
<button
onClick={() => onTestAiKey('gemini')}
disabled={isTestingKeys.gemini}
className={cn(
"px-4 py-2 rounded-xl text-xs font-bold tracking-tight transition-all border shadow-lg flex items-center gap-1.5",
isTestingKeys.gemini
? "bg-slate-800 border-slate-700 text-slate-500 cursor-wait"
: "bg-indigo-600 border-indigo-500 text-white hover:bg-indigo-500"
)}
>
{isTestingKeys.gemini ? <RotateCcw size={12} className="animate-spin" /> : <Wifi size={12} />}
{isTestingKeys.gemini ? "..." : "Test"}
</button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Claude Api Key</label>
<div className="flex gap-2">
<input
type="password"
value={aiKeys.claude}
onChange={(e) => setAiKeys({...aiKeys, claude: e.target.value})}
placeholder={aiConfig?.providers?.find((p: any) => p.id === 'claude')?.masked_key || "Enter Claude Key..."}
className="flex-1 bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs text-white outline-none focus:border-indigo-500/50 transition-all placeholder:text-slate-600"
/>
<button
onClick={() => onTestAiKey('claude')}
disabled={isTestingKeys.claude}
className={cn(
"px-4 py-2 rounded-xl text-xs font-bold tracking-tight transition-all border shadow-lg flex items-center gap-1.5",
isTestingKeys.claude
? "bg-slate-800 border-slate-700 text-slate-500 cursor-wait"
: "bg-indigo-600 border-indigo-500 text-white hover:bg-indigo-500"
)}
>
{isTestingKeys.claude ? <RotateCcw size={12} className="animate-spin" /> : <Wifi size={12} />}
{isTestingKeys.claude ? "..." : "Test"}
</button>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<label className="text-sm font-bold text-slate-400 tracking-tight">System Prompt (Vision Extraction)</label>
<button
onClick={onUpdatePrompt}
disabled={isSavingPrompt}
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-black transition-all active:scale-95 shadow-xl shadow-indigo-600/10 tracking-tight border border-indigo-500/30 flex items-center gap-2"
>
{isSavingPrompt ? <RotateCcw size={14} className="animate-spin" /> : <FileText size={14} />}
{isSavingPrompt ? "Saving..." : "Save Prompt"}
</button>
</div>
<textarea
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl p-6 text-xs font-mono font-bold text-slate-300 leading-relaxed outline-none focus:border-purple-500/50 transition-all min-h-[200px] custom-scrollbar shadow-inner"
/>
</div>
<div className="bg-purple-500/5 border border-purple-500/10 rounded-2xl p-4 flex gap-4 items-start">
<div className="p-2 bg-purple-500/10 rounded-lg text-purple-400 shrink-0"><Shield size={14} /></div>
<p className="text-xs font-medium text-slate-400 leading-relaxed">
This prompt instructs the Vision AI core on label interpretation. Ensure it defines explicit mapping for technical attributes like <span className="text-purple-400">Item</span>, <span className="text-purple-400">Type</span>, and <span className="text-purple-400">Part Number</span> to avoid extraction null-pointers and ensure inventory data integrity.
</p>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { Layers, Plus, Edit2, Trash2, X } from 'lucide-react';
interface CategoryManagerProps {
categories: any[];
onAddCategory: () => void;
onDeleteCategory: (id: number, name: string) => void;
setEditingCategory: (cat: any) => void;
setEditCatForm: (form: any) => void;
editCatForm: any;
editingCategory: any;
onUpdateCategorySubmit: () => void;
}
export default function CategoryManager({
categories,
onAddCategory,
onDeleteCategory,
setEditingCategory,
setEditCatForm,
editCatForm,
editingCategory,
onUpdateCategorySubmit
}: CategoryManagerProps) {
return (
<section className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/categories">
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<Layers size={20} />
</div>
<h2 className="text-xl font-black text-white tracking-tight">Category Groups</h2>
</div>
<button
onClick={onAddCategory}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-xl text-sm font-black transition-all shadow-xl shadow-indigo-600/10 active:scale-95 border border-indigo-500/30 tracking-tight"
>
<Plus size={14} /> New Group
</button>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
{categories.map(cat => (
<div key={cat.id} className="p-4 bg-slate-950/40 border border-slate-800/50 rounded-2xl flex items-center justify-between group hover:border-primary/40 transition-all">
<div className="min-w-0 pr-4">
<p className="text-sm font-bold text-slate-200 group-hover:text-primary transition-colors">{cat.name}</p>
<p className="text-xs text-slate-500 font-medium mt-0.5">{cat.description || 'General storage'}</p>
</div>
<div className="flex gap-1 shrink-0">
<button
onClick={() => {
setEditingCategory(cat);
setEditCatForm({ name: cat.name, description: cat.description || '' });
}}
className="p-2 text-slate-600 hover:text-white hover:bg-slate-800 rounded-lg transition-all"
>
<Edit2 size={14} />
</button>
<button
onClick={() => onDeleteCategory(cat.id, cat.name)}
className="p-2 text-slate-600 hover:text-red-500 hover:bg-red-500/5 rounded-lg transition-all"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
{/* Edit Category Modal */}
{editingCategory && (
<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="w-full max-w-lg bg-slate-900 border-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-3xl p-6 sm:p-10 shadow-2xl space-y-8 overflow-hidden animate-in slide-in-from-bottom-10">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<Edit2 size={20} />
</div>
<h3 className="text-xl font-black text-white tracking-tight">Modify Group</h3>
</div>
<button onClick={() => setEditingCategory(null)} className="p-3 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800">
<X size={20} />
</button>
</div>
<div className="space-y-6">
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Group Identifier</label>
<input
type="text"
value={editCatForm.name}
onChange={(e) => setEditCatForm({...editCatForm, name: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3.5 px-5 text-sm text-white focus:border-primary outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Strategic Description</label>
<textarea
value={editCatForm.description}
onChange={(e) => setEditCatForm({...editCatForm, description: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-4 px-5 text-sm text-white focus:border-primary outline-none transition-all h-32 resize-none"
/>
</div>
<button onClick={onUpdateCategorySubmit} className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-black py-4 rounded-2xl shadow-xl shadow-indigo-600/20 active:scale-95 transition-all text-sm mb-4 tracking-tight">
Update Asset Group
</button>
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,178 @@
import React from 'react';
import { Database, RotateCcw, Download, Upload, Zap, History, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DatabaseManagerProps {
dbStats: any;
isBackingUp: boolean;
onCreateBackup: () => void;
dbSettings: any;
onUpdateDbSettings: (settings: any) => void;
backups: any[];
onRestore: (filename: string) => void;
onExport: () => void;
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function DatabaseManager({
dbStats,
isBackingUp,
onCreateBackup,
dbSettings,
onUpdateDbSettings,
backups,
onRestore,
onExport,
onImport
}: DatabaseManagerProps) {
const formatSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
return (
<div className="space-y-6 md:space-y-8 h-full">
<div className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl overflow-hidden relative group transition-all">
<div className="flex items-center gap-4 mb-6">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<Database size={20} />
</div>
<h2 className="text-xl font-black text-white tracking-tight">System Integrity</h2>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<div className="space-y-1 sm:pr-6">
<p className="text-xs font-bold text-slate-500 tracking-tight uppercase">Database Health</p>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.3)]" />
<span className="text-sm font-bold text-slate-200">Operational</span>
</div>
</div>
<div className="sm:pl-6 sm:border-l border-slate-800">
<p className="text-xs font-bold text-slate-500 tracking-tight uppercase">Last Backup</p>
<p className="text-sm font-bold text-slate-200 tabular-nums">{dbStats.backup_count > 0 ? 'Verified' : 'Pending...'}</p>
</div>
<div className="ml-auto">
<button
onClick={onCreateBackup}
disabled={isBackingUp}
className="flex items-center gap-2 px-5 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl shadow-indigo-600/10 tracking-tight"
>
{isBackingUp ? <RotateCcw size={14} className="animate-spin" /> : <Database size={14} />}
{isBackingUp ? "Snapshotting..." : "Force Backup"}
</button>
</div>
</div>
</div>
<div className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-8">
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<History size={20} />
</div>
<div>
<h3 className="text-lg font-black text-white tracking-tight">Storage Policy</h3>
<p className="text-xs text-slate-400 font-bold tracking-tight">Retention & Maintenance</p>
</div>
</div>
<div className="grid sm:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 tracking-tight ml-1 flex items-center gap-2 uppercase">
<History size={10} /> Max Backups
</label>
<input
type="number"
value={dbSettings.retention_count}
onChange={(e) => onUpdateDbSettings({...dbSettings, retention_count: parseInt(e.target.value)})}
className="w-full bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all tabular-nums"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 tracking-tight ml-1 flex items-center gap-2 uppercase">
<Clock size={10} /> Schedule (Hour)
</label>
<input
type="number"
min="0" max="23"
value={dbSettings.schedule_hour}
onChange={(e) => onUpdateDbSettings({...dbSettings, schedule_hour: parseInt(e.target.value)})}
className="w-full bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all tabular-nums"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 tracking-tight ml-1 flex items-center gap-2 uppercase">
<Zap size={10} /> Frequency (Days)
</label>
<input
type="number"
min="1"
value={dbSettings.schedule_freq_days}
onChange={(e) => onUpdateDbSettings({...dbSettings, schedule_freq_days: parseInt(e.target.value)})}
className="w-full bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all tabular-nums"
/>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<label className="text-xs font-bold text-slate-500 tracking-tight">Recovery Points</label>
<div className="text-xs font-bold text-indigo-400 tracking-tight bg-indigo-500/5 px-3 py-1 rounded-full border border-indigo-500/10">
{formatSize(dbStats.total_size_bytes)} Used
</div>
</div>
<div className="space-y-2 pr-2 overflow-y-auto max-h-[300px] scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
{backups.map((bak: any) => (
<div key={bak.filename} className="flex items-center justify-between p-3.5 bg-slate-950/40 border border-slate-800/40 rounded-2xl group/item hover:border-indigo-500/30 transition-all">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-slate-800 flex items-center justify-center text-slate-500">
<Database size={14} />
</div>
<div>
<p className="text-xs font-bold text-slate-200">{bak.filename}</p>
<p className="text-[11px] text-slate-400 font-medium tabular-nums">
{new Date(bak.created_at).toLocaleString()} {formatSize(bak.size_bytes)}
</p>
</div>
</div>
<button
onClick={() => onRestore(bak.filename)}
className="p-2 text-slate-600 hover:text-indigo-400 hover:bg-indigo-500/5 rounded-xl transition-all opacity-0 group-hover/item:opacity-100"
title="Restore this point"
>
<RotateCcw size={16} />
</button>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-2">
<button
onClick={onExport}
className="flex items-center justify-center gap-2 py-4 bg-slate-950 border border-slate-800 hover:border-indigo-500/50 text-indigo-400 rounded-2xl text-sm font-black transition-all active:scale-95 tracking-tight shadow-xl shadow-black/20"
>
<Download size={14} /> Export Database
</button>
<div className="relative">
<input
type="file"
accept=".db"
onChange={onImport}
className="absolute inset-0 opacity-0 cursor-pointer z-10"
/>
<button className="w-full flex items-center justify-center gap-2 py-4 bg-slate-950 border border-slate-800 hover:border-rose-500/50 text-rose-500 rounded-2xl text-sm font-black transition-all tracking-tight shadow-xl shadow-black/20">
<Upload size={14} /> Import Database
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { User, UserPlus, Shield, Edit2, Trash2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface IdentityManagerProps {
users: any[];
onAddUser: () => void;
onDeleteUser: (id: number, username: string) => void;
editingUser: any | null;
setEditingUser: (user: any | null) => void;
editUserForm: any;
setEditUserForm: (form: any) => void;
onUpdateUserSubmit: () => void;
}
export default function IdentityManager({
users,
onAddUser,
onDeleteUser,
editingUser,
setEditingUser,
editUserForm,
setEditUserForm,
onUpdateUserSubmit
}: IdentityManagerProps) {
return (
<div className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 flex flex-col shadow-2xl transition-all group/identity h-full">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<User size={20} />
</div>
<h2 className="text-xl font-black text-white tracking-tight">Identity Management</h2>
</div>
<button
onClick={onAddUser}
className="flex items-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-black transition-all shadow-xl shadow-indigo-600/10 active:scale-95 border border-indigo-500/30 tracking-tight"
>
<UserPlus size={16} /> Add User
</button>
</div>
<div className="flex-1 space-y-2.5 pr-2 -mr-2 overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
{users.map((user) => (
<div key={user.id} className="flex items-center justify-between p-3.5 bg-slate-950/40 border border-slate-800/40 rounded-2xl hover:border-primary/30 transition-all group">
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
"w-9 h-9 rounded-xl flex items-center justify-center transition-colors shadow-inner shrink-0",
user.role === 'admin' ? "bg-primary/10 text-primary border border-primary/20" : "bg-slate-800 text-slate-500 border border-slate-700"
)}>
{user.role === 'admin' ? <Shield size={16} /> : <User size={16} />}
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-slate-200 truncate">{user.username}</p>
<p className="text-[11px] text-slate-400 font-bold tracking-tight capitalize">{user.role}</p>
</div>
</div>
<div className="flex items-center gap-1.5 ml-2">
<button
onClick={() => {
setEditingUser(user);
setEditUserForm({ username: user.username, password: '', role: user.role });
}}
className="p-2 text-slate-600 hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
>
<Edit2 size={16} />
</button>
{user.username !== 'Admin' && (
<button
onClick={() => onDeleteUser(user.id, user.username)}
className="p-2 text-slate-600 hover:text-red-500 hover:bg-red-500/5 rounded-xl transition-all"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
))}
</div>
{/* Edit User Modal */}
{editingUser && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-slate-900 border border-slate-800 rounded-[2.5rem] p-8 w-full max-w-md shadow-2xl">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-white tracking-tight">Edit Identity</h3>
<button onClick={() => setEditingUser(null)} className="p-2 text-slate-500 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Username</label>
<input
type="text"
value={editUserForm.username}
onChange={(e) => setEditUserForm({ ...editUserForm, username: e.target.value })}
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-3 px-4 text-sm font-bold text-white outline-none focus:border-primary/50 transition-all font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">New Password (Leave Empty To Keep)</label>
<input
type="password"
value={editUserForm.password}
onChange={(e) => setEditUserForm({ ...editUserForm, password: e.target.value })}
placeholder="********"
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-3 px-4 text-sm font-bold text-white outline-none focus:border-primary/50 transition-all font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Role</label>
<select
value={editUserForm.role}
onChange={(e) => setEditUserForm({ ...editUserForm, role: e.target.value })}
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-3 px-4 text-sm font-bold text-white outline-none focus:border-primary/50 transition-all appearance-none"
>
<option value="user">Standard User</option>
<option value="admin">Administrator</option>
</select>
</div>
<button
onClick={onUpdateUserSubmit}
className="w-full bg-primary hover:bg-primary/80 text-white rounded-2xl py-4 text-sm font-black transition-all active:scale-95 shadow-xl shadow-primary/20 tracking-tight mt-4"
>
Apply Changes
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
import React from 'react';
import { Globe, Power, Server, Shield, User, Layers, Lock, Wifi, RotateCcw } from 'lucide-react';
import { cn } from '@/lib/utils';
interface LdapManagerProps {
ldapConfig: any;
setLdapConfig: (config: any) => void;
testingLdap: boolean;
onTestLdap: () => void;
onUpdateLdap: () => void;
}
export default function LdapManager({
ldapConfig,
setLdapConfig,
testingLdap,
onTestLdap,
onUpdateLdap
}: LdapManagerProps) {
return (
<div className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/ldap">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<Globe size={20} />
</div>
<h2 className="text-xl font-black text-white tracking-tight">Enterprise LDAP</h2>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-slate-950/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-indigo-500/30">
<div className="flex items-center gap-3">
<div className={cn(
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
ldapConfig.ldap_enabled ? "bg-green-500/10 text-green-500" : "bg-slate-800 text-slate-500"
)}>
<Power size={14} />
</div>
<div>
<p className="text-xs font-bold text-slate-200 tracking-tight leading-none">Ldap Authorization</p>
<p className={cn("text-[11px] font-bold mt-1.5", ldapConfig.ldap_enabled ? "text-green-500/80" : "text-slate-500")}>
{ldapConfig.ldap_enabled ? "External Directory Active" : "Internal Authentication Only"}
</p>
</div>
</div>
<button
onClick={() => setLdapConfig({...ldapConfig, ldap_enabled: !ldapConfig.ldap_enabled})}
className={cn(
"w-10 h-5 rounded-full relative transition-all duration-300 shadow-inner border",
ldapConfig.ldap_enabled ? "bg-indigo-600 border-indigo-500" : "bg-slate-800 border-slate-700"
)}
>
<div className={cn(
"absolute top-0.5 w-3.5 h-3.5 bg-white rounded-full transition-all shadow-sm",
ldapConfig.ldap_enabled ? "right-0.5" : "left-0.5"
)} />
</button>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Ldap Uri</label>
<div className="relative">
<Server className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-700" size={14} />
<input
type="text"
placeholder="ldap://host:389"
value={ldapConfig.server_uri}
onChange={(e) => setLdapConfig({...ldapConfig, server_uri: e.target.value})}
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-2.5 pl-10 pr-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Context Dn</label>
<div className="relative">
<Shield className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-700" size={14} />
<input
type="text"
placeholder="dc=example,dc=com"
value={ldapConfig.base_dn}
onChange={(e) => setLdapConfig({...ldapConfig, base_dn: e.target.value})}
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-2.5 pl-10 pr-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all font-mono"
/>
</div>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">User Dn Template</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-700" size={14} />
<input
type="text"
placeholder="uid={username},ou=people..."
value={ldapConfig.user_template}
onChange={(e) => setLdapConfig({...ldapConfig, user_template: e.target.value})}
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-2.5 pl-10 pr-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-bold text-slate-400 tracking-tight ml-1">Groups Base Dn</label>
<div className="relative">
<Layers className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-700" size={14} />
<input
type="text"
placeholder="ou=groups"
value={ldapConfig.groups_dn}
onChange={(e) => setLdapConfig({...ldapConfig, groups_dn: e.target.value})}
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-2.5 pl-10 pr-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all font-mono"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-slate-950/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-indigo-500/30">
<div className="flex items-center gap-3">
<div className={cn(
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
ldapConfig.use_tls ? "bg-indigo-500/10 text-indigo-400" : "bg-slate-800 text-slate-500"
)}>
<Lock size={14} />
</div>
<div>
<p className="text-xs font-bold text-slate-200 tracking-tight leading-none">Use Tls</p>
<p className={cn("text-[11px] font-bold mt-1.5", ldapConfig.use_tls ? "text-indigo-400/80" : "text-slate-500")}>
{ldapConfig.use_tls ? "Encrypted Channel" : "Standard Link"}
</p>
</div>
</div>
<button
onClick={() => setLdapConfig({...ldapConfig, use_tls: !ldapConfig.use_tls})}
className={cn(
"w-10 h-5 rounded-full relative transition-all duration-300 shadow-inner border",
ldapConfig.use_tls ? "bg-indigo-600 border-indigo-500" : "bg-slate-800 border-slate-700"
)}
>
<div className={cn(
"absolute top-0.5 w-3.5 h-3.5 bg-white rounded-full transition-all shadow-sm",
ldapConfig.use_tls ? "right-0.5" : "left-0.5"
)} />
</button>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={onTestLdap}
disabled={testingLdap}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl py-3 text-sm font-black shadow-xl shadow-indigo-600/10 transition-all active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2 border border-indigo-500/30"
>
{testingLdap ? <RotateCcw size={14} className="animate-spin" /> : <Wifi size={14} />}
Test Connection
</button>
<button
onClick={onUpdateLdap}
className="flex-[2] bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl py-3 text-sm font-black shadow-xl shadow-indigo-600/10 transition-all active:scale-95 flex items-center justify-center gap-2 border border-indigo-500/30 tracking-tight"
>
<Shield size={14} /> Save LDAP Policy
</button>
</div>
</div>
);
}

View File

@@ -26,5 +26,9 @@ EOF
chown nextjs:nodejs /app/public/network.json
# Hand off to the application server as the nextjs user
echo "🐳 [Docker] Starting Next.js standalone server as nextjs user..."
exec su-exec nextjs node server.js
echo "🐳 [Docker] Starting Next.js server..."
if [ $# -eq 0 ]; then
exec su-exec nextjs node server.js
else
exec su-exec nextjs "$@"
fi

338
frontend/hooks/useAdmin.ts Normal file
View File

@@ -0,0 +1,338 @@
import { useState, useEffect } from 'react';
import { inventoryApi } from '@/lib/api';
import { toast } from 'react-hot-toast';
export function useAdmin() {
const [users, setUsers] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [ldapConfig, setLdapConfig] = useState<any>({
ldap_enabled: false,
server_uri: '',
base_dn: '',
user_template: '',
groups_dn: '',
use_tls: false,
role_mappings: []
});
const [testingLdap, setTestingLdap] = useState(false);
const [editingUser, setEditingUser] = useState<any | null>(null);
const [editUserForm, setEditUserForm] = useState({ username: '', password: '', role: 'user' });
const [editingCategory, setEditingCategory] = useState<any | null>(null);
const [editCatForm, setEditCatForm] = useState({ name: '', description: '' });
const [backups, setBackups] = useState<any[]>([]);
const [dbStats, setDbStats] = useState({ backup_count: 0, total_size_bytes: 0 });
const [dbSettings, setDbSettings] = useState({ retention_count: 10, schedule_hour: 3, schedule_freq_days: 1 });
const [isBackingUp, setIsBackingUp] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [aiPrompt, setAiPrompt] = useState("");
const [aiConfig, setAiConfig] = useState<any>(null);
const [aiKeys, setAiKeys] = useState({ gemini: '', claude: '' });
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
const [isSavingKeys, setIsSavingKeys] = useState(false);
const [isTestingKeys, setIsTestingKeys] = useState<{gemini: boolean, claude: boolean}>(
{gemini: false, claude: false}
);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [u, c, l, b, s, st, promptRes, ai] = await Promise.all([
inventoryApi.getUsers(),
inventoryApi.getCategories(),
inventoryApi.getLdapConfig(),
inventoryApi.getDbBackups(),
inventoryApi.getDbStats(),
inventoryApi.getDbSettings(),
inventoryApi.getAiPrompt(),
inventoryApi.getAiConfig()
]);
setUsers(u);
setCategories(c);
if (l && l.server_uri) setLdapConfig(l);
setBackups(b);
setDbStats(s);
setDbSettings(st);
setAiPrompt(promptRes.value);
setAiConfig(ai);
} catch (err: any) {
console.error(err);
toast.error("Failed to load admin data");
} finally {
setLoading(false);
}
};
const handleAddUser = async () => {
const name = window.prompt("Enter new username:");
if (!name) return;
const pwd = window.prompt("Enter password for " + name + ":");
if (!pwd) return;
try {
await inventoryApi.createUser({ username: name, password: pwd, role: 'user' });
toast.success("User created successfully");
loadData();
} catch (err: any) {
toast.error("Failed to create user");
}
};
const handleDeleteUser = async (id: number, username: string) => {
if (username === 'Admin') return;
if (!window.confirm(`Delete user ${username}?`)) return;
try {
await inventoryApi.deleteUser(id);
toast.success("User removed");
loadData();
} catch (err: any) {
toast.error("Delete failed");
}
};
const handleUpdateUserSubmit = async () => {
if (!editingUser) return;
try {
const payload: any = { username: editUserForm.username, role: editUserForm.role };
if (editUserForm.password) payload.password = editUserForm.password;
await inventoryApi.updateUser(editingUser.id, payload);
toast.success("User updated successfully");
setEditingUser(null);
loadData();
} catch (err: any) {
toast.error("Update failed");
}
};
const handleUpdateAiProvider = async (provider: string) => {
try {
await inventoryApi.updateAiProvider(provider);
toast.success(`AI provider switched to ${provider}`);
loadData();
} catch (err: any) {
toast.error("Failed to switch AI provider");
}
};
const handleSaveAiKeys = async () => {
if (!aiKeys.gemini && !aiKeys.claude) {
toast.error("Enter at least one API key to save");
return;
}
setIsSavingKeys(true);
try {
await inventoryApi.updateAiKeys({
gemini_api_key: aiKeys.gemini || undefined,
claude_api_key: aiKeys.claude || undefined
});
toast.success("AI Configuration keys updated successfully");
setAiKeys({ gemini: '', claude: '' });
loadData();
} catch (err: any) {
toast.error("Failed to update AI keys");
} finally {
setIsSavingKeys(false);
}
};
const handleTestAiKey = async (provider: 'gemini' | 'claude') => {
const keyToTest = provider === 'gemini' ? aiKeys.gemini : aiKeys.claude;
setIsTestingKeys(prev => ({ ...prev, [provider]: true }));
try {
const res = await inventoryApi.testAiKey(provider, keyToTest);
toast.success(res.message || `${provider.toUpperCase()} connection successful!`);
} catch (err: any) {
const msg = err.response?.data?.detail || `Failed to test ${provider} key`;
toast.error(msg);
} finally {
setIsTestingKeys(prev => ({ ...prev, [provider]: false }));
}
};
const handleAddCategory = async () => {
const name = window.prompt("New category name:");
if (!name) return;
const desc = window.prompt("Description (optional):");
try {
await inventoryApi.createCategory({ name, description: desc });
toast.success("Category added");
loadData();
} catch (err: any) {
toast.error("Failed to add category");
}
};
const handleUpdateCategorySubmit = async () => {
if (!editingCategory) return;
try {
await inventoryApi.updateCategory(editingCategory.id, editCatForm);
toast.success("Category updated");
setEditingCategory(null);
loadData();
} catch (err: any) {
toast.error("Update failed");
}
};
const handleDeleteCategory = async (id: number, name: string) => {
if (!window.confirm(`Delete category ${name}?`)) return;
try {
await inventoryApi.deleteCategory(id);
toast.success("Category removed");
loadData();
} catch (err: any) {
toast.error(err.response?.data?.detail || "Delete failed");
}
};
const handleCreateBackup = async () => {
setIsBackingUp(true);
try {
await inventoryApi.triggerBackup();
toast.success("Snapshot created successfully");
loadData();
} catch (err: any) {
toast.error("Backup failed");
} finally {
setIsBackingUp(false);
}
};
const handleUpdateDbSettings = async (newSettings: any) => {
try {
await inventoryApi.updateDbSettings(newSettings);
toast.success("System policy updated");
setDbSettings(newSettings);
} catch (err: any) {
toast.error("Failed to update settings");
}
};
const handleUpdatePrompt = async () => {
setIsSavingPrompt(true);
try {
await inventoryApi.updateAiPrompt(aiPrompt);
toast.success("AI Extraction Prompt updated");
} catch (err: any) {
toast.error("Failed to update AI prompt");
} finally {
setIsSavingPrompt(false);
}
};
const handleUpdateLdap = async () => {
setLoading(true);
try {
await inventoryApi.updateLdapConfig(ldapConfig);
toast.success("Enterprise configuration updated");
} catch (err: any) {
toast.error("Failed to update LDAP config");
} finally {
setLoading(false);
}
};
const handleTestLdap = async () => {
setTestingLdap(true);
try {
const res = await inventoryApi.testLdapConnection(ldapConfig);
if (res.status === 'success') {
toast.success(res.message || "LDAP Connection Successful!");
} else {
toast.error(`LDAP Error: ${res.message || "Unknown error"}`);
}
} catch (err: any) {
toast.error(`Connection failed: ${err.response?.data?.detail || err.message}`);
} finally {
setTestingLdap(false);
}
};
const handleRestore = async (filename: string) => {
if (!window.confirm(`DANGEROUS: Restore database from ${filename}? Current data will be replaced. A rollback snapshot will be created automatically.`)) return;
const loadingToast = toast.loading("Restoring database...");
try {
await inventoryApi.restoreDatabase(filename);
toast.success("Database restored! Reloading system...", { id: loadingToast });
setTimeout(() => window.location.reload(), 2000);
} catch (err: any) {
toast.error("Restore failed", { id: loadingToast });
}
};
const handleExportDb = async () => {
try {
const blob = await inventoryApi.exportDb();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `inventory_backup_${new Date().toISOString().split('T')[0]}.db`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
toast.success("Database exported successfully");
} catch (err: any) {
toast.error("Export failed");
}
};
const handleImportDb = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!window.confirm("DANGEROUS: You are about to REPLACE the entire database with this file. All current data will be lost. Proceed?")) {
e.target.value = '';
return;
}
setIsImporting(true);
const formData = new FormData();
formData.append('file', file);
const loadingToast = toast.loading("Importing database...");
try {
await inventoryApi.importDb(formData);
toast.success("Database imported! Reloading system...", { id: loadingToast });
setTimeout(() => window.location.reload(), 2000);
} catch (err: any) {
toast.error("Import failed: " + (err.response?.data?.detail || err.message), { id: loadingToast });
} finally {
setIsImporting(false);
e.target.value = '';
}
};
const handleLogout = () => {
import('@/lib/auth').then(m => m.clearAuth());
window.location.href = '/login';
};
return {
users, categories, loading,
ldapConfig, setLdapConfig, testingLdap,
editingUser, setEditingUser, editUserForm, setEditUserForm,
editingCategory, setEditingCategory, editCatForm, setEditCatForm,
backups, dbStats, dbSettings, isBackingUp, isImporting,
aiPrompt, setAiPrompt, aiConfig, aiKeys, setAiKeys,
isSavingPrompt, isSavingKeys, isTestingKeys,
refreshData: loadData,
handleAddUser, handleDeleteUser, handleUpdateUserSubmit,
handleUpdateLdap, handleTestLdap,
handleUpdateAiProvider, handleSaveAiKeys, handleTestAiKey, handleUpdatePrompt,
handleAddCategory, handleUpdateCategorySubmit, handleDeleteCategory,
handleCreateBackup, handleUpdateDbSettings, handleRestore, handleExportDb, handleImportDb,
handleLogout
};
}

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -15,9 +15,12 @@ BACKEND_SSL_PORT=8918
FRONTEND_PORT=8917
FRONTEND_SSL_PORT=8919
# Security & AI
# Security
JWT_SECRET_KEY=change_me_in_production
# AI
GEMINI_API_KEY=AIzaSyAajthWG2agpDLyJHY11U5qFLP4WnV5z0w
CLAUDE_API_KEY=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
# External Access (CORS)
# Comma-separated list of extra IPs or FQDNs allowed to connect (e.g. Tailscale, VPN)