Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d50e0849 | |||
| 757e9a7aa7 | |||
| 79e3152ce8 | |||
| be251c8a40 | |||
| 019b941bdb | |||
| 2d162ff3c0 | |||
| 6760ab0abf | |||
| d9e592cc97 | |||
| 062df8cfd9 | |||
| 074d4f1f8d | |||
| add873b159 | |||
| 6bf3276c72 | |||
| e5963123ab | |||
| db918a86ab |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
|
||||
- **Validation:** Pydantic v2
|
||||
- **Auth:** Hybrid LDAP (python-ldap) + PBKDF2 local password hash caching
|
||||
- **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)
|
||||
@@ -27,6 +28,7 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
|
||||
- **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)
|
||||
@@ -110,3 +112,9 @@ To ensure deployment stability on macOS environments with potentially broken dev
|
||||
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.
|
||||
|
||||
16
README.md
16
README.md
@@ -49,11 +49,12 @@ To generate a clean production package and snapshot the current state:
|
||||
- Accessible markup with `role="status"` and `aria-hidden` attributes
|
||||
- **Pages Updated:** Inventory (Categories, Item Types, Total Boxes), Logs (Total Events, Check in/out), Admin (Local Archives)
|
||||
- **Tested on:** iPhone SE (375px), iPhone 12 (390px), iPhone 14 Pro Max (430px), tablets (768px), desktop (1024px)
|
||||
- **Multi-AI Selection & Secure Config (v1.9.23):**
|
||||
- Support for both **Google Gemini** and **Anthropic Claude** for AI label extraction.
|
||||
- Dynamically configurable AI providers via the Admin Dashboard.
|
||||
- Centralized `ConfigManager` for secure API key and environment management.
|
||||
- **Admin UI Standardization:** Refactored Admin page with unified StatCards and secure input masking for system credentials.
|
||||
- **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+)
|
||||
@@ -62,6 +63,13 @@ To generate a clean production package and snapshot the current state:
|
||||
* **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).
|
||||
|
||||
---
|
||||
|
||||
4
__push_ALL_to_remote.sh
Executable file
4
__push_ALL_to_remote.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
git push origin --all
|
||||
git push origin --tags
|
||||
47
artifacts/superpowers/brainstorm.md
Normal file
47
artifacts/superpowers/brainstorm.md
Normal 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 (2–4)
|
||||
|
||||
### 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).
|
||||
@@ -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
0
backend/__init__.py
Normal 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()
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
0
backend/routers/admin/__init__.py
Normal file
0
backend/routers/admin/__init__.py
Normal file
91
backend/routers/admin/backups.py
Normal file
91
backend/routers/admin/backups.py
Normal 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))
|
||||
@@ -1,68 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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
|
||||
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 Database"]
|
||||
tags=["Admin Configuration"]
|
||||
)
|
||||
|
||||
@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))
|
||||
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(
|
||||
@@ -70,7 +20,6 @@ def get_db_settings(
|
||||
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()
|
||||
@@ -102,59 +51,22 @@ def update_db_settings(
|
||||
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
|
||||
"""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 as e:
|
||||
print(f"Error reading prompt file: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Fallback to DB
|
||||
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
if not setting:
|
||||
return {"value": "", "source": "none"}
|
||||
@@ -166,21 +78,18 @@ def update_ai_prompt(
|
||||
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)."""
|
||||
"""Update the AI extraction prompt."""
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Update DB (Backup/Sync)
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
if existing:
|
||||
existing.value = value
|
||||
@@ -190,8 +99,6 @@ def update_ai_prompt(
|
||||
db.commit()
|
||||
return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}
|
||||
|
||||
from ..config_manager import ConfigManager
|
||||
|
||||
@router.get("/settings/ai")
|
||||
def get_ai_config(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -201,7 +108,6 @@ def get_ai_config(
|
||||
gemini_key = os.environ.get("GEMINI_API_KEY")
|
||||
claude_key = os.environ.get("CLAUDE_API_KEY")
|
||||
|
||||
# Get active provider from DB
|
||||
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||
active_provider = provider_setting.value if provider_setting else "gemini"
|
||||
|
||||
@@ -230,7 +136,7 @@ def update_ai_keys(
|
||||
payload: dict,
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update AI API keys in inventory.env."""
|
||||
"""Update AI API keys."""
|
||||
gemini_key = payload.get("gemini_api_key")
|
||||
claude_key = payload.get("claude_api_key")
|
||||
|
||||
@@ -240,10 +146,9 @@ def update_ai_keys(
|
||||
if claude_key:
|
||||
updates["CLAUDE_API_KEY"] = claude_key
|
||||
|
||||
if not updates:
|
||||
return {"status": "no_change"}
|
||||
if updates:
|
||||
ConfigManager.update_keys(updates)
|
||||
|
||||
ConfigManager.update_keys(updates)
|
||||
return {
|
||||
"status": "success",
|
||||
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")),
|
||||
@@ -262,7 +167,6 @@ def test_ai_key(
|
||||
if not provider or provider not in ["gemini", "claude"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid provider")
|
||||
|
||||
# If key is masked or empty, use the one from environment
|
||||
if not key or "****" in key:
|
||||
key = os.environ.get("GEMINI_API_KEY" if provider == "gemini" else "CLAUDE_API_KEY")
|
||||
|
||||
@@ -273,25 +177,15 @@ def test_ai_key(
|
||||
if provider == "gemini":
|
||||
from google import genai
|
||||
client = genai.Client(api_key=key, http_options={'api_version': 'v1beta'})
|
||||
# Listing models is the standard way to check key validity
|
||||
client.models.list()
|
||||
return {"status": "success", "message": "Google Gemini API connection verified!"}
|
||||
|
||||
elif provider == "claude":
|
||||
import anthropic
|
||||
client = anthropic.Anthropic(api_key=key)
|
||||
# Claude also supports model listing for key verification
|
||||
client.models.list(limit=1)
|
||||
return {"status": "success", "message": "Anthropic Claude API connection verified!"}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "API_KEY_INVALID" in error_msg or "invalid_api_key" in error_msg:
|
||||
error_msg = "The API key provided is invalid."
|
||||
elif "PERMISSION_DENIED" in error_msg:
|
||||
error_msg = "Permission denied. Check if the key has correct permissions."
|
||||
|
||||
raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {error_msg}")
|
||||
raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {str(e)}")
|
||||
|
||||
@router.post("/settings/ai")
|
||||
def update_ai_provider(
|
||||
@@ -302,7 +196,7 @@ def update_ai_provider(
|
||||
"""Update the active AI provider."""
|
||||
provider = payload.get("provider")
|
||||
if provider not in ["gemini", "claude"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid provider. Must be 'gemini' or 'claude'.")
|
||||
raise HTTPException(status_code=400, detail="Invalid provider")
|
||||
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||
if existing:
|
||||
65
backend/tests/conftest.py
Normal file
65
backend/tests/conftest.py
Normal 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()
|
||||
35
backend/tests/test_admin.py
Normal file
35
backend/tests/test_admin.py
Normal 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
|
||||
@@ -1,347 +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 (Task 5 complete - Mobile viewport testing done)
|
||||
**Current Version:** v1.10.0 (ModularAdmin)
|
||||
**Branch:** master (Refactor complete & Merged)
|
||||
|
||||
---
|
||||
|
||||
## STATUS: 🟢 STABLE — MOBILE VIEWPORT TESTING COMPLETE
|
||||
## STATUS: 🟢 STABLE — MODULAR REFACTORING COMPLETE
|
||||
|
||||
**PROGRESS:** Task 5 complete - StatCard responsive layout verified on mobile viewports (320px-430px), tablet (768px), and desktop (1024px). All tests passed with no overflow or layout issues.
|
||||
**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.
|
||||
|
||||
### Task 5: Mobile Viewport Testing [COMPLETED]
|
||||
### v1.10.0 Accomplishments (ModularAdmin)
|
||||
|
||||
**Test Date:** 2026-04-15
|
||||
**Tester:** Claude (Haiku)
|
||||
#### 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.
|
||||
|
||||
#### Mobile Widths Tested
|
||||
#### 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.
|
||||
|
||||
1. **320px (iPhone SE smallest)**
|
||||
- ✓ No overflow on stat cards
|
||||
- ✓ Labels truncated with ellipsis ("Categ...", "Item T...", "Total B...")
|
||||
- ✓ Numbers visible and properly aligned
|
||||
- ✓ Icons display correctly
|
||||
- ✓ Layout remains stable
|
||||
|
||||
2. **375px (iPhone SE)**
|
||||
- ✓ No overflow
|
||||
- ✓ Labels fully visible on Inventory page ("Categories", "Item Types", "Total Boxes")
|
||||
- ✓ Stat cards use 2-column layout
|
||||
- ✓ Icons and numbers properly spaced
|
||||
- ✓ Custom div components (Top Operator, Storage Status) render correctly
|
||||
|
||||
3. **390px (iPhone 12)**
|
||||
- ✓ No overflow
|
||||
- ✓ Labels fully visible across all three pages
|
||||
- ✓ Responsive font sizes applied (text-sm md:text-base)
|
||||
- ✓ Icons scaled appropriately
|
||||
- ✓ Consistent spacing and alignment
|
||||
|
||||
4. **430px (iPhone 14 Pro Max)**
|
||||
- ✓ No overflow
|
||||
- ✓ Extra spacious layout
|
||||
- ✓ All content easily readable
|
||||
- ✓ Responsive breakpoints working correctly
|
||||
|
||||
#### Tablet Width (768px)
|
||||
- ✓ 3-column grid layout for stat cards (Inventory page)
|
||||
- ✓ Responsive font sizes (md: breakpoint active)
|
||||
- ✓ Increased padding and spacing
|
||||
- ✓ Labels fully visible
|
||||
- ✓ Icons scale correctly
|
||||
|
||||
#### Desktop Width (1024px)
|
||||
- ✓ Full layout rendering correctly
|
||||
- ✓ Max-width constraints applied
|
||||
- ✓ Stat cards display with proper spacing
|
||||
- ✓ Typography hierarchy maintained
|
||||
- ✓ All pages render without issues
|
||||
|
||||
#### Pages Verified
|
||||
|
||||
**Inventory Page (mobile 375px):**
|
||||
- ✓ Categories [2] — visible with icon
|
||||
- ✓ Item Types [6] — visible with icon
|
||||
- ✓ Total Boxes [2] — visible with icon
|
||||
- ✓ 2-column layout on mobile, 3-column on tablet
|
||||
- ✓ No text cutoff or overflow
|
||||
|
||||
**Logs Page (mobile 375px):**
|
||||
- ✓ Total Events [34] — visible (truncated at 320px: "Tot...")
|
||||
- ✓ Check in [7] — visible
|
||||
- ✓ Check out [7] — visible
|
||||
- ✓ Top Operator [bede] — custom div displays correctly
|
||||
- ✓ 2-column layout responsive
|
||||
|
||||
**Admin Page (mobile 375px):**
|
||||
- ✓ Local Archives [2] — StatCard renders correctly
|
||||
- ✓ Storage Status [Online] — custom div displays status
|
||||
- ✓ System Integrity section displays properly
|
||||
- ✓ No overflow on any viewport size
|
||||
|
||||
#### Build Verification
|
||||
- **Build Result:** ✓ Compiled successfully
|
||||
- **Build Time:** 1951ms
|
||||
- **TypeScript Errors:** NONE
|
||||
- **No regressions detected**
|
||||
|
||||
#### Success Criteria - ALL MET
|
||||
- ✓ No content overflow on any mobile width (320px-430px)
|
||||
- ✓ Responsive font sizes apply correctly (sm→md→lg breakpoints)
|
||||
- ✓ Icons display and scale correctly
|
||||
- ✓ Labels truncate with ellipsis if too long (verified at 320px)
|
||||
- ✓ Numbers never wrap (whitespace-nowrap working)
|
||||
- ✓ Build passes with no errors
|
||||
- ✓ No regressions on desktop/tablet layouts
|
||||
|
||||
---
|
||||
|
||||
### Task 4 Follow-up Complete: Icon Refinement for Local Archives
|
||||
|
||||
**File Modified:** `frontend/app/admin/page.tsx`
|
||||
|
||||
#### What Changed
|
||||
- Changed icon from `Archive` to `Database` for "Local Archives" stat card
|
||||
- Before: `<StatCard label="Local Archives" value={dbStats.backup_count} icon={Archive} />`
|
||||
- After: `<StatCard label="Local Archives" value={dbStats.backup_count} icon={Database} />`
|
||||
- Removed unused `Archive` import from lucide-react
|
||||
- Database icon was already imported and available
|
||||
|
||||
#### Build Verification
|
||||
- **Commit:** `66844a56`
|
||||
- **Message:** "fix: use Database icon for Local Archives stat card"
|
||||
- **Build Result:** ✓ Compiled successfully in 2.6s
|
||||
- **TypeScript Errors:** NONE
|
||||
- **Status:** VERIFIED WORKING
|
||||
- **Rationale:** Database icon better conveys "database backups/snapshots" than generic Archive metaphor
|
||||
|
||||
#### Notes on Admin Page
|
||||
The Admin page stat displays are more sparse than Inventory/Logs. The "System Integrity" section contains:
|
||||
- Storage Status: "Online" (string value - kept as custom, not converted)
|
||||
- Local Archives: backup_count (numeric value - **CONVERTED to StatCard**)
|
||||
|
||||
The Storage Status remains as custom HTML because it displays a status string, not a numeric metric. This is consistent with the pattern used on Logs page where "Top Operator" was kept as custom because it displays a username string.
|
||||
#### 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
|
||||
|
||||
### Task 3: Logs Page Stat Cards Refactoring
|
||||
|
||||
**File Modified:** `frontend/app/logs/page.tsx`
|
||||
|
||||
#### Step 1: Added Import
|
||||
- Added `StatCard` component import from `@/components/StatCard`
|
||||
|
||||
#### Step 2: Replaced Three Numeric Stat Displays
|
||||
1. **Total Events Card:**
|
||||
- Old: Inline flex div with Activity icon
|
||||
- New: `<StatCard label="Total Events" value={totalCount} icon={Activity} />`
|
||||
|
||||
2. **Check in Card:**
|
||||
- Old: Inline flex div with ArrowDownCircle icon
|
||||
- New: `<StatCard label="Check in" value={inCount} icon={ArrowDownCircle} />`
|
||||
|
||||
3. **Check out Card:**
|
||||
- Old: Inline flex div with ArrowUpCircle icon
|
||||
- New: `<StatCard label="Check out" value={outCount} icon={ArrowUpCircle} />`
|
||||
|
||||
#### Step 3: Top Operator Card (Kept Custom)
|
||||
- Kept as custom div (matches StatCard styling but displays string value `mostActiveUser`)
|
||||
- Uses same responsive sizing and layout as StatCard
|
||||
|
||||
#### Step 4: Build Verification
|
||||
- **Commit:** `f47e7d00`
|
||||
- **Message:** "fix: refactor Logs page stat cards to use StatCard component"
|
||||
- **Build Result:** ✓ Compiled successfully in 2.8s
|
||||
- **TypeScript Errors:** NONE
|
||||
- **Status:** VERIFIED WORKING
|
||||
|
||||
#### Benefits Implemented
|
||||
✓ Mobile responsive two-column flexbox layout
|
||||
✓ Consistent styling across numeric stat cards
|
||||
✓ Responsive font sizing (text-sm md:text-base for labels, text-lg md:text-xl for values)
|
||||
✓ Label truncation for long text prevents overflow
|
||||
✓ Unified design with premium aesthetic
|
||||
✓ Maintained existing functionality for Top Operator card
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Inventory Page Stat Cards Refactoring [COMPLETED]
|
||||
|
||||
**File Modified:** `frontend/app/inventory/page.tsx`
|
||||
|
||||
#### Step 1: Added Imports
|
||||
- Added `StatCard` component import from `@/components/StatCard`
|
||||
- Added `Box` icon import from `lucide-react` (for Total Boxes stat)
|
||||
|
||||
#### Step 2: Replaced Three Stat Displays
|
||||
1. **Categories Card:**
|
||||
- Old: Inline flex div with Layers icon
|
||||
- New: `<StatCard label="Categories" value={stats?.total_categories || categories.length} icon={Layers} />`
|
||||
|
||||
2. **Item Types Card:**
|
||||
- Old: Inline flex div with Package icon
|
||||
- New: `<StatCard label="Item Types" value={stats?.total_items || inventory.length} icon={Package} />`
|
||||
|
||||
3. **Total Boxes Card:**
|
||||
- Old: Inline flex div with Layout icon
|
||||
- New: `<StatCard label="Total Boxes" value={existingBoxes.length} icon={Box} />`
|
||||
|
||||
#### Step 3: Build Verification
|
||||
- **Commit:** `adb6cde8`
|
||||
- **Message:** "fix: refactor Inventory page stat cards to use StatCard component"
|
||||
- **Build Result:** ✓ Compiled successfully in 7.2s
|
||||
- **TypeScript Errors:** NONE
|
||||
- **Status:** VERIFIED WORKING
|
||||
|
||||
#### Benefits Implemented
|
||||
✓ Mobile responsive two-column flexbox layout
|
||||
✓ Consistent styling across all stat cards
|
||||
✓ Responsive font sizing (text-sm md:text-base for labels, text-lg md:text-xl for values)
|
||||
✓ Label truncation for long text prevents overflow
|
||||
✓ Unified design with premium aesthetic
|
||||
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 (Next Tasks in Series)
|
||||
### 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).
|
||||
|
||||
1. **Task 6:** Final verification & documentation
|
||||
- All stat cards have been tested and verified on mobile/tablet/desktop
|
||||
- Build passes with no errors
|
||||
- Ready for Task 6: Final verification & documentation
|
||||
- Consider pushing changes to remote and merging to master if all tests pass
|
||||
|
||||
### Architecture Note
|
||||
- StatCard component is located at: `frontend/components/StatCard.tsx`
|
||||
- Component props: `label` (string), `value` (number), `icon` (optional LucideIcon)
|
||||
- Styling uses Tailwind: `flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg`
|
||||
- Responsive sizing: labels use `text-sm md:text-base`, values use `text-lg md:text-xl`
|
||||
### 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final Verification & Documentation [COMPLETED]
|
||||
|
||||
**Verification Date:** 2026-04-15
|
||||
**Verifier:** Claude (Haiku)
|
||||
|
||||
#### Step 1: Git Commits Verified
|
||||
All required commits present in history:
|
||||
- ✓ `4df2d844` feat: create reusable StatCard component
|
||||
- ✓ `460dc4fe` fix: improve StatCard accessibility
|
||||
- ✓ `adb6cde8` fix: refactor Inventory page stat cards
|
||||
- ✓ `f47e7d00` fix: refactor Logs page stat cards
|
||||
- ✓ `45db169d` fix: refactor Admin page stat cards
|
||||
- ✓ `66844a56` fix: use Database icon for Local Archives stat card
|
||||
|
||||
**Total: 6 commits across 7 days**
|
||||
**Branch Status:** dev (9 commits ahead of origin/dev)
|
||||
|
||||
#### Step 2: Old Stat Display Search Results
|
||||
Searched for remaining old-style stat displays using pattern `text-xs text-slate-500`.
|
||||
|
||||
**Findings:**
|
||||
- `frontend/app/admin/page.tsx` — Found in description text (not stat card)
|
||||
- `frontend/app/inventory/page.tsx` — Found in description text (not stat card)
|
||||
- `frontend/app/login/page.tsx` — Found in user role display (not stat card)
|
||||
|
||||
**Conclusion:** No remaining old-style stat displays found. All numeric stat cards have been converted to StatCard component. Description/metadata text patterns are intentional and correct.
|
||||
|
||||
#### Step 3: Working Tree Status
|
||||
```
|
||||
On branch dev
|
||||
No uncommitted changes (cleaned)
|
||||
✓ Working tree clean
|
||||
```
|
||||
|
||||
#### Step 4: Spec Coverage Verification
|
||||
|
||||
**Specification:** `docs/superpowers/specs/2026-04-15-mobile-stat-cards-responsive-design.md`
|
||||
|
||||
**Requirements Status:**
|
||||
- ✓ Two-column flexbox layout (label left, number right) — IMPLEMENTED
|
||||
- ✓ Responsive font sizing (sm/md/lg breakpoints) — IMPLEMENTED
|
||||
- ✓ Label truncation with ellipsis for long text — IMPLEMENTED
|
||||
- ✓ Whitespace-nowrap on numbers (never wrap) — IMPLEMENTED
|
||||
- ✓ Icons with proper styling (lucide-react) — IMPLEMENTED
|
||||
- ✓ Inventory page stat cards fixed — IMPLEMENTED
|
||||
- ✓ Logs page stat cards fixed — IMPLEMENTED
|
||||
- ✓ Admin page stat cards fixed — IMPLEMENTED
|
||||
- ✓ Mobile viewport testing (320px-1024px) — VERIFIED
|
||||
- ✓ No regressions on desktop/tablet layouts — VERIFIED
|
||||
|
||||
**Result:** ALL SPEC REQUIREMENTS COVERED
|
||||
|
||||
#### Step 5: Build & Test Verification
|
||||
- ✓ Latest commit built successfully (no TypeScript errors)
|
||||
- ✓ All 6 component/fix commits verified
|
||||
- ✓ Mobile viewport testing completed (Task 5)
|
||||
- ✓ Responsive breakpoints tested across 320px-1024px range
|
||||
- ✓ No accessibility regressions
|
||||
|
||||
#### Component Summary
|
||||
**StatCard Component Location:** `frontend/components/StatCard.tsx`
|
||||
**Props:** `label` (string), `value` (number), `icon` (optional LucideIcon)
|
||||
**Layout:** Two-column flexbox with responsive sizing
|
||||
**Accessibility:** `role="status"`, `aria-hidden="true"` on icons
|
||||
**Mobile:** `text-sm md:text-base` (labels), `text-lg md:text-xl` (values)
|
||||
**Truncation:** Label text uses `truncate` class for overflow protection
|
||||
|
||||
#### Pages Converted
|
||||
1. **Inventory Page** — 3 stat cards (Categories, Item Types, Total Boxes)
|
||||
2. **Logs Page** — 3 stat cards (Total Events, Check in, Check out)
|
||||
3. **Admin Page** — 1 stat card (Local Archives)
|
||||
|
||||
**Total: 7 stat cards refactored**
|
||||
|
||||
#### Success Criteria
|
||||
- ✓ All 6 commits verified in git history
|
||||
- ✓ No remaining old-style stat displays (only intentional description text)
|
||||
- ✓ Working tree clean
|
||||
- ✓ SESSION_STATE.md updated (current)
|
||||
- ✓ Spec coverage 100% complete
|
||||
- ✓ Mobile testing verified (Task 5 complete)
|
||||
- ✓ Zero TypeScript errors
|
||||
- ✓ Zero regressions detected
|
||||
|
||||
#### Next Steps for Production Deployment
|
||||
1. Push dev branch to origin: `git push origin dev`
|
||||
2. Create pull request: `dev` → `master`
|
||||
3. Perform code review (optional)
|
||||
4. Merge to master and tag release
|
||||
5. Deploy using: `./deploy.sh --reset-ssl` on remote server
|
||||
|
||||
---
|
||||
✓ Done.
|
||||
**Active AI Tools:**
|
||||
- **Git Binary:** `/Library/Developer/CommandLineTools/usr/bin/git` (Bypasses xcode-select errors).
|
||||
- **Environment:** Use `./backend/venv/` for python tasks.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.9.24",
|
||||
"last_build": "2026-04-15-1533",
|
||||
"codename": "MultiAI",
|
||||
"commit": "f751cc20"
|
||||
"version": "1.10.9",
|
||||
"last_build": "2026-04-15-1957",
|
||||
"codename": "AccessibleUI",
|
||||
"commit": "757e9a7a"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -252,10 +252,10 @@ export default function InventoryPage() {
|
||||
<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-[10px] md:text-xs text-slate-500 font-bold mt-0.5">Enterprise Stock Overview</p>
|
||||
</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"
|
||||
@@ -311,12 +311,12 @@ export default function InventoryPage() {
|
||||
<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" />}
|
||||
@@ -353,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>
|
||||
@@ -389,12 +389,12 @@ export default function InventoryPage() {
|
||||
<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 && (
|
||||
@@ -431,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 || ''}
|
||||
@@ -440,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 || ''}
|
||||
@@ -450,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"
|
||||
@@ -466,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"
|
||||
@@ -476,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 || ''}
|
||||
@@ -486,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 || ''}
|
||||
@@ -496,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 || ''}
|
||||
@@ -506,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"
|
||||
@@ -534,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})}
|
||||
@@ -567,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>
|
||||
|
||||
@@ -641,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}
|
||||
@@ -650,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)}
|
||||
@@ -736,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>
|
||||
);
|
||||
@@ -757,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>
|
||||
)}
|
||||
@@ -775,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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function LogsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Operations Audit</h1>
|
||||
<p className="text-[10px] md:text-xs text-slate-500 font-bold uppercase tracking-widest mt-0.5">Real-time Intervention Tracking</p>
|
||||
<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>
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function LogsPage() {
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-[10px] font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl"
|
||||
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
|
||||
@@ -153,10 +153,10 @@ export default function LogsPage() {
|
||||
<button
|
||||
onClick={() => setFilterAction('ALL')}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
|
||||
"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-500 border-slate-800 hover:border-slate-700"
|
||||
: "bg-slate-900/50 text-slate-400 border-slate-800 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
All Streams
|
||||
@@ -166,10 +166,10 @@ export default function LogsPage() {
|
||||
key={f}
|
||||
onClick={() => setFilterAction(f)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
|
||||
"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-500 border-slate-800 hover:border-slate-700"
|
||||
: "bg-slate-900/50 text-slate-400 border-slate-800 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
{f}
|
||||
@@ -182,7 +182,7 @@ export default function LogsPage() {
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-slate-600 gap-4 animate-pulse">
|
||||
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-[10px] font-black tracking-widest uppercase italic">Securing Audit Stream...</p>
|
||||
<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-[2.5rem] py-20 flex flex-col items-center justify-center text-center gap-6">
|
||||
@@ -205,7 +205,7 @@ export default function LogsPage() {
|
||||
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
|
||||
{/* Compact Action Badge */}
|
||||
<div className={cn(
|
||||
"text-[8px] font-black px-2 py-1 rounded-lg border min-w-[75px] text-center uppercase tracking-tighter",
|
||||
"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" :
|
||||
@@ -216,13 +216,13 @@ export default function LogsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-slate-100 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 mt-0.5">
|
||||
<span className="text-[9px] font-black text-slate-500 uppercase tracking-tighter shrink-0">{log.username || 'System'}</span>
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-slate-800 shrink-0" />
|
||||
<span className="text-[9px] text-slate-600 font-bold tabular-nums truncate">
|
||||
<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>
|
||||
@@ -250,7 +250,7 @@ export default function LogsPage() {
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className={cn(
|
||||
"text-[9px] font-black px-3 py-1 rounded-full border inline-block uppercase tracking-widest",
|
||||
"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-primary/10 text-primary border-primary/30")
|
||||
)}>
|
||||
@@ -267,11 +267,11 @@ export default function LogsPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Protocol Operator</p>
|
||||
<p className="text-[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/50">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Quantity Delta</p>
|
||||
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">Quantity Delta</p>
|
||||
<p className={cn(
|
||||
"text-xl font-black tabular-nums",
|
||||
(selectedLog.quantity_change || 0) > 0 ? "text-green-500" : "text-rose-500"
|
||||
@@ -285,7 +285,7 @@ export default function LogsPage() {
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Universal Timestamp</p>
|
||||
<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>
|
||||
@@ -305,8 +305,8 @@ export default function LogsPage() {
|
||||
{Object.entries(snap).map(([key, val]) => (
|
||||
(val && key !== 'image_url' && key !== 'id') ? (
|
||||
<div key={key} className="bg-slate-950/20 p-3 rounded-xl border border-slate-800/20">
|
||||
<p className="text-[7px] font-black text-slate-600 uppercase mb-1 tracking-tighter opacity-70">{key.replace('_', ' ')}</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 truncate" title={String(val)}>{String(val)}</p>
|
||||
<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
|
||||
))}
|
||||
@@ -318,8 +318,8 @@ export default function LogsPage() {
|
||||
|
||||
{selectedLog.details && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Intervention Details</p>
|
||||
<div className="bg-primary/5 text-primary/80 p-5 rounded-3xl border border-primary/10 text-xs font-bold leading-relaxed italic shadow-inner">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -635,18 +615,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 +637,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 +653,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 +664,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 +691,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 +701,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 +711,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 +721,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 +730,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,7 +773,8 @@ export default function Home() {
|
||||
<Minus size={24} />
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<span className="text-5xl font-black tabular-nums">{adjustQty}</span>
|
||||
<span className="text-xs font-black tabular-nums">{adjustQty}</span>
|
||||
<span className="text-[10px] text-primary/80 font-bold uppercase tracking-tight">Units</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAdjustQty(adjustQty + 1)}
|
||||
@@ -876,23 +858,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 +880,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -70,7 +70,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
<Shield size={40} className="italic" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-white tracking-tight">Protocol Access</h2>
|
||||
<p className="text-slate-500 text-[10px] font-black uppercase tracking-widest">Select operator profile to initialize</p>
|
||||
<p className="text-slate-500 text-xs font-black uppercase tracking-widest">Select operator profile to initialize</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3.5">
|
||||
@@ -88,7 +88,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-100 font-black text-sm tracking-tight">{user.username}</p>
|
||||
<p className="text-[9px] text-slate-600 font-black uppercase tracking-widest mt-0.5">{user.role}</p>
|
||||
<p className="text-xs text-slate-600 font-black uppercase tracking-widest mt-0.5">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
@@ -99,7 +99,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => setIsEnterprise(true)}
|
||||
className="w-full flex items-center justify-center gap-3 py-5 rounded-[1.5rem] border border-dashed border-slate-800 text-slate-600 hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-all font-black text-[10px] uppercase tracking-widest"
|
||||
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"
|
||||
>
|
||||
<Lock size={14} />
|
||||
Enterprise Directory
|
||||
@@ -109,10 +109,10 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
) : isEnterprise ? (
|
||||
<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-[10px] font-black text-slate-600 uppercase tracking-widest italic">LDAP Authentication</p>
|
||||
<p className="text-xs font-black text-slate-600 uppercase tracking-widest italic">LDAP Authentication</p>
|
||||
<button
|
||||
onClick={() => setIsEnterprise(false)}
|
||||
className="text-[10px] font-black text-primary hover:underline uppercase tracking-tighter"
|
||||
className="text-xs font-black text-primary hover:underline uppercase tracking-tighter"
|
||||
>
|
||||
Profile Swap
|
||||
</button>
|
||||
@@ -146,7 +146,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
|
||||
<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-[10px] tracking-widest border border-primary/20"
|
||||
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>
|
||||
@@ -159,7 +159,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Active Profile</p>
|
||||
<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>
|
||||
@@ -188,7 +188,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
|
||||
<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-[10px] tracking-widest border border-primary/20"
|
||||
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"
|
||||
>
|
||||
Unlock Account
|
||||
</button>
|
||||
@@ -197,7 +197,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="text-center p-8 text-slate-700 animate-pulse font-black text-[10px] uppercase tracking-[0.2em]">
|
||||
<div className="text-center p-8 text-slate-700 animate-pulse font-black text-xs uppercase tracking-[0.2em]">
|
||||
Synchronizing User Tokens...
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -257,11 +257,11 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
<div className="p-6 bg-slate-900/90 backdrop-blur-xl border-t border-slate-800 flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-sm font-black text-white italic text-center">Protocol Detected</p>
|
||||
<p className="text-[10px] text-slate-500 font-black uppercase tracking-widest text-center">Select identity from label matrix</p>
|
||||
<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 hover:bg-slate-700 text-white rounded-2xl font-black text-[10px] uppercase tracking-widest transition-all active:scale-95 border border-slate-700"
|
||||
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"
|
||||
>
|
||||
Abort Cycle
|
||||
</button>
|
||||
@@ -315,7 +315,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
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 tabular-nums">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-[8px] text-primary font-black uppercase tracking-tighter">Zoom</span>
|
||||
<span className="text-xs text-primary font-black uppercase tracking-tighter">Zoom</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -323,13 +323,13 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
{ocrProcessing ? (
|
||||
<>
|
||||
<RefreshCw className="animate-spin text-primary" size={18} />
|
||||
<span className="text-[10px] font-black text-slate-200 uppercase tracking-widest leading-none">Analyzing</span>
|
||||
<span className="text-xs font-black text-slate-200 uppercase tracking-widest leading-none">Analyzing</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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-[8px] text-slate-500 font-black leading-none uppercase tracking-widest">Optical Assist</span>
|
||||
<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>
|
||||
@@ -347,7 +347,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
|
||||
<div className="w-full flex justify-center items-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-green-500 animate-pulse" />
|
||||
<p className="text-[9px] text-slate-600 font-black uppercase tracking-[0.15em]">
|
||||
<p className="text-xs text-slate-600 font-black uppercase tracking-[0.15em]">
|
||||
Optical Data Stream Active
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,12 @@ export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
||||
<div className="flex justify-between items-center gap-2 p-3.5 bg-slate-900/50 backdrop-blur-md border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-slate-900/80" role="status">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />}
|
||||
<span className="text-sm md:text-base text-slate-400 font-medium truncate">
|
||||
<span className="text-base md:text-lg text-slate-300 font-semibold truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap tabular-nums">
|
||||
<span className="text-2xl md:text-3xl font-black text-white whitespace-nowrap tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
183
frontend/components/admin/AiManager.tsx
Normal file
183
frontend/components/admin/AiManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
frontend/components/admin/CategoryManager.tsx
Normal file
113
frontend/components/admin/CategoryManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
178
frontend/components/admin/DatabaseManager.tsx
Normal file
178
frontend/components/admin/DatabaseManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
frontend/components/admin/IdentityManager.tsx
Normal file
136
frontend/components/admin/IdentityManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
frontend/components/admin/LdapManager.tsx
Normal file
165
frontend/components/admin/LdapManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
338
frontend/hooks/useAdmin.ts
Normal 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
|
||||
};
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -15,13 +15,13 @@ 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)
|
||||
EXTRA_ALLOWED_ORIGINS=100.78.182.27
|
||||
|
||||
# --- Automatically Added Keys ---
|
||||
CLAUDE_API_KEY=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
|
||||
|
||||
Reference in New Issue
Block a user