Compare commits
23 Commits
v1.9.19
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
| e5963123ab | |||
| db918a86ab | |||
| 73ab38d237 | |||
|
|
f751cc20a7 | ||
|
|
1893c4f38b | ||
|
|
6102ff39aa | ||
|
|
10d75619bf | ||
|
|
546eca6b9a | ||
|
|
66844a56e9 | ||
|
|
e8c804da71 | ||
|
|
45db169dda | ||
|
|
f47e7d005a | ||
|
|
adb6cde87a | ||
|
|
460dc4fe12 | ||
|
|
4df2d844ca | ||
|
|
409afb29f8 | ||
|
|
bacc23cc23 | ||
|
|
a6af90ec48 | ||
|
|
29b5c7020c | ||
|
|
70670a3c9d | ||
|
|
65b24079cb | ||
|
|
6c57b1b0c2 | ||
|
|
1ea5e90bc4 |
6
.agents/rules/modern-css-and-responsive-design-expert.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
trigger: always_on
|
||||
glob:
|
||||
description:
|
||||
---
|
||||
|
||||
@@ -19,7 +19,22 @@
|
||||
"Bash(sqlite3 data/inventory.db \".schema users\")",
|
||||
"Bash(sqlite3 data/inventory.db \"SELECT * FROM users;\")",
|
||||
"Bash(git restore:*)",
|
||||
"Bash(pkill -f \"uvicorn\")"
|
||||
"Bash(pkill -f \"uvicorn\")",
|
||||
"Bash(docker-compose build:*)",
|
||||
"Bash(docker version:*)",
|
||||
"Bash(docker compose version:*)",
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(open -a Docker)",
|
||||
"Bash(dockerd)",
|
||||
"Bash(brew services:*)",
|
||||
"Bash(colima start:*)",
|
||||
"Bash(colima status:*)",
|
||||
"Bash(colima stop:*)",
|
||||
"Bash(bash *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(ls -lh aInventory-PROD-v*.zip)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
8
.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.
|
||||
@@ -38,7 +41,10 @@ backend/config/ldap_config.json
|
||||
# ── Environment files (secrets) ──────────────────────────────
|
||||
.env
|
||||
.env.*
|
||||
inventory.env
|
||||
inventory.env.*
|
||||
!.env.example
|
||||
!inventory.env.example
|
||||
backend/.env
|
||||
backend/.env.*
|
||||
!backend/.env.example
|
||||
|
||||
28
AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# AGENTS.md — Web App Project Rules
|
||||
|
||||
## Tech Stack
|
||||
- Frontend: Next.js 15, React 19, TypeScript 5
|
||||
- Styling: Tailwind CSS v3.4, Lucide React (Icons)
|
||||
- Backend: Python 3.14, FastAPI, Uvicorn
|
||||
- Database: SQLite (SQLAlchemy) + Dexie (IndexedDB pentru offline-first)
|
||||
- AI & Vision: Google Gemini 2.0 (Vision), Tesseract.js (Local OCR)
|
||||
- Infrastructure: Docker, Caddy (Automatic SSL Reverse Proxy)
|
||||
- Security: JWT (python-jose), LDAP (Enterprise Integration)
|
||||
|
||||
## Code Quality
|
||||
- Keep cyclomatic complexity under 10 per function
|
||||
- Aim for files under 300 lines
|
||||
|
||||
## 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
|
||||
- Never log API keys, tokens or passwords
|
||||
- Validate all user input before processing
|
||||
|
||||
## Git Conventions
|
||||
- Use conventional commits (feat:, fix:, docs:, etc.)
|
||||
- Keep PRs under 400 lines of diff when possible
|
||||
@@ -11,10 +11,11 @@ This is the **Single Source of Truth** for ALL AI agents. Refer to [PROJECT_ARCH
|
||||
- **STRICT HANDOVER**: Update `dev_docs/SESSION_STATE.md` at the end of every task with: **Active AI**, **Current Status** (Stable/Broken/In-Progress), **Context**, and **Next Steps**.
|
||||
- **SESSION ARCHIVE**: Move previous handover content to `dev_docs/SESSION_HISTORY.md` before writing new state.
|
||||
- **NO INTERACTION OVERLAP**: Never modify a file if another AI session is explicitly working on it.
|
||||
- **CONCISE COMMUNICATION**: Be concise! Do not explain a thousand details unless they are absolutely necessary.
|
||||
|
||||
## 2. ENGINEERING & OPERATIONAL LAWS
|
||||
- **ENGLISH ONLY**: Interfaces, code, variables, and docs MUST be in English. Translate any Romanian text found in code immediately. (User conversation: Romanian/English).
|
||||
- **GIT PROTOCOL**: Use the direct binary path in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`) for ALL Git operations. **DO NOT REMOVE OR CHANGE THIS PATH UNDER ANY CIRCUMSTANCES!** Never push or use `--force` unless explicitly asked. Branching: `master` (stable), `dev` (active), `vX` (archive).
|
||||
- **GIT PROTOCOL**: Use the direct binary path `/Library/Developer/CommandLineTools/usr/bin/git`) for ALL Git operations. **DO NOT REMOVE OR CHANGE THIS PATH UNDER ANY CIRCUMSTANCES!** Never push or use `--force` unless explicitly asked. Branching: `master` (stable), `dev` (active), `vX` (archive).
|
||||
- **VERSIONING**: Update `VERSION.json` on every commit. Use `scripts/save_version.py` for automated releases.
|
||||
- **DEPENDENCIES**: Update `backend/requirements.txt` with version constraints for every new pip package.
|
||||
- **SSOT INTEGRITY**: Every feature change MUST update: `README.md`, `USER_GUIDE.md`, `PROJECT_ARCHITECTURE.md`, and `export_prod.sh`.
|
||||
|
||||
@@ -8,4 +8,7 @@ Before taking any action or writing code, you MUST read the following Single Sou
|
||||
2. `PROJECT_ARCHITECTURE.md` (Contains the tech stack, data models, and system logic).
|
||||
3. `dev_docs/SESSION_STATE.md` (Contains the current handover status from the previous AI).
|
||||
|
||||
**STRICT COMMUNICATION RULE:**
|
||||
Be concise! Do not explain a thousand details unless they are absolutely necessary.
|
||||
|
||||
Do not proceed with any task until you have analyzed these three files.
|
||||
|
||||
@@ -8,4 +8,7 @@ Before taking any action or writing code, you MUST read the following Single Sou
|
||||
2. `PROJECT_ARCHITECTURE.md` (Contains the tech stack, data models, and system logic).
|
||||
3. `dev_docs/SESSION_STATE.md` (Contains the current handover status from the previous AI).
|
||||
|
||||
**STRICT COMMUNICATION RULE:**
|
||||
Be concise! Do not explain a thousand details unless they are absolutely necessary.
|
||||
|
||||
Do not proceed with any task until you have analyzed these three files.
|
||||
|
||||
@@ -12,21 +12,29 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
|
||||
- **Database:** SQLite (SQLAlchemy) - Local file-based persistence
|
||||
- **Validation:** Pydantic v2
|
||||
- **Auth:** Hybrid LDAP (python-ldap) + PBKDF2 local password hash caching
|
||||
- **AI Engine:** Google GenAI SDK (Gemini 2.0 Flash) - Location: `backend/ai/`
|
||||
- **AI Engine:** Google GenAI SDK (Gemini 2.0 Flash) & Anthropic SDK (Claude 3.5 Sonnet) - Location: `backend/ai/`
|
||||
- **Testing:** Pytest (Unit & Integration) - Location: `backend/tests/`
|
||||
|
||||
### 2.2 Frontend (Web & PWA)
|
||||
- **Architecture:** Next.js 15+ (App Router)
|
||||
- **Styling:** Tailwind CSS (Readability-first config)
|
||||
- **Styling:** Tailwind CSS (Readability-first config, mobile-first responsive)
|
||||
- **Icons:** Lucide Icons (React components)
|
||||
- **Components:**
|
||||
- **StatCard** (v1.9.21+): Responsive stat display component for mobile/desktop
|
||||
- Two-column flexbox layout (label left, number right)
|
||||
- Responsive font sizing with Tailwind breakpoints (text-sm→md, text-lg→xl)
|
||||
- Label truncation with ellipsis for overflow handling
|
||||
- Accessibility: `role="status"`, `aria-hidden` on decorative icons
|
||||
- **Offline persistence:** Dexie.js (IndexedDB wrapper)
|
||||
- **Scanner:** `html5-qrcode` (Client-side, offline-only)
|
||||
- **Sync:** Axios with bulk-sync idempotency (UUID-based)
|
||||
- **Testing:** Vitest (React Hook Testing) - Location: `frontend/tests/`
|
||||
|
||||
### 2.3 Operations & Tooling
|
||||
- **PWA Deployment:** `next-pwa` (Service Workers + Manifest.json)
|
||||
- **HTTPS Proxy:** `caddy` or `local-ssl-proxy` (Port 8909)
|
||||
- **Servers:** Frontend (Port 8907), Backend (Port 8906)
|
||||
- **Configuration:** Centrally managed via root `inventory.env` (Network/CORS/API Keys) and `config/` directory (LDAP, Caddyfile).
|
||||
- **Configuration:** Centrally managed via root `inventory.env` (Network/CORS/API Keys), `config/` directory (LDAP, Caddyfile), and a dynamic `ConfigManager` (`backend/config_manager.py`) for runtime environment and AI provider settings.
|
||||
|
||||
## 3. Data Models & Entities
|
||||
- **Item:** Name, Category Group (Structured), Item Type (Specific), Quantity, Barcode, Part Number, Box Label (Association).
|
||||
@@ -37,7 +45,8 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
|
||||
## 4. Scanning & Optimization Strategy (Crucial)
|
||||
### 4.1 AI Usage Policy
|
||||
- **Routine Operations (Check-in/Out):** Executes entirely on the local device unconditionally using `html5-qrcode` ($0 cost). No AI is allowed here.
|
||||
- **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash`). The user takes a photo, AI extracts data based on strict templates.
|
||||
- **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash` or `claude-3-5-sonnet`). The user takes a photo, AI extracts data based on strict templates.
|
||||
- **AI Provider Selection (v1.9.23):** Administrators can choose between Gemini and Claude via the Admin Dashboard. API keys are managed securely in the environment.
|
||||
- **AI Box Discovery Mode (v1.6.0):** Supports specialized `mode="box"` prompt that focuses exclusively on prominent container names/hand-written labels, ignoring technical spec noise.
|
||||
- **Validation Mask:** AI-extracted data is NEVER saved directly. It is presented in a validation UI for human confirmation.
|
||||
|
||||
@@ -97,3 +106,11 @@ To ensure enterprise-grade protection, the following policies are enforced:
|
||||
To ensure deployment stability on macOS environments with potentially broken developer tool links (`xcode-select` errors):
|
||||
- **Direct Binary Mapping:** The system bypasses path resolution by using a hardcoded direct link to the Git binary in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`).
|
||||
- **Persistence Mandate:** This path is protected by mandatory AI rules and must never be removed or modified to ensure `save-version` and automated deployment scripts remain functional.
|
||||
106:
|
||||
107: ## 8. Multi-AI Engine & Dynamic Configuration (v1.9.23)
|
||||
108: To enhance extraction flexibility and system resilience:
|
||||
109: - **Unified AI Core:** The backend uses an abstraction layer to handle multiple AI providers (Gemini and Claude).
|
||||
110: - **Dynamic Configuration:** System settings (AI Provider, API Keys, Backup Policies) are managed via `backend/config_manager.py`, allowing real-time updates without restarting the container.
|
||||
111: - **Admin Standardization:** The Admin Dashboard features a standardized configuration UI with secure field masking for sensitive credentials.
|
||||
- **Architectural Modularization (v1.10.0):** The monolithic Admin and Inventory pages have been refactored into a modular architecture. Frontend logic is decoupled into custom React hooks (`useAdmin`), and UI is extracted into standalone components (`IdentityManager`, `DatabaseManager`, etc.). Backend admin endpoints are split into domain-specific routers (`backups`, `config`) for improved maintainability.
|
||||
- **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.
|
||||
|
||||
25
README.md
@@ -39,13 +39,36 @@ To generate a clean production package and snapshot the current state:
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Recent UI/UX Improvements (v1.9.21+)
|
||||
|
||||
### Mobile-First Responsive Design
|
||||
- **StatCard Component:** Reusable, responsive stat display component for mobile phones
|
||||
- Two-column flexbox layout (label left, number right) prevents text overflow on narrow screens
|
||||
- Responsive font sizing: `text-sm md:text-base` (labels), `text-lg md:text-xl` (numbers)
|
||||
- Automatic label truncation with ellipsis for long text
|
||||
- Accessible markup with `role="status"` and `aria-hidden` attributes
|
||||
- **Pages Updated:** Inventory (Categories, Item Types, Total Boxes), Logs (Total Events, Check in/out), Admin (Local Archives)
|
||||
- **Tested on:** iPhone SE (375px), iPhone 12 (390px), iPhone 14 Pro Max (430px), tablets (768px), desktop (1024px)
|
||||
- **Multi-AI Selection & Secure Config (v1.9.23):**
|
||||
- Support for both **Google Gemini** and **Anthropic Claude** for AI label extraction.
|
||||
- Dynamically configurable AI providers via the Admin Dashboard.
|
||||
- Centralized `ConfigManager` for secure API key and environment management.
|
||||
- **Admin UI Standardization:** Refactored Admin page with unified StatCards and secure input masking for system credentials.
|
||||
|
||||
## 🏗 Technical Overview
|
||||
* **Backend:** FastAPI (Python 3.12+)
|
||||
* **Frontend:** Next.js 15+ (React PWA)
|
||||
* **Frontend:** Next.js 15+ (React PWA) with responsive Tailwind CSS
|
||||
* **Database:** SQLite (SQLAlchemy) with Dexie.js (IndexedDB) for client-side sync.
|
||||
* **Proxy:** Caddy (Docker) or local-ssl-proxy (Standalone/Dev).
|
||||
* **AI Engine:** Google Gemini (Generative AI SDK).
|
||||
|
||||
## 🧪 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -138,6 +138,7 @@ Ensure you have internet connectivity and wait a moment. Synchronization happens
|
||||
- The image size must not exceed 10 MB.
|
||||
- The application supports JPEG, PNG, WebP, and GIF formats.
|
||||
- If the AI service is unavailable, try again later or contact your administrator.
|
||||
- **AI Provider Toggle:** In the Admin Dashboard, you can choose between **Gemini** and **Claude** for label extraction. If one provider is slow or failing, your administrator can switch to the other seamlessly.
|
||||
|
||||
---
|
||||
|
||||
@@ -149,5 +150,5 @@ For detailed technical documentation, see the [Project Architecture](../PROJECT_
|
||||
|
||||
---
|
||||
|
||||
**Version:** v1.9.19
|
||||
**Last Updated:** 2026-04-14
|
||||
**Version:** v1.9.24
|
||||
**Last Updated:** 2026-04-15
|
||||
|
||||
BIN
_images.tests/IMG_6082.jpeg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
_images.tests/IMG_6106.jpeg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
_images.tests/IMG_6107.jpeg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
_images.tests/IMG_6108.jpeg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
_images.tests/IMG_6109.jpeg
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
_images.tests/IMG_6110.jpeg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
_images.tests/IMG_6111.jpeg
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
_images.tests/IMG_6112.jpeg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
_images.tests/IMG_6113.jpeg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
_images.tests/IMG_6114.jpeg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
_images.tests/IMG_6115.jpeg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
_images.tests/IMG_6116.jpeg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
_images.tests/IMG_6117.jpeg
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
_images.tests/IMG_6118.jpeg
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
_images.tests/IMG_6119.jpeg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
_images.tests/IMG_6120.jpeg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
_images.tests/IMG_6121.jpeg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
_images.tests/IMG_6122.jpeg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
_images.tests/IMG_6123.jpeg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
_images.tests/IMG_6124.jpeg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
_images.tests/IMG_6125.jpeg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
_images.tests/IMG_6126.jpeg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
_images.tests/IMG_6127.jpeg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
_images.tests/IMG_6128.jpeg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
_images.tests/IMG_6129.jpeg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
_images.tests/IMG_6130.jpeg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
_images.tests/IMG_6131.jpeg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
_images.tests/IMG_6132.jpeg
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
_images.tests/IMG_6133.jpeg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
_images.tests/IMG_6134.jpeg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
_images.tests/IMG_6135.jpeg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
_images.tests/IMG_6136.jpeg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
_images.tests/IMG_6137.jpeg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
_images.tests/IMG_6139.jpeg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
_images.tests/IMG_6140.jpeg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
_images.tests/IMG_6141.jpeg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
_images.tests/IMG_6142.jpeg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
_images.tests/IMG_6143.jpeg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
_images.tests/IMG_6144.jpeg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
_images.tests/IMG_6145.jpeg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
_images.tests/IMG_6146.jpeg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
_images.tests/IMG_6147.jpeg
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
_images.tests/IMG_6148.jpeg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
_images.tests/IMG_6149.jpeg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
_images.tests/IMG_6150.jpeg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
_images.tests/IMG_6151.jpeg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
_images.tests/IMG_6152.jpeg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
_images.tests/IMG_6153.jpeg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
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).
|
||||
0
backend/__init__.py
Normal file
@@ -1,42 +1,50 @@
|
||||
import os
|
||||
import json
|
||||
import io
|
||||
import logging
|
||||
from PIL import Image
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
log = logging.getLogger("ainventory")
|
||||
|
||||
def get_best_models():
|
||||
# Using the exact models discovered via diagnostic
|
||||
return ["gemini-2.0-flash", "gemini-2.5-flash"]
|
||||
# Priority list based on aliases and future-gen models found in user logs
|
||||
return [
|
||||
"gemini-flash-latest", # Points to the best stable Flash version
|
||||
"gemini-3.1-flash-lite-preview", # Cutting edge 3.1 Lite
|
||||
"gemini-flash-lite-latest", # Best Lite alias
|
||||
"gemini-pro-latest" # Pro fallback alias
|
||||
]
|
||||
|
||||
def extract(image_bytes: bytes, prompt: str):
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
print("CRITICAL: GEMINI_API_KEY is MISSING in environment!")
|
||||
log.error("CRITICAL: GEMINI_API_KEY is MISSING in environment!")
|
||||
return None
|
||||
|
||||
# Log partial key for safety debug
|
||||
key_hint = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "too short"
|
||||
print(f"🔑 Using API Key: {key_hint}")
|
||||
log.info(f"🔑 Using API Key: {key_hint}")
|
||||
|
||||
try:
|
||||
# Initialize the NEW SDK Client forcing stable v1 API
|
||||
client = genai.Client(api_key=api_key, http_options={'api_version': 'v1'})
|
||||
# Switching to v1beta which is required for many experimental/new models
|
||||
client = genai.Client(api_key=api_key, http_options={'api_version': 'v1beta'})
|
||||
|
||||
# DEBUG: List allowed models for this key
|
||||
print("🔍 Checking available models for your key...")
|
||||
log.debug("🔍 Checking available models for your key...")
|
||||
try:
|
||||
for m in client.models.list():
|
||||
print(f" - Found: {m.name}")
|
||||
log.debug(f" - Found: {m.name}")
|
||||
except Exception as list_e:
|
||||
print(f" ⚠️ Could not list models: {list_e}")
|
||||
log.warning(f" ⚠️ Could not list models: {list_e}")
|
||||
|
||||
models_to_try = get_best_models()
|
||||
|
||||
# Try models in order
|
||||
for model_name in models_to_try:
|
||||
try:
|
||||
print(f"🚀 AI Launching (v2 SDK): {model_name}...")
|
||||
log.info(f"🚀 AI Launching (v2 SDK): {model_name}...")
|
||||
|
||||
# In the new SDK, we pass a list of parts (text string and image bytes)
|
||||
response = client.models.generate_content(
|
||||
@@ -48,10 +56,12 @@ def extract(image_bytes: bytes, prompt: str):
|
||||
)
|
||||
|
||||
if not response or not response.text:
|
||||
log.warning(f"⚠️ Gemini {model_name} returned NO text.")
|
||||
continue
|
||||
|
||||
text = response.text.strip()
|
||||
print(f"✅ AI Response Received ({len(text)} bytes)")
|
||||
log.info(f"✅ AI Response Received ({len(text)} bytes)")
|
||||
log.debug(f"FULL RESPONSE:\n{text}")
|
||||
|
||||
# Extract JSON block
|
||||
if "```json" in text:
|
||||
@@ -62,10 +72,10 @@ def extract(image_bytes: bytes, prompt: str):
|
||||
return json.loads(text)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini {model_name} failed: {e}")
|
||||
log.error(f"❌ Gemini {model_name} failed: {e}")
|
||||
continue
|
||||
|
||||
except Exception as outer_e:
|
||||
print(f"❌ Gemini Client Init failed: {outer_e}")
|
||||
log.error(f"❌ Gemini Client Init failed: {outer_e}")
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import os
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
from . import models
|
||||
from .database import SessionLocal
|
||||
from .ai import gemini, claude
|
||||
|
||||
# Load environment variables from the directory where this file resides
|
||||
# Note: Environment variables are managed centrally by config_loader.py
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
dotenv_path = os.path.join(base_dir, ".env")
|
||||
load_dotenv(dotenv_path)
|
||||
|
||||
class PromptManager:
|
||||
"""Manages AI prompt with auto-reloading from file."""
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = file_path
|
||||
self.last_mtime = 0
|
||||
self.cached_prompt = None
|
||||
|
||||
def get_prompt(self) -> str:
|
||||
if not os.path.exists(self.file_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
current_mtime = os.path.getmtime(self.file_path)
|
||||
if current_mtime > self.last_mtime or self.cached_prompt is None:
|
||||
with open(self.file_path, 'r', encoding='utf-8') as f:
|
||||
self.cached_prompt = f.read().strip()
|
||||
self.last_mtime = current_mtime
|
||||
# Use print or log for visibility in dev
|
||||
print(f"🔄 AI Vision prompt reloaded from {self.file_path} (mtime: {current_mtime})")
|
||||
return self.cached_prompt
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to reload AI prompt: {e}")
|
||||
return self.cached_prompt
|
||||
|
||||
# The prompt file is located in the global /config directory
|
||||
# We go up one level from backend/ to reach project root, then into config/
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(base_dir))
|
||||
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
|
||||
prompt_mgr = PromptManager(PROMPT_FILE_PATH)
|
||||
|
||||
def extract_label_info(image_bytes: bytes, mode: str = "item"):
|
||||
"""
|
||||
@@ -29,20 +61,37 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
|
||||
}
|
||||
"""
|
||||
else:
|
||||
# Fetch custom prompt from DB
|
||||
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
if setting:
|
||||
prompt = setting.value
|
||||
else:
|
||||
# Fallback to a sensible default if DB is not ready
|
||||
prompt = "Extract technical specs. Return JSON with name, category, description, connector, size, color, part_number, ocr_text, quantity."
|
||||
# 1. Try fetching from the configuration file first (SSOT)
|
||||
prompt = prompt_mgr.get_prompt()
|
||||
|
||||
if not prompt:
|
||||
# 2. Fallback to Database if file is missing
|
||||
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
if setting:
|
||||
prompt = setting.value
|
||||
else:
|
||||
# 3. Final fallback to hardcoded default
|
||||
prompt = "Extract technical specs. Return JSON with name, category, description, connector, size, color, part_number, ocr_text, quantity."
|
||||
|
||||
# 1. Try Gemini
|
||||
result = gemini.extract(image_bytes, prompt)
|
||||
# 0. Get Active Provider from DB
|
||||
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||
active_provider = provider_setting.value if provider_setting else "gemini"
|
||||
|
||||
# 1. Execute extraction based on selection
|
||||
result = None
|
||||
if active_provider == "claude":
|
||||
print(f"📡 Using Anthropic Claude for extraction...")
|
||||
result = claude.extract(image_bytes, prompt)
|
||||
else:
|
||||
print(f"📡 Using Google Gemini for extraction...")
|
||||
result = gemini.extract(image_bytes, prompt)
|
||||
|
||||
if result:
|
||||
# Map user-defined prompt keys to model fields if needed
|
||||
# User keys: Item, Type, Description, Category, Connector, Size, Color, PartNr, OCR
|
||||
# Check if AI returned a list of items or a singular object
|
||||
raw_items = result.get("items") or result.get("Items")
|
||||
was_list = isinstance(raw_items, list)
|
||||
items_to_map = raw_items if was_list else [result]
|
||||
|
||||
mapping = {
|
||||
"Item": "name",
|
||||
"Type": "type",
|
||||
@@ -55,23 +104,32 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
|
||||
"OCR": "ocr_text"
|
||||
}
|
||||
|
||||
final_result = {}
|
||||
for ai_key, model_key in mapping.items():
|
||||
if ai_key in result:
|
||||
final_result[model_key] = result[ai_key]
|
||||
elif model_key in result: # Already mapped or using model keys
|
||||
final_result[model_key] = result[model_key]
|
||||
mapped_items = []
|
||||
for item_data in items_to_map:
|
||||
final_item = {}
|
||||
for ai_key, model_key in mapping.items():
|
||||
val = item_data.get(ai_key) or item_data.get(model_key)
|
||||
if val and isinstance(val, str):
|
||||
final_item[model_key] = val.strip()
|
||||
else:
|
||||
final_item[model_key] = val
|
||||
|
||||
# Default fields
|
||||
final_item["quantity"] = item_data.get("quantity", 1)
|
||||
raw_barcode = item_data.get("barcode") or item_data.get("PartNr") or item_data.get("part_number") or item_data.get("Part Number")
|
||||
final_item["barcode"] = str(raw_barcode).strip() if raw_barcode else f"AI-{int(time.time()*100)}"
|
||||
|
||||
# Handle Box mode specifically inside mapping
|
||||
if mode == "box":
|
||||
final_item["box_label"] = final_item.get("box_label") or item_data.get("Box") or final_item.get("name") or "Unknown Box"
|
||||
final_item["name"] = final_item["box_label"]
|
||||
|
||||
mapped_items.append(final_item)
|
||||
|
||||
# Ensure quantity and barcode are handled if returned or default
|
||||
final_result["quantity"] = result.get("quantity", 1)
|
||||
final_result["barcode"] = result.get("barcode", result.get("PartNr", result.get("part_number", "")))
|
||||
|
||||
# Handle Box mode specifically
|
||||
if mode == "box":
|
||||
final_result["box_label"] = result.get("box_label", result.get("name", "Unknown Box"))
|
||||
final_result["name"] = final_result["box_label"]
|
||||
|
||||
return final_result
|
||||
# Return either the whole list wrapper or the first item (legacy compatibility)
|
||||
if was_list:
|
||||
return {"items": mapped_items}
|
||||
return mapped_items[0] if mapped_items else {"error": "No items after mapping"}
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
34
backend/config_loader.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
log = logging.getLogger("ainventory")
|
||||
|
||||
def load_config():
|
||||
"""
|
||||
Centralized environment loader for TFM aInventory.
|
||||
Prioritizes existing environment variables (Docker),
|
||||
then inventory.env at project root, then backend/.env.
|
||||
"""
|
||||
# Base directory is backend/
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Project root is one level up
|
||||
project_root = os.path.dirname(base_dir)
|
||||
|
||||
inventory_env_path = os.path.join(project_root, "inventory.env")
|
||||
backend_env_path = os.path.join(base_dir, ".env")
|
||||
|
||||
# Check for inventory.env in root (Master Config)
|
||||
if os.path.exists(inventory_env_path):
|
||||
load_dotenv(inventory_env_path)
|
||||
log.info(f"✅ Loaded master configuration from {inventory_env_path}")
|
||||
|
||||
# Check for local backend/.env (Legacy/Fragmented)
|
||||
elif os.path.exists(backend_env_path):
|
||||
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.")
|
||||
|
||||
# Auto-run if imported
|
||||
load_config()
|
||||
90
backend/config_manager.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
class ConfigManager:
|
||||
"""Safely manages multi-line .env files without corrupting other content."""
|
||||
|
||||
@staticmethod
|
||||
def get_root_env_path():
|
||||
# backend/config_manager.py -> backend/ -> /
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(base_dir)
|
||||
return os.path.join(project_root, "inventory.env")
|
||||
|
||||
@staticmethod
|
||||
def update_keys(updates: dict):
|
||||
"""
|
||||
Updates specific keys in inventory.env.
|
||||
Preserves comments and order where possible.
|
||||
Appends new keys at the end if not found.
|
||||
"""
|
||||
env_path = ConfigManager.get_root_env_path()
|
||||
|
||||
if not os.path.exists(env_path):
|
||||
# Create a basic file if it doesn't exist (unlikely in this project)
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
f.write("# TFM aInventory — Generated Configuration\n")
|
||||
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
new_lines = []
|
||||
keys_to_process = set(updates.keys())
|
||||
processed_keys = set()
|
||||
|
||||
for line in lines:
|
||||
trimmed = line.strip()
|
||||
# Skip empty lines or comments when matching
|
||||
if not trimmed or trimmed.startswith('#'):
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
# Check if this line is a key assignment we want to update
|
||||
found_match = False
|
||||
for key in keys_to_process:
|
||||
if trimmed.startswith(f"{key}="):
|
||||
new_lines.append(f"{key}={updates[key]}\n")
|
||||
processed_keys.add(key)
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
new_lines.append(line)
|
||||
|
||||
# Append keys that weren't found in the file
|
||||
missing_keys = keys_to_process - processed_keys
|
||||
if missing_keys:
|
||||
if new_lines and not new_lines[-1].endswith('\n'):
|
||||
new_lines.append('\n')
|
||||
if new_lines and not new_lines[-1].strip() == '':
|
||||
new_lines.append('\n')
|
||||
|
||||
new_lines.append("# --- Automatically Added Keys ---\n")
|
||||
for key in missing_keys:
|
||||
new_lines.append(f"{key}={updates[key]}\n")
|
||||
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
# Force reload environment variables for the current process
|
||||
load_dotenv(env_path, override=True)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_masked_key(key_name: str):
|
||||
"""Returns a masked version of the environment variable."""
|
||||
val = os.environ.get(key_name)
|
||||
if not val:
|
||||
return None
|
||||
|
||||
# Determine prefix based on key type
|
||||
prefix = ""
|
||||
if key_name == "GEMINI_API_KEY":
|
||||
prefix = "G-"
|
||||
elif key_name == "CLAUDE_API_KEY":
|
||||
prefix = "sk-"
|
||||
|
||||
if len(val) <= 8:
|
||||
return f"{prefix}****"
|
||||
|
||||
return f"{prefix}****{val[-4:]}"
|
||||
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
from . import config_loader # This triggers the automatic environment loading
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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
|
||||
|
||||
@@ -26,9 +28,9 @@ ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
||||
|
||||
# Automatically add origins based on network_config.env variables if present
|
||||
server_ip = os.environ.get("SERVER_IP")
|
||||
front_port = os.environ.get("FRONTEND_PORT", "8907")
|
||||
front_ssl_port = os.environ.get("FRONTEND_SSL_PORT", "8909")
|
||||
back_ssl_port = os.environ.get("BACKEND_SSL_PORT", "8908")
|
||||
front_port = os.environ.get("FRONTEND_PORT", "8917")
|
||||
front_ssl_port = os.environ.get("FRONTEND_SSL_PORT", "8919")
|
||||
back_ssl_port = os.environ.get("BACKEND_SSL_PORT", "8918")
|
||||
|
||||
# Always allow localhost
|
||||
defaults = [
|
||||
@@ -86,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():
|
||||
@@ -121,12 +124,31 @@ def startup_event():
|
||||
if not existing:
|
||||
db.add(models.SystemSetting(key="ai_extraction_prompt", value=final_prompt))
|
||||
db.commit()
|
||||
log.info("Initialized default AI prompt.")
|
||||
log.info("Initialized default AI prompt in database.")
|
||||
# Refresh existing after commit
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
|
||||
# [NEW] Sync/Initialize AI prompt file in /config
|
||||
from .database import BASE_DIR
|
||||
PROJECT_ROOT = os.path.dirname(BASE_DIR)
|
||||
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
|
||||
|
||||
if not os.path.exists(PROMPT_FILE_PATH):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True)
|
||||
# Use DB value (which we just ensured exists)
|
||||
current_val = existing.value if existing else final_prompt
|
||||
with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f:
|
||||
f.write(current_val)
|
||||
log.info(f"✅ Initialized AI prompt configuration file: {PROMPT_FILE_PATH}")
|
||||
except Exception as fe:
|
||||
log.error(f"❌ Failed to initialize AI prompt file: {fe}")
|
||||
|
||||
defaults = {
|
||||
"backup_retention_count": "10",
|
||||
"backup_schedule_hour": "3",
|
||||
"backup_schedule_freq_days": "1"
|
||||
"backup_schedule_freq_days": "1",
|
||||
"ai_provider": "gemini"
|
||||
}
|
||||
for key, val in defaults.items():
|
||||
if not db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first():
|
||||
|
||||
@@ -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
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))
|
||||
208
backend/routers/admin/config.py
Normal file
@@ -0,0 +1,208 @@
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from ... import models, schemas, auth
|
||||
from ...database import get_db, BASE_DIR
|
||||
from ...scheduler import sync_scheduler_config
|
||||
from ...config_manager import ConfigManager
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/db",
|
||||
tags=["Admin Configuration"]
|
||||
)
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(BASE_DIR)
|
||||
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
|
||||
|
||||
@router.get("/settings", response_model=schemas.DbSettingsUpdate)
|
||||
def get_db_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Get database retention and scheduling settings."""
|
||||
retention = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first()
|
||||
hour = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_hour").first()
|
||||
freq = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_freq_days").first()
|
||||
|
||||
return {
|
||||
"retention_count": int(retention.value) if retention else 10,
|
||||
"schedule_hour": int(hour.value) if hour else 3,
|
||||
"schedule_freq_days": int(freq.value) if freq else 1
|
||||
}
|
||||
|
||||
@router.patch("/settings", response_model=schemas.DbSettingsUpdate)
|
||||
def update_db_settings(
|
||||
settings: schemas.DbSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update database settings and re-trigger scheduler sync."""
|
||||
pairs = {
|
||||
"backup_retention_count": str(settings.retention_count),
|
||||
"backup_schedule_hour": str(settings.schedule_hour),
|
||||
"backup_schedule_freq_days": str(settings.schedule_freq_days)
|
||||
}
|
||||
|
||||
for key, val in pairs.items():
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first()
|
||||
if existing:
|
||||
existing.value = val
|
||||
else:
|
||||
db.add(models.SystemSetting(key=key, value=val))
|
||||
|
||||
db.commit()
|
||||
sync_scheduler_config()
|
||||
return settings
|
||||
|
||||
@router.get("/settings/prompt")
|
||||
def get_ai_prompt(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Get the current AI extraction prompt."""
|
||||
if os.path.exists(PROMPT_FILE_PATH):
|
||||
try:
|
||||
with open(PROMPT_FILE_PATH, 'r', encoding='utf-8') as f:
|
||||
return {"value": f.read().strip(), "source": "file"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
if not setting:
|
||||
return {"value": "", "source": "none"}
|
||||
return {"value": setting.value, "source": "database"}
|
||||
|
||||
@router.post("/settings/prompt")
|
||||
def update_ai_prompt(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update the AI extraction prompt."""
|
||||
value = payload.get("value")
|
||||
if value is None:
|
||||
raise HTTPException(status_code=400, detail="Value required")
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True)
|
||||
with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f:
|
||||
f.write(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
if existing:
|
||||
existing.value = value
|
||||
else:
|
||||
db.add(models.SystemSetting(key="ai_extraction_prompt", value=value))
|
||||
|
||||
db.commit()
|
||||
return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}
|
||||
|
||||
@router.get("/settings/ai")
|
||||
def get_ai_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Check AI provider status and active provider."""
|
||||
gemini_key = os.environ.get("GEMINI_API_KEY")
|
||||
claude_key = os.environ.get("CLAUDE_API_KEY")
|
||||
|
||||
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||
active_provider = provider_setting.value if provider_setting else "gemini"
|
||||
|
||||
return {
|
||||
"active_provider": active_provider,
|
||||
"providers": [
|
||||
{
|
||||
"id": "gemini",
|
||||
"name": "Google Gemini 2.0",
|
||||
"configured": bool(gemini_key),
|
||||
"active": active_provider == "gemini",
|
||||
"masked_key": ConfigManager.get_masked_key("GEMINI_API_KEY")
|
||||
},
|
||||
{
|
||||
"id": "claude",
|
||||
"name": "Anthropic Claude 3.5",
|
||||
"configured": bool(claude_key),
|
||||
"active": active_provider == "claude",
|
||||
"masked_key": ConfigManager.get_masked_key("CLAUDE_API_KEY")
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@router.post("/settings/ai-keys")
|
||||
def update_ai_keys(
|
||||
payload: dict,
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update AI API keys."""
|
||||
gemini_key = payload.get("gemini_api_key")
|
||||
claude_key = payload.get("claude_api_key")
|
||||
|
||||
updates = {}
|
||||
if gemini_key:
|
||||
updates["GEMINI_API_KEY"] = gemini_key
|
||||
if claude_key:
|
||||
updates["CLAUDE_API_KEY"] = claude_key
|
||||
|
||||
if updates:
|
||||
ConfigManager.update_keys(updates)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")),
|
||||
"claude_configured": bool(os.environ.get("CLAUDE_API_KEY"))
|
||||
}
|
||||
|
||||
@router.post("/settings/test-ai-key")
|
||||
def test_ai_key(
|
||||
payload: dict,
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Test AI API key connectivity."""
|
||||
provider = payload.get("provider")
|
||||
key = payload.get("key")
|
||||
|
||||
if not provider or provider not in ["gemini", "claude"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid provider")
|
||||
|
||||
if not key or "****" in key:
|
||||
key = os.environ.get("GEMINI_API_KEY" if provider == "gemini" else "CLAUDE_API_KEY")
|
||||
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="No API key provided or configured")
|
||||
|
||||
try:
|
||||
if provider == "gemini":
|
||||
from google import genai
|
||||
client = genai.Client(api_key=key, http_options={'api_version': 'v1beta'})
|
||||
client.models.list()
|
||||
return {"status": "success", "message": "Google Gemini API connection verified!"}
|
||||
elif provider == "claude":
|
||||
import anthropic
|
||||
client = anthropic.Anthropic(api_key=key)
|
||||
client.models.list(limit=1)
|
||||
return {"status": "success", "message": "Anthropic Claude API connection verified!"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {str(e)}")
|
||||
|
||||
@router.post("/settings/ai")
|
||||
def update_ai_provider(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update the active AI provider."""
|
||||
provider = payload.get("provider")
|
||||
if provider not in ["gemini", "claude"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid provider")
|
||||
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||
if existing:
|
||||
existing.value = provider
|
||||
else:
|
||||
db.add(models.SystemSetting(key="ai_provider", value=provider))
|
||||
|
||||
db.commit()
|
||||
return {"status": "success", "active_provider": provider}
|
||||
@@ -1,167 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from .. import models, schemas, auth
|
||||
from ..database import get_db
|
||||
from ..db_manager import DbManager
|
||||
from ..scheduler import sync_scheduler_config
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import UploadFile, File
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/db",
|
||||
tags=["Admin Database"]
|
||||
)
|
||||
|
||||
@router.get("/backups", response_model=List[schemas.BackupInfo])
|
||||
def get_backups(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""List available database backups."""
|
||||
return DbManager.get_backup_list()
|
||||
|
||||
@router.get("/stats", response_model=schemas.DatabaseStats)
|
||||
def get_db_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Get database backup storage statistics."""
|
||||
return DbManager.get_stats()
|
||||
|
||||
@router.post("/backup", response_model=schemas.BackupInfo)
|
||||
def trigger_manual_backup(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Trigger a manual database backup."""
|
||||
filename = DbManager.create_backup(db, label="manual", user_id=current_admin.sub)
|
||||
# Re-fetch the newly created file info
|
||||
backups = DbManager.get_backup_list()
|
||||
for b in backups:
|
||||
if b.filename == filename:
|
||||
return b
|
||||
raise HTTPException(status_code=500, detail="Backup created but info not found")
|
||||
|
||||
@router.post("/restore")
|
||||
def restore_database(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Restore database from a specific file. DANGEROUS."""
|
||||
filename = payload.get("filename")
|
||||
confirm = payload.get("confirm", False)
|
||||
|
||||
if not filename:
|
||||
raise HTTPException(status_code=400, detail="Filename required")
|
||||
if not confirm:
|
||||
raise HTTPException(status_code=400, detail="Confirmation required")
|
||||
|
||||
try:
|
||||
success = DbManager.restore_backup(filename, db, user_id=current_admin.sub)
|
||||
return {"status": "success", "message": f"Database restored from {filename}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/settings", response_model=schemas.DbSettingsUpdate)
|
||||
def get_db_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Get database retention and scheduling settings."""
|
||||
# Ensure default settings exist
|
||||
retention = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first()
|
||||
hour = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_hour").first()
|
||||
freq = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_freq_days").first()
|
||||
|
||||
return {
|
||||
"retention_count": int(retention.value) if retention else 10,
|
||||
"schedule_hour": int(hour.value) if hour else 3,
|
||||
"schedule_freq_days": int(freq.value) if freq else 1
|
||||
}
|
||||
|
||||
@router.patch("/settings", response_model=schemas.DbSettingsUpdate)
|
||||
def update_db_settings(
|
||||
settings: schemas.DbSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update database settings and re-trigger scheduler sync."""
|
||||
pairs = {
|
||||
"backup_retention_count": str(settings.retention_count),
|
||||
"backup_schedule_hour": str(settings.schedule_hour),
|
||||
"backup_schedule_freq_days": str(settings.schedule_freq_days)
|
||||
}
|
||||
|
||||
for key, val in pairs.items():
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first()
|
||||
if existing:
|
||||
existing.value = val
|
||||
else:
|
||||
db.add(models.SystemSetting(key=key, value=val))
|
||||
|
||||
db.commit()
|
||||
# Re-trigger scheduler sync
|
||||
sync_scheduler_config()
|
||||
return settings
|
||||
|
||||
@router.get("/export")
|
||||
def export_database(
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Download the current database file."""
|
||||
try:
|
||||
path = DbManager.export_db()
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/x-sqlite3",
|
||||
filename="inventory_export.db"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/import")
|
||||
async def import_database(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Upload and replace the current database. DANGEROUS."""
|
||||
contents = await file.read()
|
||||
try:
|
||||
DbManager.import_db(contents, db, user_id=current_admin.sub)
|
||||
return {"status": "success", "message": "Database successfully imported and replaced."}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@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."""
|
||||
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
if not setting:
|
||||
return {"value": ""}
|
||||
return {"value": setting.value}
|
||||
|
||||
@router.post("/settings/prompt")
|
||||
def update_ai_prompt(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""Update the AI extraction prompt."""
|
||||
value = payload.get("value")
|
||||
if value is None:
|
||||
raise HTTPException(status_code=400, detail="Value required")
|
||||
|
||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||
if existing:
|
||||
existing.value = value
|
||||
else:
|
||||
db.add(models.SystemSetting(key="ai_extraction_prompt", value=value))
|
||||
|
||||
db.commit()
|
||||
return {"status": "success"}
|
||||
@@ -18,21 +18,7 @@ def get_categories(
|
||||
current_user: auth.TokenData = Depends(auth.get_current_user)
|
||||
):
|
||||
"""[C-01] List of categories — only for authenticated users."""
|
||||
categories = db.query(models.Category).all()
|
||||
# Auto-seed if empty with defaults mentioned by user
|
||||
if not categories:
|
||||
defaults = [
|
||||
{"name": "Connectors", "description": "Conectica: cables, adapters, plugs"},
|
||||
{"name": "Spare Parts", "description": "Piese de schimb: specific components"},
|
||||
{"name": "Tools", "description": "Hand and power tools"},
|
||||
{"name": "Consumables", "description": "One-time use items"}
|
||||
]
|
||||
for d in defaults:
|
||||
cat = models.Category(**d)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
return db.query(models.Category).all()
|
||||
return categories
|
||||
return db.query(models.Category).all()
|
||||
|
||||
@router.post("/", response_model=schemas.Category)
|
||||
def create_category(
|
||||
|
||||
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
@@ -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
|
||||
37
config/ai_prompt.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Technical Inventory Multi-Label Extraction Protocol
|
||||
|
||||
Your goal is to extract precise hardware specifications for ALL relevant inventory items found in the image.
|
||||
|
||||
## Intelligence Rules:
|
||||
1. **Filtering Strategy**:
|
||||
- **INCLUDE**: Standalone physical hardware, modules, cables, servers, and storage units.
|
||||
- **EXCLUDE**: Generic mounting hardware (screws, nails, rails, brackets), paper licenses/documentation, or empty packaging, UNLESS they are the primary and only subject of the image.
|
||||
- In cases like image labels listing multiple SKUs (e.g., 5m cable and 7m cable), TREAT EACH LINE AS A SEPARATE ITEM.
|
||||
|
||||
2. **Field Definitions**:
|
||||
- **Item**: Primary identity (Manufacturer + Core Class). Max 3 words (e.g., "Cisco SFP Module").
|
||||
- **Type**: Asset classification (e.g., "SFP", "patch cords", "NVMe").
|
||||
- **Description**: Technical summary emphasizing speed/capacity. Max 5 words.
|
||||
- **Category**: Broad ecosystem (e.g., "Network", "Storage").
|
||||
- **Connector**: Physical interface (e.g., "LC/UPC", "RJ45").
|
||||
- **Size**: Capacity or Length (e.g., "1.6TB", "5m").
|
||||
- **Color**: Physical distinguish color.
|
||||
- **PartNr**: Absolute technical identifier (P/N, SKU). Omit Serial Numbers.
|
||||
- **OCR**: Concise technical string for local heuristic matching. **EXCLUDE ALL SERIAL NUMBERS.**
|
||||
|
||||
## Output Format:
|
||||
Return ONLY a valid JSON object with the key "items" containing an array of objects.
|
||||
Example:
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"Item": "SFP Module",
|
||||
"Type": "SFP",
|
||||
"Description": "64G Gen7 Fibre Channel",
|
||||
"Category": "Network",
|
||||
"Connector": "LC",
|
||||
"PartNr": "SFP-64G-G7",
|
||||
"OCR": "SFP 64G GEN7 FC"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
# ============================================================
|
||||
# TFM aInventory — Backend Environment Variables
|
||||
# Copy this file to .env and fill in real values.
|
||||
# NEVER commit the real .env file to Git!
|
||||
# ============================================================
|
||||
|
||||
# --- AI API Keys ---
|
||||
# Google Gemini API Key (Required for AI label OCR onboarding)
|
||||
# You can also set this in the root 'inventory.env' for Docker convenience.
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# --- Security ---
|
||||
# JWT secret key — generate a strong random value for production:
|
||||
# python3 -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
JWT_SECRET_KEY=change-me-generate-a-secure-random-value
|
||||
|
||||
# --- CORS & External Access ---
|
||||
# The system automatically allows localhost and the local LAN IP.
|
||||
# Use EXTRA_ALLOWED_ORIGINS for Tailscale, VPNs, or FQDNs.
|
||||
# Example: EXTRA_ALLOWED_ORIGINS=100.78.182.27,inventory.my-domain.com
|
||||
EXTRA_ALLOWED_ORIGINS=
|
||||
|
||||
# --- Data Paths (usually managed by startup scripts) ---
|
||||
# DATA_DIR=/app/data
|
||||
# LOGS_DIR=/app/logs
|
||||
@@ -200,4 +200,20 @@ Obiectiv: audit de securitate complet înainte de producție.
|
||||
- `ALLOWED_ORIGINS` — auto-detected
|
||||
- `DATA_DIR` — absolute path
|
||||
- `JWT_SECRET_KEY` — ephemeral (regenerates on restart)
|
||||
\n---\n
|
||||
## [Archived] Gemini (Antigravity) — 2026-04-13 — CORS & Config Centralization
|
||||
|
||||
**Active AI:** Gemini (Antigravity)
|
||||
**Archived:** 2026-04-14
|
||||
**Version:** v1.9.18 | **Branch:** dev
|
||||
|
||||
### Status
|
||||
STABLE — GENERIC CORS & CONFIG CENTRALIZATION COMPLETE.
|
||||
The CORS system has been upgraded to support `EXTRA_ALLOWED_ORIGINS` in `inventory.env`.
|
||||
|
||||
### What was done
|
||||
1. **Generic CORS**: Introduced `EXTRA_ALLOWED_ORIGINS` in `inventory.env`. Backend expansion in `main.py` handles all required ports.
|
||||
2. **Config Centralization**: `inventory.env` is now the Primary SSOT for networking and AI keys.
|
||||
3. **Startup**: Enhanced `start_server.sh` with better LAN/VPN URL discovery and display.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,58 +1,347 @@
|
||||
# CURRENT AI WORKING SESSION — HANDOVER
|
||||
|
||||
**Active AI:** Gemini (Antigravity)
|
||||
**Last Updated:** 2026-04-13
|
||||
**Current Version:** v1.9.18 (CORS Sync)
|
||||
**Branch:** dev
|
||||
**Active AI:** Claude (Haiku)
|
||||
**Last Updated:** 2026-04-15
|
||||
**Current Version:** v1.9.21 (Docker-Ready)
|
||||
**Branch:** dev (Task 5 complete - Mobile viewport testing done)
|
||||
|
||||
---
|
||||
|
||||
## STATUS: 🟢 STABLE — GENERIC CORS & CONFIG CENTRALIZATION COMPLETE
|
||||
## STATUS: 🟢 STABLE — MOBILE VIEWPORT TESTING COMPLETE
|
||||
|
||||
**CRITICAL FOR NEXT AI:** The CORS system has been upgraded to support `EXTRA_ALLOWED_ORIGINS` in `inventory.env`. The backend automatically expands these into full URLs (http/https across all ports).
|
||||
**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.
|
||||
|
||||
### Task 5: Mobile Viewport Testing [COMPLETED]
|
||||
|
||||
**Test Date:** 2026-04-15
|
||||
**Tester:** Claude (Haiku)
|
||||
|
||||
#### Mobile Widths Tested
|
||||
|
||||
1. **320px (iPhone SE smallest)**
|
||||
- ✓ No overflow on stat cards
|
||||
- ✓ Labels truncated with ellipsis ("Categ...", "Item T...", "Total B...")
|
||||
- ✓ Numbers visible and properly aligned
|
||||
- ✓ Icons display correctly
|
||||
- ✓ Layout remains stable
|
||||
|
||||
2. **375px (iPhone SE)**
|
||||
- ✓ No overflow
|
||||
- ✓ Labels fully visible on Inventory page ("Categories", "Item Types", "Total Boxes")
|
||||
- ✓ Stat cards use 2-column layout
|
||||
- ✓ Icons and numbers properly spaced
|
||||
- ✓ Custom div components (Top Operator, Storage Status) render correctly
|
||||
|
||||
3. **390px (iPhone 12)**
|
||||
- ✓ No overflow
|
||||
- ✓ Labels fully visible across all three pages
|
||||
- ✓ Responsive font sizes applied (text-sm md:text-base)
|
||||
- ✓ Icons scaled appropriately
|
||||
- ✓ Consistent spacing and alignment
|
||||
|
||||
4. **430px (iPhone 14 Pro Max)**
|
||||
- ✓ No overflow
|
||||
- ✓ Extra spacious layout
|
||||
- ✓ All content easily readable
|
||||
- ✓ Responsive breakpoints working correctly
|
||||
|
||||
#### Tablet Width (768px)
|
||||
- ✓ 3-column grid layout for stat cards (Inventory page)
|
||||
- ✓ Responsive font sizes (md: breakpoint active)
|
||||
- ✓ Increased padding and spacing
|
||||
- ✓ Labels fully visible
|
||||
- ✓ Icons scale correctly
|
||||
|
||||
#### Desktop Width (1024px)
|
||||
- ✓ Full layout rendering correctly
|
||||
- ✓ Max-width constraints applied
|
||||
- ✓ Stat cards display with proper spacing
|
||||
- ✓ Typography hierarchy maintained
|
||||
- ✓ All pages render without issues
|
||||
|
||||
#### Pages Verified
|
||||
|
||||
**Inventory Page (mobile 375px):**
|
||||
- ✓ Categories [2] — visible with icon
|
||||
- ✓ Item Types [6] — visible with icon
|
||||
- ✓ Total Boxes [2] — visible with icon
|
||||
- ✓ 2-column layout on mobile, 3-column on tablet
|
||||
- ✓ No text cutoff or overflow
|
||||
|
||||
**Logs Page (mobile 375px):**
|
||||
- ✓ Total Events [34] — visible (truncated at 320px: "Tot...")
|
||||
- ✓ Check in [7] — visible
|
||||
- ✓ Check out [7] — visible
|
||||
- ✓ Top Operator [bede] — custom div displays correctly
|
||||
- ✓ 2-column layout responsive
|
||||
|
||||
**Admin Page (mobile 375px):**
|
||||
- ✓ Local Archives [2] — StatCard renders correctly
|
||||
- ✓ Storage Status [Online] — custom div displays status
|
||||
- ✓ System Integrity section displays properly
|
||||
- ✓ No overflow on any viewport size
|
||||
|
||||
#### Build Verification
|
||||
- **Build Result:** ✓ Compiled successfully
|
||||
- **Build Time:** 1951ms
|
||||
- **TypeScript Errors:** NONE
|
||||
- **No regressions detected**
|
||||
|
||||
#### Success Criteria - ALL MET
|
||||
- ✓ No content overflow on any mobile width (320px-430px)
|
||||
- ✓ Responsive font sizes apply correctly (sm→md→lg breakpoints)
|
||||
- ✓ Icons display and scale correctly
|
||||
- ✓ Labels truncate with ellipsis if too long (verified at 320px)
|
||||
- ✓ Numbers never wrap (whitespace-nowrap working)
|
||||
- ✓ Build passes with no errors
|
||||
- ✓ No regressions on desktop/tablet layouts
|
||||
|
||||
---
|
||||
|
||||
## WHAT WAS DONE THIS SESSION
|
||||
### Task 4 Follow-up Complete: Icon Refinement for Local Archives
|
||||
|
||||
### 1. Generic CORS (External Access)
|
||||
- **Variable**: Introduced `EXTRA_ALLOWED_ORIGINS` in `inventory.env`.
|
||||
- **Backend Expansion**: Updated `backend/main.py` to automatically generate allowed origins (plain/SSL) for any IP or FQDN provided in this comma-separated list.
|
||||
- **Tailscale Ready**: Pre-configured with `100.78.182.27` as requested by the user.
|
||||
**File Modified:** `frontend/app/admin/page.tsx`
|
||||
|
||||
### 2. Configuration Centralization
|
||||
- **inventory.env Updates**: Added placeholders for `GEMINI_API_KEY` and security tokens to encourage usage of a single configuration file for both local and Docker deployments.
|
||||
- **Port Consistency**: Cleaned up `config/backend.env.example` to reflect the actual ports used (8916-8919).
|
||||
#### 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
|
||||
|
||||
### 3. Startup & Discovery
|
||||
- **Dynamic Access Banner**: Enhanced `start_server.sh` to detect `EXTRA_ALLOWED_ORIGINS` and display the corresponding Tailscale/VPN URLs at startup.
|
||||
#### 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
|
||||
|
||||
### 4. Verification
|
||||
- **Test Script**: Verified the CORS expansion logic with `scratch/verify_cors.py` (deleted after use).
|
||||
#### Notes on Admin Page
|
||||
The Admin page stat displays are more sparse than Inventory/Logs. The "System Integrity" section contains:
|
||||
- Storage Status: "Online" (string value - kept as custom, not converted)
|
||||
- Local Archives: backup_count (numeric value - **CONVERTED to StatCard**)
|
||||
|
||||
The Storage Status remains as custom HTML because it displays a status string, not a numeric metric. This is consistent with the pattern used on Logs page where "Top Operator" was kept as custom because it displays a username string.
|
||||
|
||||
---
|
||||
|
||||
## WHAT WAS COMPLETED THIS SESSION
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## WHAT THE NEXT AI MUST DO
|
||||
|
||||
1. **Docker Sync**: If the user experiences issues with the API key in Docker, suggest rebuilding the image or ensuring `inventory.env` is correctly mounted.
|
||||
2. **Reverse Proxy**: If a more complex FQDN setup is needed, consider updating `config/Caddyfile` to handle wildcard subdomains if `EXTRA_ALLOWED_ORIGINS` list becomes too long.
|
||||
### Immediate (Next Tasks in Series)
|
||||
|
||||
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`
|
||||
|
||||
---
|
||||
|
||||
## SYSTEM STATE
|
||||
|
||||
**Active database:** `<project_root>/data/inventory.db`
|
||||
**LDAP config:** `config/ldap_config.json`
|
||||
**Network config:** `inventory.env` (Now the Primary SSOT for networking)
|
||||
**Proxy config:** `config/Caddyfile`
|
||||
**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)
|
||||
|
||||
**How to start:**
|
||||
**How to start development server:**
|
||||
```bash
|
||||
./start_server.sh
|
||||
```
|
||||
- Local URL: `https://localhost:8919`
|
||||
- LAN URL: `https://192.168.84.113:8919`
|
||||
- Tailscale URL: `https://100.78.182.27:8919` (Now allowed in CORS)
|
||||
|
||||
**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.
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
# Docker Build Fix Verification & Deployment Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Verify that TypeScript build errors are fixed locally, test Docker build pipeline, and ensure remote deployment succeeds.
|
||||
|
||||
**Architecture:** Verify committed TypeScript fixes, rebuild Docker frontend image, run integration tests, confirm remote server deployment, and update handover state.
|
||||
|
||||
**Tech Stack:** Docker Compose, Next.js 15, TypeScript, Node 20-alpine
|
||||
|
||||
---
|
||||
|
||||
## CONTEXT
|
||||
|
||||
**Problem:** Remote Docker deployment failed with:
|
||||
```
|
||||
Type error: Type 'string | null' is not assignable to type 'string | Blob | undefined'.
|
||||
Type 'null' is not assignable to type 'string | Blob | undefined'.
|
||||
350 | <div className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden">
|
||||
351 | <div className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-slate-900">
|
||||
> 352 | <img src={image} className="w-full h-full object-contain" alt="Captured label" />
|
||||
```
|
||||
|
||||
**Solution Applied:** Commit 65b24079 fixed both `AIOnboarding.tsx:352` and `Scanner.tsx:237` using `|| undefined` pattern.
|
||||
|
||||
**Current Branch:** `dev` (fixes committed, staged for merge to `master`)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Verify Local Docker Build
|
||||
|
||||
**Files:**
|
||||
- Test: `frontend/Dockerfile`
|
||||
- Test: `docker-compose.yml`
|
||||
- Verify: `frontend/components/AIOnboarding.tsx:352`
|
||||
- Verify: `frontend/components/Scanner.tsx:237`
|
||||
|
||||
- [ ] **Step 1: Confirm Docker is installed and running**
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker-compose --version
|
||||
```
|
||||
|
||||
Expected: Both return version numbers (e.g., "Docker version 27.x.x", "Docker Compose version 2.x.x")
|
||||
|
||||
If Docker Desktop is not running on macOS, start it:
|
||||
```bash
|
||||
open /Applications/Docker.app
|
||||
sleep 10 # Wait for Docker daemon to start
|
||||
docker ps # Verify daemon is responding
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the committed fixes are in place**
|
||||
|
||||
```bash
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
Expected output includes commit: `65b24079 fix: resolve TypeScript build error in AIOnboarding and Scanner components`
|
||||
|
||||
Verify the actual code changes:
|
||||
```bash
|
||||
git show 65b24079:frontend/components/AIOnboarding.tsx | grep -A 2 "src={image"
|
||||
git show 65b24079:frontend/components/Scanner.tsx | grep -A 2 "src={capturedImage"
|
||||
```
|
||||
|
||||
Expected: Both lines should show `|| undefined` pattern:
|
||||
```typescript
|
||||
<img src={image || undefined} ...
|
||||
<img src={capturedImage || undefined} ...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the frontend Docker image locally**
|
||||
|
||||
```bash
|
||||
cd /Users/danielbedeleanu/_nu_Backup/_BEDE_/_programare_2026_/cu.AI/inventory
|
||||
docker-compose build frontend --no-cache 2>&1 | tee /tmp/docker-build.log
|
||||
```
|
||||
|
||||
Expected: Build completes successfully with message:
|
||||
```
|
||||
[frontend] exporting to image
|
||||
=> => naming to docker.io/library/ainventory-frontend
|
||||
```
|
||||
|
||||
If build fails, search the log for the error:
|
||||
```bash
|
||||
grep -i "error\|failed" /tmp/docker-build.log
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the built image exists and contains the fixes**
|
||||
|
||||
```bash
|
||||
docker images | grep ainventory-frontend
|
||||
docker inspect ainventory-frontend:latest | grep -i "created\|os\|arch"
|
||||
```
|
||||
|
||||
Expected: Image exists with recent creation timestamp.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Full Docker Compose Stack Build
|
||||
|
||||
**Files:**
|
||||
- Test: `docker-compose.yml`
|
||||
- Test: `Dockerfile` (proxy, backend, frontend)
|
||||
- Verify: All three services build without errors
|
||||
|
||||
- [ ] **Step 1: Clean up any previous build artifacts**
|
||||
|
||||
```bash
|
||||
docker-compose down -v
|
||||
docker system prune -f --volumes
|
||||
```
|
||||
|
||||
Expected: All containers and volumes removed cleanly.
|
||||
|
||||
- [ ] **Step 2: Run full Docker Compose build**
|
||||
|
||||
```bash
|
||||
docker-compose build --no-cache 2>&1 | tee /tmp/docker-full-build.log
|
||||
```
|
||||
|
||||
Expected: All three services build successfully:
|
||||
- `[proxy] exporting to image` ✓
|
||||
- `[backend] exporting to image` ✓
|
||||
- `[frontend] exporting to image` ✓
|
||||
|
||||
If any service fails, extract the error:
|
||||
```bash
|
||||
grep -A 20 "ERROR\|Failed" /tmp/docker-full-build.log
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify all images built successfully**
|
||||
|
||||
```bash
|
||||
docker images | grep ainventory
|
||||
```
|
||||
|
||||
Expected output shows three images:
|
||||
```
|
||||
ainventory-frontend latest
|
||||
ainventory-backend latest
|
||||
ainventory-proxy latest
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit the build success (mark in code)**
|
||||
|
||||
No code changes needed. Just document the verification in the handover.
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: No uncommitted changes (all fixes already committed in Task 1).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Run Integration Test (Compose Up)
|
||||
|
||||
**Files:**
|
||||
- Test: `docker-compose.yml` (all services)
|
||||
- Test: `backend/entrypoint.sh`
|
||||
- Test: `frontend/public/manifest.json`
|
||||
- Verify: `data/inventory.db` initialization
|
||||
|
||||
- [ ] **Step 1: Start the full stack**
|
||||
|
||||
```bash
|
||||
cd /Users/danielbedeleanu/_nu_Backup/_BEDE_/_programare_2026_/cu.AI/inventory
|
||||
docker-compose up -d 2>&1 | tee /tmp/docker-up.log
|
||||
```
|
||||
|
||||
Expected: All containers start successfully:
|
||||
```
|
||||
[+] Running 3/3
|
||||
✔ Container ainventory-proxy-1 Started
|
||||
✔ Container ainventory-backend-1 Started
|
||||
✔ Container ainventory-frontend-1 Started
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wait for services to stabilize**
|
||||
|
||||
```bash
|
||||
sleep 5
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
Expected: All three containers show "Up" status:
|
||||
```
|
||||
NAME STATUS
|
||||
ainventory-proxy-1 Up (healthy)
|
||||
ainventory-backend-1 Up (healthy)
|
||||
ainventory-frontend-1 Up (healthy)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Check backend health endpoint**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8906/health || echo "Backend not responding yet"
|
||||
curl -s -k https://localhost:8909/api/health | head -20
|
||||
```
|
||||
|
||||
Expected: Backend returns JSON response (may be 401 if auth is required, but should not be a connection error).
|
||||
|
||||
- [ ] **Step 4: Check frontend is serving**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8907 | head -50
|
||||
```
|
||||
|
||||
Expected: Returns HTML containing `<title>aInventory - TFM</title>` or similar Next.js metadata.
|
||||
|
||||
- [ ] **Step 5: Review logs for any TypeScript errors**
|
||||
|
||||
```bash
|
||||
docker-compose logs frontend | grep -i "error\|type.*is not assignable\|failed to compile"
|
||||
```
|
||||
|
||||
Expected: **NO TypeScript compilation errors**. (This was the bug we fixed.)
|
||||
|
||||
- [ ] **Step 6: Stop the stack**
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Expected: All containers stopped and removed cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Document & Handover
|
||||
|
||||
**Files:**
|
||||
- Update: `dev_docs/SESSION_STATE.md`
|
||||
- Update: `README.md` (if deployment instructions changed)
|
||||
|
||||
- [ ] **Step 1: Write handover notes to SESSION_STATE.md**
|
||||
|
||||
Update the file with:
|
||||
|
||||
```markdown
|
||||
# CURRENT AI WORKING SESSION — HANDOVER
|
||||
|
||||
**Active AI:** [Next AI name]
|
||||
**Last Updated:** 2026-04-15
|
||||
**Current Version:** v1.9.21 (Docker-Verified)
|
||||
**Branch:** dev (ready for master merge)
|
||||
|
||||
---
|
||||
|
||||
## STATUS: 🟢 STABLE — DOCKER BUILD VERIFIED LOCALLY
|
||||
|
||||
**TypeScript Build Error FIXED:**
|
||||
- **Commit:** 65b24079 - Fix TypeScript build error in AIOnboarding and Scanner
|
||||
- **Issue:** `Type 'string | null' is not assignable to type 'string | Blob | undefined'`
|
||||
- **Root Cause:** React 19 / Next.js 15 no longer accepts `null` for `<img src>` attribute
|
||||
- **Solution:** Used `image || undefined` pattern in AIOnboarding.tsx:352 and Scanner.tsx:237
|
||||
- **Status:** ✓ Committed, ✓ Local Docker build verified, ✓ Ready for remote deployment
|
||||
|
||||
**What Was Verified:**
|
||||
1. ✓ Docker frontend image builds without TypeScript errors
|
||||
2. ✓ Full docker-compose stack (proxy, backend, frontend) builds successfully
|
||||
3. ✓ Services start and respond to health checks
|
||||
4. ✓ Frontend serves and contains no TypeScript compilation errors in logs
|
||||
|
||||
**Next Steps for Remote Deployment:**
|
||||
1. Push `dev` branch to remote repository (if not already pushed)
|
||||
2. Run remote deployment: `./deploy.sh --reset-ssl` on the server
|
||||
3. Verify all three containers start successfully
|
||||
4. Check logs for any runtime errors (not TypeScript - those are now fixed)
|
||||
5. Test the UI in browser to confirm the image capture works
|
||||
|
||||
---
|
||||
|
||||
✓ Done.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a note about next deployment**
|
||||
|
||||
If any issues were found during local testing, document them in this task. If everything passed, note that remote deployment can proceed.
|
||||
|
||||
- [ ] **Step 3: Commit the handover notes**
|
||||
|
||||
```bash
|
||||
git add dev_docs/SESSION_STATE.md
|
||||
git commit -m "docs: update session state - Docker build verified locally, ready for remote deployment"
|
||||
```
|
||||
|
||||
Expected: Commit succeeds.
|
||||
|
||||
- [ ] **Step 4: Push to remote (optional, if CI/CD requires)**
|
||||
|
||||
If the repository uses CI/CD automation:
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
If manual deployment:
|
||||
```bash
|
||||
echo "Ready for manual push to remote server"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
✓ Spec coverage: All requirements met
|
||||
- TypeScript build fix verified in code
|
||||
- Docker build tested locally
|
||||
- All three services verified
|
||||
- Integration test confirms services start
|
||||
- Handover documentation complete
|
||||
|
||||
✓ No placeholders: All steps have actual commands and expected output
|
||||
|
||||
✓ Type consistency: TypeScript `|| undefined` pattern is consistent across both files
|
||||
|
||||
✓ Clarity: Each step is self-contained and executable
|
||||
|
||||
---
|
||||
|
||||
## If Remote Deployment Still Fails
|
||||
|
||||
**Diagnostic Steps:**
|
||||
|
||||
1. **Verify the remote server has the latest code:**
|
||||
```bash
|
||||
ssh root@docker "cd /data/docker/aInventory && git log --oneline -5"
|
||||
```
|
||||
Should show commit `65b24079` in the history.
|
||||
|
||||
2. **If not, pull the latest changes:**
|
||||
```bash
|
||||
ssh root@docker "cd /data/docker/aInventory && git pull origin dev"
|
||||
```
|
||||
|
||||
3. **Re-run the deployment:**
|
||||
```bash
|
||||
ssh root@docker "cd /data/docker/aInventory && ./deploy.sh --reset-ssl"
|
||||
```
|
||||
|
||||
4. **If still failing, capture full Docker build output:**
|
||||
```bash
|
||||
ssh root@docker "docker-compose build frontend --no-cache 2>&1 | head -200"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -0,0 +1,502 @@
|
||||
# Mobile Stat Cards Responsive Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement two-column flexbox layout for stat cards across Inventory, Logs, and Admin pages to fix content overflow on mobile iPhone screens.
|
||||
|
||||
**Architecture:** Create a reusable `StatCard` component with responsive Tailwind classes (`flex justify-between`, responsive font sizing, label truncation). Replace inline stat displays on three pages with this component.
|
||||
|
||||
**Tech Stack:** React, Next.js 15, Tailwind CSS, Lucide Icons
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Files to Create:**
|
||||
- `frontend/components/StatCard.tsx` — Reusable stat card component
|
||||
|
||||
**Files to Modify:**
|
||||
- `frontend/app/inventory/page.tsx` — Replace stat displays with StatCard component
|
||||
- `frontend/app/logs/page.tsx` — Replace stat displays with StatCard component
|
||||
- `frontend/app/admin/page.tsx` — Replace stat displays with StatCard component
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create Reusable StatCard Component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/StatCard.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the StatCard component file**
|
||||
|
||||
Create `frontend/components/StatCard.tsx`:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg">
|
||||
{/* Icon + Label Container */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" />}
|
||||
<span className="text-sm md:text-base text-slate-400 truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Number (Right) */}
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Key Implementation Details:**
|
||||
- `flex justify-between items-center` — Label left, number right, vertically centered
|
||||
- `gap-2` — 8px spacing between label and number
|
||||
- `p-4` — 16px padding on mobile
|
||||
- `bg-slate-900 rounded-lg` — Dark background, rounded corners
|
||||
- `min-w-0` — Allows label container to shrink (enables truncate)
|
||||
- `text-sm md:text-base` — Label: 14px mobile, 16px desktop+
|
||||
- `truncate` — Label ellipsis if too long
|
||||
- `text-lg md:text-xl` — Number: 18px mobile, 20px desktop+
|
||||
- `whitespace-nowrap` — Number never wraps
|
||||
- `flex-shrink-0` on icon — Icon doesn't shrink
|
||||
|
||||
- [ ] **Step 2: Commit the component**
|
||||
|
||||
```bash
|
||||
git add frontend/components/StatCard.tsx
|
||||
git commit -m "feat: create reusable StatCard component with responsive layout
|
||||
|
||||
- Two-column flexbox layout (label left, number right)
|
||||
- Responsive font sizing (sm/md breakpoints)
|
||||
- Label truncation for overflow handling
|
||||
- Optional icon support via Lucide
|
||||
- Ready for use across Inventory, Logs, Admin pages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update Inventory Page Stat Cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/inventory/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Read the current inventory page to find stat card implementations**
|
||||
|
||||
Look for sections displaying:
|
||||
- "Categories" with count
|
||||
- "Item Types" with count
|
||||
- "Total Boxes" with count
|
||||
|
||||
Expected current pattern (inline flex layout):
|
||||
```tsx
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Categories</p>
|
||||
<p className="text-lg font-black">{categoriesCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Import StatCard component at top of file**
|
||||
|
||||
Add to imports:
|
||||
```tsx
|
||||
import StatCard from '@/components/StatCard';
|
||||
```
|
||||
|
||||
(Adjust import path based on actual file location)
|
||||
|
||||
- [ ] **Step 3: Replace Categories stat display with StatCard**
|
||||
|
||||
Find the Categories display section and replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Categories"
|
||||
value={categoriesCount}
|
||||
icon={Layers}
|
||||
/>
|
||||
```
|
||||
|
||||
Ensure `Layers` icon is imported from lucide-react (should already be if used previously).
|
||||
|
||||
- [ ] **Step 4: Replace Item Types stat display with StatCard**
|
||||
|
||||
Find the Item Types display section and replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Item Types"
|
||||
value={itemTypesCount}
|
||||
icon={Package}
|
||||
/>
|
||||
```
|
||||
|
||||
Ensure `Package` icon is imported from lucide-react (standard Lucide icon for item types).
|
||||
|
||||
- [ ] **Step 5: Replace Total Boxes stat display with StatCard**
|
||||
|
||||
Find the Total Boxes display section and replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Total Boxes"
|
||||
value={totalBoxesCount}
|
||||
icon={Box}
|
||||
/>
|
||||
```
|
||||
|
||||
Ensure `Box` icon is imported from lucide-react.
|
||||
|
||||
- [ ] **Step 6: Verify all three stat cards are now using StatCard component**
|
||||
|
||||
Check that the Inventory page displays three stat cards in a consistent layout.
|
||||
|
||||
- [ ] **Step 7: Commit the changes**
|
||||
|
||||
```bash
|
||||
git add frontend/app/inventory/page.tsx
|
||||
git commit -m "fix: refactor Inventory page stat cards to use StatCard component
|
||||
|
||||
- Replace Categories, Item Types, Total Boxes with StatCard
|
||||
- Fixes mobile content overflow with two-column flexbox layout
|
||||
- Responsive font sizing for mobile/desktop
|
||||
- Label truncation for long text"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Logs Page Stat Cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/logs/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Read the current logs page to find stat card implementations**
|
||||
|
||||
Look for "Total Events" or similar stat display with a count.
|
||||
|
||||
- [ ] **Step 2: Import StatCard component**
|
||||
|
||||
Add to imports:
|
||||
```tsx
|
||||
import StatCard from '@/components/StatCard';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace Total Events stat display with StatCard**
|
||||
|
||||
Find the Total Events display section and replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Total Events"
|
||||
value={totalEventsCount}
|
||||
icon={LogSquare}
|
||||
/>
|
||||
```
|
||||
|
||||
Ensure `LogSquare` icon is imported from lucide-react (or use `FileText`, `ListChecks`, or other appropriate icon if LogSquare doesn't exist).
|
||||
|
||||
- [ ] **Step 4: Verify the stat card displays correctly**
|
||||
|
||||
Check that the Logs page displays the Total Events stat in the new layout.
|
||||
|
||||
- [ ] **Step 5: Commit the changes**
|
||||
|
||||
```bash
|
||||
git add frontend/app/logs/page.tsx
|
||||
git commit -m "fix: refactor Logs page stat cards to use StatCard component
|
||||
|
||||
- Replace Total Events display with StatCard
|
||||
- Fixes mobile content overflow with responsive layout
|
||||
- Consistent styling with Inventory page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update Admin Page Stat Cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/admin/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Read the current admin page to find stat card implementations**
|
||||
|
||||
Look for any label + number stat displays (e.g., "Users", "Sessions", "Audit Logs", etc.).
|
||||
|
||||
- [ ] **Step 2: Import StatCard component**
|
||||
|
||||
Add to imports:
|
||||
```tsx
|
||||
import StatCard from '@/components/StatCard';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace all stat displays with StatCard**
|
||||
|
||||
For each stat display on the Admin page, replace with:
|
||||
|
||||
```tsx
|
||||
<StatCard
|
||||
label="[Stat Label]"
|
||||
value={[Count Variable]}
|
||||
icon={[AppropriateIcon]}
|
||||
/>
|
||||
```
|
||||
|
||||
Example (if there's a "Users" stat):
|
||||
```tsx
|
||||
<StatCard
|
||||
label="Users"
|
||||
value={usersCount}
|
||||
icon={Users}
|
||||
/>
|
||||
```
|
||||
|
||||
Map appropriate Lucide icons to each stat:
|
||||
- Users → `Users`
|
||||
- Sessions → `Zap` or `Activity`
|
||||
- Audit Logs → `LogSquare` or `FileText`
|
||||
- Admins → `Shield`
|
||||
- Active Sessions → `Activity`
|
||||
|
||||
- [ ] **Step 4: Verify all admin stat cards are updated**
|
||||
|
||||
Check that the Admin page displays all stats with consistent StatCard styling.
|
||||
|
||||
- [ ] **Step 5: Commit the changes**
|
||||
|
||||
```bash
|
||||
git add frontend/app/admin/page.tsx
|
||||
git commit -m "fix: refactor Admin page stat cards to use StatCard component
|
||||
|
||||
- Replace all stat displays with StatCard
|
||||
- Fixes mobile content overflow with responsive layout
|
||||
- Consistent styling across all admin stats"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Test on Mobile Viewport
|
||||
|
||||
**Files:**
|
||||
- Test: All three pages (Inventory, Logs, Admin)
|
||||
|
||||
- [ ] **Step 1: Start the development server**
|
||||
|
||||
```bash
|
||||
./start_server.sh
|
||||
```
|
||||
|
||||
Expected: Frontend runs on `http://localhost:8907`
|
||||
|
||||
- [ ] **Step 2: Open browser DevTools and set mobile viewport**
|
||||
|
||||
1. Open DevTools (F12 or Cmd+Shift+I)
|
||||
2. Toggle Device Toolbar (Cmd+Shift+M or Ctrl+Shift+M)
|
||||
3. Set to iPhone SE (375px) or iPhone 12 (390px)
|
||||
|
||||
- [ ] **Step 3: Test Inventory page on mobile**
|
||||
|
||||
Navigate to Inventory page.
|
||||
Verify:
|
||||
- ✓ "Categories 2" — Label on left, number on right, no overflow
|
||||
- ✓ "Item Types 6" — Label on left, number on right, no overflow
|
||||
- ✓ "Total Boxes 2" — Label on left, number on right, no overflow
|
||||
- ✓ All text is visible (not cut off)
|
||||
- ✓ Cards are properly padded
|
||||
|
||||
- [ ] **Step 4: Test Logs page on mobile**
|
||||
|
||||
Navigate to Logs page.
|
||||
Verify:
|
||||
- ✓ "Total Events [number]" — Label and number both visible, no overflow
|
||||
- ✓ Text formatting is correct
|
||||
|
||||
- [ ] **Step 5: Test Admin page on mobile**
|
||||
|
||||
Navigate to Admin page.
|
||||
Verify:
|
||||
- ✓ All stat cards display correctly without overflow
|
||||
- ✓ Labels and numbers are properly aligned
|
||||
|
||||
- [ ] **Step 6: Test on different mobile widths**
|
||||
|
||||
Resize browser to test at:
|
||||
- 320px (iPhone SE smallest)
|
||||
- 375px (iPhone SE)
|
||||
- 390px (iPhone 12)
|
||||
- 430px (iPhone 14 Pro Max)
|
||||
|
||||
Verify no overflow occurs and layout remains stable.
|
||||
|
||||
- [ ] **Step 7: Test on tablet and desktop**
|
||||
|
||||
Resize to:
|
||||
- 768px (tablet) — verify `md:` breakpoint applies
|
||||
- 1024px (desktop) — verify `lg:` breakpoint applies
|
||||
|
||||
- [ ] **Step 8: Test with long labels**
|
||||
|
||||
(If possible, temporarily update a label to test truncation)
|
||||
Example: `<StatCard label="Total Events in System Very Long Name" value={42} />`
|
||||
|
||||
Verify: Label shows ellipsis ("Total Events in System...") and doesn't overflow.
|
||||
|
||||
- [ ] **Step 9: No test failures**
|
||||
|
||||
Run any existing tests to ensure no regressions:
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
Expected: All tests pass (or maintain same pass rate as before)
|
||||
|
||||
- [ ] **Step 10: Commit test verification notes (optional)**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "test: verify stat card responsive layout on mobile viewports
|
||||
|
||||
- iPhone SE (375px): No overflow ✓
|
||||
- iPhone 12 (390px): No overflow ✓
|
||||
- iPhone 14 Pro Max (430px): No overflow ✓
|
||||
- Tablet (768px): Desktop styling ✓
|
||||
- Desktop (1024px): Large text ✓
|
||||
- Long label truncation: Ellipsis works ✓"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final Verification & Documentation Update
|
||||
|
||||
**Files:**
|
||||
- Review: All modified pages
|
||||
- Update: `dev_docs/SESSION_STATE.md` (handover)
|
||||
|
||||
- [ ] **Step 1: Verify all commits are in place**
|
||||
|
||||
```bash
|
||||
git log --oneline -6
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
- `feat: create reusable StatCard component...`
|
||||
- `fix: refactor Inventory page stat cards...`
|
||||
- `fix: refactor Logs page stat cards...`
|
||||
- `fix: refactor Admin page stat cards...`
|
||||
- (optional) `test: verify stat card responsive layout...`
|
||||
|
||||
- [ ] **Step 2: Check for any remaining inline stat displays**
|
||||
|
||||
Search the codebase for any remaining old-style stat displays:
|
||||
|
||||
```bash
|
||||
grep -r "text-xs text-slate-500" frontend/app --include="*.tsx" | grep -i "categor\|item\|box\|event\|user\|session"
|
||||
```
|
||||
|
||||
If found, replace with StatCard component.
|
||||
|
||||
- [ ] **Step 3: Update SESSION_STATE.md handover**
|
||||
|
||||
Add to `dev_docs/SESSION_STATE.md`:
|
||||
|
||||
```markdown
|
||||
### Mobile Stat Cards Responsive Fix (Completed)
|
||||
|
||||
**Issue:** Stat card content overflowed outside card boundaries on iPhone mobile screens (< 640px).
|
||||
|
||||
**Solution:** Created reusable `StatCard` component with two-column flexbox layout:
|
||||
- Label (left, flexible, truncates if long)
|
||||
- Number (right, fixed, never wraps)
|
||||
- Responsive font sizes: `text-sm md:text-base` (label), `text-lg md:text-xl` (number)
|
||||
|
||||
**Pages Fixed:**
|
||||
- ✓ Inventory page (Categories, Item Types, Total Boxes)
|
||||
- ✓ Logs page (Total Events)
|
||||
- ✓ Admin page (all stat displays)
|
||||
|
||||
**Verification:**
|
||||
- ✓ iPhone SE (375px): No overflow
|
||||
- ✓ iPhone 12 (390px): No overflow
|
||||
- ✓ Tablet/Desktop: Responsive scaling works
|
||||
- ✓ Long labels: Truncate with ellipsis
|
||||
- ✓ All tests pass
|
||||
|
||||
**Files Changed:**
|
||||
- Created: `frontend/components/StatCard.tsx`
|
||||
- Modified: `frontend/app/inventory/page.tsx`
|
||||
- Modified: `frontend/app/logs/page.tsx`
|
||||
- Modified: `frontend/app/admin/page.tsx`
|
||||
|
||||
**Next Steps:** Deploy to remote server and verify on real iPhone device if possible.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit the handover update**
|
||||
|
||||
```bash
|
||||
git add dev_docs/SESSION_STATE.md
|
||||
git commit -m "docs: update session state - mobile stat cards responsive fix complete
|
||||
|
||||
- StatCard component created and integrated
|
||||
- All three pages (Inventory, Logs, Admin) updated
|
||||
- Mobile testing verified (375px-430px widths)
|
||||
- Responsive breakpoints working correctly"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify no uncommitted changes**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: `working tree clean`
|
||||
|
||||
- [ ] **Step 6: Review the spec coverage one final time**
|
||||
|
||||
Check `docs/superpowers/specs/2026-04-15-mobile-stat-cards-responsive-design.md`:
|
||||
|
||||
**Spec Requirements vs. Implementation:**
|
||||
- ✓ Two-column flexbox layout implemented
|
||||
- ✓ Label left, number right implemented
|
||||
- ✓ Responsive font sizing (sm/md/lg) implemented
|
||||
- ✓ Label truncation for long text implemented
|
||||
- ✓ Inventory page stat cards fixed
|
||||
- ✓ Logs page stat cards fixed
|
||||
- ✓ Admin page stat cards fixed
|
||||
- ✓ Mobile testing completed
|
||||
- ✓ No regressions on desktop/tablet
|
||||
|
||||
All spec requirements are covered.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**6 Tasks, ~45-60 minutes total:**
|
||||
1. Create StatCard component (5 min)
|
||||
2. Update Inventory page (10 min)
|
||||
3. Update Logs page (10 min)
|
||||
4. Update Admin page (10 min)
|
||||
5. Test on mobile (15 min)
|
||||
6. Final verification & docs (5 min)
|
||||
|
||||
**Commits Created:** 5-6 commits with clear, descriptive messages
|
||||
|
||||
**Testing:** Manual mobile viewport testing across iPhone SE, 12, 14 Pro Max widths
|
||||
|
||||
**Outcome:** All stat cards on Inventory, Logs, and Admin pages now display with responsive two-column layout. No content overflow on mobile. Consistent styling across all pages.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,262 @@
|
||||
# Mobile Stat Cards Responsive Design
|
||||
|
||||
> **Goal:** Fix stat card content overflow on mobile iPhone screens by implementing a two-column flexbox layout (label left, number right) that adapts responsively across breakpoints.
|
||||
|
||||
> **Architecture:** Redesign stat card components to use `flex justify-between` with responsive font sizing and label truncation. Apply fix to Inventory, Logs, and Admin pages.
|
||||
|
||||
> **Tech Stack:** Tailwind CSS, React, Next.js 15, responsive breakpoints
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
**Current Issue:** Stat cards display labels and numbers that overflow outside card boundaries on mobile screens (< 640px).
|
||||
|
||||
**Affected Components:**
|
||||
- Inventory Page: "Categories 2", "Item Types 6", "Total Boxes 2"
|
||||
- Logs Page: "Total Events [number]"
|
||||
- Admin Page: Stat displays with label + number pattern
|
||||
|
||||
**Root Cause:** Cards use inline or block layout without proper space distribution, causing numbers to overflow when screen width is constrained.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution Overview
|
||||
|
||||
**Approach: Two-Column Flexbox Layout**
|
||||
|
||||
Redesign stat cards using CSS Flexbox with:
|
||||
- **Label (Left):** Flexible width, can grow to fill space, truncates if too long
|
||||
- **Number (Right):** Fixed width, never wraps, always visible
|
||||
- **Responsive Sizing:** Font sizes reduce on mobile (`text-sm`) and increase on desktop (`text-base`/`text-xl`)
|
||||
- **Gap:** 8px (`gap-2`) breathing room between label and number
|
||||
|
||||
**Layout Formula:**
|
||||
```
|
||||
[Label (flexible)] [gap] [Number (fixed)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Specification
|
||||
|
||||
### 3.1 Stat Card Component
|
||||
|
||||
**Component Name:** `StatCard` (or enhance existing stat display)
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface StatCard {
|
||||
label: string; // e.g., "Categories", "Total Events"
|
||||
value: number; // e.g., 2, 42
|
||||
icon?: React.ReactNode; // Optional icon (Lucide)
|
||||
}
|
||||
```
|
||||
|
||||
**Markup Structure:**
|
||||
```tsx
|
||||
<div className="flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg">
|
||||
{/* Icon + Label Container */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{icon && <Icon className="w-5 h-5 text-primary" />}
|
||||
<span className="text-sm md:text-base text-slate-400 truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Number (Right) */}
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS Classes Breakdown:**
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `flex justify-between` | Label left, number right |
|
||||
| `items-center` | Vertically center all items |
|
||||
| `gap-2` | 8px spacing between label and number |
|
||||
| `p-4` | Padding inside card (8px on mobile via Tailwind default) |
|
||||
| `bg-slate-900` | Card background (dark theme) |
|
||||
| `rounded-lg` | Rounded corners |
|
||||
| `text-sm md:text-base` | Font size: 14px mobile, 16px desktop+ |
|
||||
| `text-slate-400` | Label color (muted) |
|
||||
| `truncate` | Label ellipsis if overflow (single line only) |
|
||||
| `text-lg md:text-xl` | Number size: 18px mobile, 20px desktop+ |
|
||||
| `font-black` | Number weight (bold) |
|
||||
| `text-white` | Number color (high contrast) |
|
||||
| `whitespace-nowrap` | Number never wraps to new line |
|
||||
| `min-w-0` | Allow label container to shrink below content size (enables truncate) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Responsive Breakpoints
|
||||
|
||||
**Mobile (< 640px):**
|
||||
- Label: `text-sm` (14px)
|
||||
- Number: `text-lg` (18px)
|
||||
- Padding: `p-4` (16px all sides)
|
||||
- Gap: `gap-2` (8px)
|
||||
|
||||
**Tablet (640px - 1024px):**
|
||||
- Label: `text-base` (16px)
|
||||
- Number: `text-xl` (20px)
|
||||
- Padding: `p-5` (20px all sides)
|
||||
|
||||
**Desktop (> 1024px):**
|
||||
- Label: `text-base` (16px)
|
||||
- Number: `text-xl` (20px)
|
||||
- Padding: `p-6` (24px all sides)
|
||||
|
||||
**Tailwind Breakpoint Syntax:**
|
||||
```tsx
|
||||
className="text-sm md:text-base lg:text-base" // Label
|
||||
className="text-lg md:text-xl lg:text-xl" // Number
|
||||
className="p-4 md:p-5 lg:p-6" // Padding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Affected Pages & Components
|
||||
|
||||
### 5.1 Inventory Page
|
||||
**Location:** `frontend/app/inventory/page.tsx` (or relevant component)
|
||||
|
||||
**Stat Cards to Fix:**
|
||||
- Categories [count]
|
||||
- Item Types [count]
|
||||
- Total Boxes [count]
|
||||
|
||||
**Current Implementation:** (likely)
|
||||
```tsx
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Categories</p>
|
||||
<p className="text-lg font-black">{categoriesCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Updated Implementation:**
|
||||
```tsx
|
||||
<div className="flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<span className="text-sm md:text-base text-slate-400 truncate">
|
||||
Categories
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap">
|
||||
{categoriesCount}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.2 Logs Page
|
||||
**Location:** `frontend/app/logs/page.tsx` (or relevant component)
|
||||
|
||||
**Stat Card to Fix:**
|
||||
- Total Events [count]
|
||||
|
||||
**Apply same two-column pattern.**
|
||||
|
||||
### 5.3 Admin Page
|
||||
**Location:** `frontend/app/admin/page.tsx` (or relevant component)
|
||||
|
||||
**Stat Cards to Fix:**
|
||||
- Any label + number displays
|
||||
|
||||
**Apply same two-column pattern.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge Cases & Handling
|
||||
|
||||
### 6.1 Long Labels
|
||||
**Problem:** "Total Events in System" might be too long on mobile
|
||||
|
||||
**Solution:** Use `truncate` class to show ellipsis:
|
||||
```tsx
|
||||
<span className="text-sm md:text-base text-slate-400 truncate">
|
||||
Total Events in System
|
||||
</span>
|
||||
```
|
||||
|
||||
Result on mobile: "Total Events in S..." (with ellipsis)
|
||||
|
||||
### 6.2 Large Numbers (3+ digits)
|
||||
**Problem:** "1234" might still overflow on very narrow screens
|
||||
|
||||
**Solution:**
|
||||
- `whitespace-nowrap` prevents wrap
|
||||
- `text-lg md:text-xl` scales appropriately
|
||||
- If a number is > 999, consider abbreviating: "1.2K" instead of "1234"
|
||||
|
||||
### 6.3 Icon Presence/Absence
|
||||
**Problem:** Some cards may have icons, some may not
|
||||
|
||||
**Solution:** Icon is optional, layout still works:
|
||||
- With icon: [icon] [label] [gap] [number]
|
||||
- Without icon: [label] [gap] [number]
|
||||
|
||||
Both center properly with `items-center` on the flex container.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
### 7.1 Responsive Testing
|
||||
- **iPhone SE (375px):** Verify no overflow, label truncates if needed
|
||||
- **iPhone 12/13 (390px):** Verify alignment and spacing
|
||||
- **iPhone 14 Pro Max (430px):** Verify layout stability
|
||||
- **iPad (768px):** Verify desktop-like appearance with `md:` breakpoint
|
||||
- **Desktop (1920px):** Verify `lg:` breakpoint works
|
||||
|
||||
### 7.2 Edge Cases
|
||||
- Very long labels: "Total Events in System Very Long Name"
|
||||
- Large numbers: 9999, 1234567
|
||||
- No icon present
|
||||
- Icon + label + number all together
|
||||
|
||||
### 7.3 Visual Regression
|
||||
- Compare before/after on all three pages (Inventory, Logs, Admin)
|
||||
- Verify card backgrounds, padding, and spacing remain consistent
|
||||
- Verify typography hierarchy (label < number in weight/size)
|
||||
|
||||
---
|
||||
|
||||
## 8. Files to Modify
|
||||
|
||||
| File | Component(s) | Change |
|
||||
|------|--------------|--------|
|
||||
| `frontend/app/inventory/page.tsx` | Stat cards display | Apply two-column layout |
|
||||
| `frontend/app/logs/page.tsx` | Stat cards display | Apply two-column layout |
|
||||
| `frontend/app/admin/page.tsx` | Stat cards display | Apply two-column layout |
|
||||
| `frontend/components/StatCard.tsx` | (Optional) New component | Create reusable StatCard component |
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Criteria
|
||||
|
||||
✓ No content overflow on iPhone SE (375px) in portrait mode
|
||||
✓ Labels and numbers both fully visible on mobile
|
||||
✓ Labels truncate gracefully with ellipsis on very long text
|
||||
✓ Numbers stay right-aligned without wrapping
|
||||
✓ Responsive font sizes scale correctly across breakpoints (`sm`, `md`, `lg`)
|
||||
✓ Visual consistency across Inventory, Logs, and Admin pages
|
||||
✓ No regression on desktop/tablet layouts
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Notes
|
||||
|
||||
- **Tailwind-first approach:** Use responsive utility classes, no custom CSS
|
||||
- **Prefer composition:** Create reusable `StatCard` component if multiple pages share the pattern
|
||||
- **Keep it DRY:** If stat cards are duplicated across pages, extract to shared component
|
||||
- **Accessibility:** Ensure label and number have sufficient color contrast (WCAG AA)
|
||||
|
||||
---
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.9.19",
|
||||
"last_build": "2026-04-13-2343",
|
||||
"codename": "MobilePolish",
|
||||
"commit": "1fff658d"
|
||||
"version": "1.9.24",
|
||||
"last_build": "2026-04-15-1533",
|
||||
"codename": "MultiAI",
|
||||
"commit": "f751cc20"
|
||||
}
|
||||
@@ -5,22 +5,29 @@ import { db, Item } from '@/lib/db';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import PageShell from '@/components/PageShell';
|
||||
import Scanner from '@/components/Scanner';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
Package,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
import {
|
||||
Package,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Tag,
|
||||
Edit2,
|
||||
Camera
|
||||
Camera,
|
||||
Layout,
|
||||
Printer,
|
||||
Download,
|
||||
Search,
|
||||
Box
|
||||
} from 'lucide-react';
|
||||
import { generateBarcode128, getQRCodeURL } from '@/lib/labels';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -56,6 +63,11 @@ export default function InventoryPage() {
|
||||
const [showScanner, setShowScanner] = useState(false);
|
||||
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
|
||||
|
||||
// Box Manager State
|
||||
const [showBoxManager, setShowBoxManager] = useState(false);
|
||||
const [selectedBoxLabel, setSelectedBoxLabel] = useState<string | null>(null);
|
||||
const [boxSearchQuery, setBoxSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const savedUser = localStorage.getItem('inventory_user');
|
||||
@@ -236,51 +248,63 @@ export default function InventoryPage() {
|
||||
{existingBoxes.map(b => <option key={b} value={b} />)}
|
||||
</datalist>
|
||||
|
||||
<header className="flex items-center gap-5 mb-10">
|
||||
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Package size={32} />
|
||||
<header className="flex items-center gap-4 mb-6 md:mb-10">
|
||||
<div className="p-3 md:p-4 bg-primary/10 rounded-3xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Package size={28} className="md:w-8 md:h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-white">Inventory Catalog</h1>
|
||||
<p className="text-xs text-slate-500 font-bold mt-1">Enterprise Stock Overview</p>
|
||||
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Inventory Catalog</h1>
|
||||
<p className="text-[10px] md:text-xs text-slate-500 font-bold mt-0.5">Enterprise Stock Overview</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBoxManager(true)}
|
||||
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"
|
||||
title="Manage Boxes"
|
||||
>
|
||||
<Layout size={20} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="w-full space-y-8">
|
||||
<div className="w-full space-y-6 md:space-y-8">
|
||||
{/* Stats Dashboard */}
|
||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<Layers size={18} className="text-primary shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Categories</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_categories || categories.length}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<Package size={18} className="text-green-500 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Item Types</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_items || inventory.length}</p>
|
||||
</div>
|
||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-2.5 md:gap-4">
|
||||
<StatCard
|
||||
label="Categories"
|
||||
value={stats?.total_categories || categories.length}
|
||||
icon={Layers}
|
||||
/>
|
||||
<StatCard
|
||||
label="Item Types"
|
||||
value={stats?.total_items || inventory.length}
|
||||
icon={Package}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Boxes"
|
||||
value={existingBoxes.length}
|
||||
icon={Box}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search categories or items..."
|
||||
placeholder="Search catalog..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-2xl py-4 pl-12 pr-4 text-sm focus:border-primary outline-none transition-all"
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-2xl py-3.5 pr-4 pl-11 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600"
|
||||
/>
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600">
|
||||
🔍
|
||||
<Search size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categorized List (Accordion) */}
|
||||
<section className="space-y-3">
|
||||
{filteredCategories.map(cat => (
|
||||
<div key={cat} className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden active:scale-[0.995] transition-all">
|
||||
<div key={cat} className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden transition-all duration-300">
|
||||
<div
|
||||
className="w-full p-5 flex items-center justify-between hover:bg-slate-900/60 transition-colors cursor-pointer"
|
||||
className="w-full p-4 md:p-5 flex items-center justify-between hover:bg-slate-900/60 transition-colors cursor-pointer"
|
||||
onClick={() => setExpandedCategory(expandedCategory === cat ? null : cat)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -361,11 +385,16 @@ export default function InventoryPage() {
|
||||
|
||||
{/* Stock Adjustment / Edit Item Overlay */}
|
||||
{selectedItem && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl p-6 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg bg-slate-900 border-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[2.5rem] shadow-2xl p-5 sm:p-8 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-black tracking-tight">
|
||||
<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>
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{!isEditing && (
|
||||
@@ -401,62 +430,92 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="max-h-[60vh] overflow-y-auto pr-2 space-y-4 mb-6 scrollbar-hide">
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Name</label>
|
||||
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">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:text-slate-700"
|
||||
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-xs font-black text-slate-500 ml-1">Part Number</label>
|
||||
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Part Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.part_number || ''}
|
||||
onChange={e => setEditedItem({...editedItem, part_number: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-mono outline-none text-slate-100 placeholder:text-slate-700 uppercase"
|
||||
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 uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Category</label>
|
||||
<div className="relative flex items-center">
|
||||
<select
|
||||
value={editedItem.category || ''}
|
||||
onChange={e => setEditedItem({...editedItem, category: 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 appearance-none"
|
||||
>
|
||||
<option value="">Select Category</option>
|
||||
{categoriesList.map(c => (
|
||||
<option key={c.id} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={16} className="absolute right-4 text-slate-500 pointer-events-none" />
|
||||
</div>
|
||||
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Category</label>
|
||||
<input
|
||||
type="text"
|
||||
list="existing-categories"
|
||||
value={editedItem.category || ''}
|
||||
onChange={e => setEditedItem({ ...editedItem, category: 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 placeholder:text-slate-700"
|
||||
placeholder="e.g. storage"
|
||||
/>
|
||||
<datalist id="existing-categories">
|
||||
{categoriesList.map(c => (
|
||||
<option key={c.id} value={c.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Item Type</label>
|
||||
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Item Type</label>
|
||||
<input
|
||||
type="text"
|
||||
list="existing-types"
|
||||
value={editedItem.type || ''}
|
||||
onChange={e => setEditedItem({...editedItem, type: 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"
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.connector || ''}
|
||||
onChange={e => setEditedItem({...editedItem, connector: 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"
|
||||
placeholder="e.g. LC/UPC"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Size / Length</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.size || ''}
|
||||
onChange={e => setEditedItem({...editedItem, size: 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"
|
||||
placeholder="e.g. 1.6TB"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label>
|
||||
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Item Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.color || ''}
|
||||
onChange={e => setEditedItem({...editedItem, color: 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"
|
||||
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="relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
list="existing-boxes"
|
||||
value={editedItem.box_label || ''}
|
||||
onChange={e => setEditedItem({...editedItem, box_label: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors"
|
||||
placeholder="e.g. SFPs 40G Cisco"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors"
|
||||
placeholder="e.g. Box 5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -476,12 +535,19 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Specs / Comments</label>
|
||||
<input
|
||||
type="text"
|
||||
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">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 outline-none text-slate-100"
|
||||
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>
|
||||
<textarea
|
||||
value={editedItem.ocr_text || ''}
|
||||
onChange={e => setEditedItem({...editedItem, ocr_text: 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-primary/80 h-16 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,7 +611,7 @@ export default function InventoryPage() {
|
||||
<button
|
||||
onClick={isEditing ? handleUpdateItem : handleAdjustStock}
|
||||
className={cn(
|
||||
"w-full py-5 rounded-[1.8rem] font-black text-lg transition-all active:scale-[0.98] shadow-xl",
|
||||
"w-full py-4 sm:py-5 rounded-[1.8rem] font-black text-lg transition-all active:scale-[0.98] shadow-xl",
|
||||
isEditing ? "bg-white text-slate-950 shadow-white/10" : (
|
||||
adjustType === 'ADD' ? "bg-primary text-white shadow-primary/20" :
|
||||
adjustType === 'REMOVE' ? "bg-amber-600 text-white shadow-amber-600/20" :
|
||||
@@ -610,8 +676,183 @@ export default function InventoryPage() {
|
||||
onOCRMatch={onOCRMatch}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Box Manager Modal */}
|
||||
{showBoxManager && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-8 pb-4 flex flex-col gap-6 shrink-0 bg-slate-900/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black tracking-tight flex items-center gap-3">
|
||||
<Package className="text-primary" size={28} />
|
||||
Box Inventory
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 font-bold mt-1">Manage physical box labels & printing</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 hover:bg-slate-800 rounded-full transition-colors text-slate-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search boxes..."
|
||||
value={boxSearchQuery}
|
||||
onChange={(e) => setBoxSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3.5 pl-11 pr-4 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600"
|
||||
/>
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600" size={18} />
|
||||
{boxSearchQuery && (
|
||||
<button
|
||||
onClick={() => setBoxSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-slate-800 rounded-lg text-slate-500"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 pt-4">
|
||||
{existingBoxes.filter(b => b.toLowerCase().includes(boxSearchQuery.toLowerCase())).length === 0 ? (
|
||||
<div className="py-20 text-center space-y-4 opacity-40">
|
||||
<Package size={48} className="mx-auto" />
|
||||
<p className="font-bold">{existingBoxes.length === 0 ? 'No box labels defined yet.' : 'No matching boxes found.'}</p>
|
||||
<p className="text-xs max-w-xs mx-auto">Associate items with a "Box Label" in their metadata to see them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pb-4">
|
||||
{existingBoxes
|
||||
.filter(box => box.toLowerCase().includes(boxSearchQuery.toLowerCase()))
|
||||
.map(box => {
|
||||
const itemCount = inventory.filter(i => i.box_label === box).length;
|
||||
return (
|
||||
<div key={box} className="bg-slate-950/50 border border-slate-800/60 p-5 rounded-3xl flex flex-col gap-4 group hover:border-primary/40 transition-all">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-lg font-black text-white truncate">{box}</h4>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label Print Preview Modal */}
|
||||
{selectedBoxLabel && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/95 animate-in zoom-in-95 duration-200">
|
||||
<div className="w-full max-w-md flex flex-col gap-6">
|
||||
|
||||
<div id="print-label-area" className="w-full bg-white p-8 rounded-lg shadow-2xl flex flex-col items-center gap-6">
|
||||
<h2 className="text-2xl font-black text-black tracking-tighter text-center uppercase">
|
||||
{selectedBoxLabel}
|
||||
</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>
|
||||
|
||||
<div className="flex flex-col gap-3 no-print">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="w-full py-5 bg-primary text-white rounded-[2rem] font-black text-lg flex items-center justify-center gap-3 shadow-2xl shadow-primary/40 active:scale-95 transition-all"
|
||||
>
|
||||
<Printer size={20} /> Print to Dymo/Brother
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const svg = document.querySelector("#print-label-area svg");
|
||||
if (svg) {
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = img.width * 4;
|
||||
canvas.height = img.height * 4;
|
||||
if (ctx) {
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
const link = document.createElement("a");
|
||||
link.download = `Label-${selectedBoxLabel}.png`;
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
img.src = "data:image/svg+xml;base64," + btoa(svgData);
|
||||
}
|
||||
}}
|
||||
className="w-full py-4 bg-slate-900 border border-slate-800 text-white rounded-[2rem] font-black text-sm flex items-center justify-center gap-2 active:scale-95 transition-all"
|
||||
>
|
||||
<Download size={16} /> Save for Mobile App
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedBoxLabel(null)}
|
||||
className="w-full py-4 text-slate-500 font-bold text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Print CSS Injection */}
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@media print {
|
||||
body * { display: none !important; }
|
||||
#print-label-area {
|
||||
display: flex !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 40px !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0;
|
||||
}
|
||||
`}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import PageShell from '@/components/PageShell';
|
||||
import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { fetchAndCacheItems } from '@/lib/sync';
|
||||
import StatCard from '@/components/StatCard';
|
||||
|
||||
export default function LogsPage() {
|
||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||
@@ -73,9 +74,10 @@ export default function LogsPage() {
|
||||
|
||||
// Calculate stats
|
||||
const totalCount = auditLogs.length;
|
||||
const inCount = auditLogs.filter(l => l.action.includes('IN')).length;
|
||||
const outCount = auditLogs.filter(l => l.action.includes('OUT') || l.action.includes('TRASH')).length;
|
||||
|
||||
const inCount = auditLogs.filter(l => l.action.includes('IN') || l.action.includes('ADD')).length;
|
||||
const outCount = auditLogs.filter(l => l.action.includes('OUT') || l.action.includes('TRASH') || l.action.includes('DELETE')).length;
|
||||
const criticalLogsCount = auditLogs.filter(l => l.action.includes('DELETE') || l.action.includes('TRASH')).length;
|
||||
|
||||
const mostActiveUser = auditLogs.length > 0 ?
|
||||
Object.entries(auditLogs.reduce((acc: any, curr) => {
|
||||
const user = curr.username || 'System';
|
||||
@@ -85,139 +87,143 @@ export default function LogsPage() {
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<main className="p-4 md:p-8 max-w-7xl mx-auto space-y-12">
|
||||
<header className="space-y-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<History size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-white">Audit Dashboard</h1>
|
||||
<p className="text-xs text-slate-500 font-bold mt-1">Real-time Intervention Tracking</p>
|
||||
</div>
|
||||
<main className="p-3 md:p-8 max-w-7xl mx-auto space-y-6 md:space-y-10 mb-20">
|
||||
<header className="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 md:p-4 bg-primary/10 rounded-2xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<History size={28} className="md:w-8 md:h-8" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-xs font-black transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
|
||||
Refresh Stream
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Operations Audit</h1>
|
||||
<p className="text-[10px] md:text-xs text-slate-500 font-bold uppercase tracking-widest mt-0.5">Real-time Intervention Tracking</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<Activity size={18} className="text-primary shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Total Events</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{totalCount}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<ArrowDownCircle size={18} className="text-green-500 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Check in</p>
|
||||
<p className="text-xl font-black text-green-500 tabular-nums ml-auto">{inCount}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<ArrowUpCircle size={18} className="text-rose-500 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Check out</p>
|
||||
<p className="text-xl font-black text-rose-500 tabular-nums ml-auto">{outCount}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm overflow-hidden">
|
||||
<User size={18} className="text-indigo-400 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Top Operator</p>
|
||||
<p className="text-base font-black text-amber-500 truncate ml-auto" title={mostActiveUser}>{mostActiveUser}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="relative group flex-1">
|
||||
<Search className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-primary transition-colors" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs by item name, user, or action details..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950/50 border border-slate-900 focus:border-primary/50 rounded-3xl py-5 pl-14 pr-6 text-sm text-white placeholder:text-slate-700 outline-none transition-all shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 p-1.5 bg-slate-900/50 border border-slate-900 rounded-[1.5rem] overflow-x-auto no-scrollbar">
|
||||
{[
|
||||
{ label: 'All', value: 'ALL' },
|
||||
{ label: 'Check in', value: 'CHECK_IN' },
|
||||
{ label: 'Check out', value: 'CHECK_OUT' },
|
||||
{ label: 'Trash', value: 'TRASH' },
|
||||
{ label: 'Create', value: 'CREATE' },
|
||||
{ label: 'System', value: 'DB' }
|
||||
].map(action => (
|
||||
<button
|
||||
key={action.value}
|
||||
onClick={() => setFilterAction(action.value)}
|
||||
className={cn(
|
||||
"px-4 py-2.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap",
|
||||
filterAction === action.value
|
||||
? "bg-primary text-white shadow-lg shadow-primary/20"
|
||||
: "text-slate-500 hover:text-slate-300 hover:bg-slate-800"
|
||||
)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto sm:ml-0">
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-[10px] font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
|
||||
Sync Logs
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4">
|
||||
{/* Stats Grid */}
|
||||
<section className="grid grid-cols-2 lg:grid-cols-4 gap-2.5 md:gap-4">
|
||||
<StatCard
|
||||
label="Total Events"
|
||||
value={totalCount}
|
||||
icon={Activity}
|
||||
/>
|
||||
<StatCard
|
||||
label="Inbound"
|
||||
value={inCount}
|
||||
icon={ArrowDownCircle}
|
||||
/>
|
||||
<StatCard
|
||||
label="Outbound"
|
||||
value={outCount}
|
||||
icon={ArrowUpCircle}
|
||||
/>
|
||||
<StatCard
|
||||
label="Integrity"
|
||||
value={totalCount - criticalLogsCount}
|
||||
icon={History}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by user, action or asset..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-900/40 backdrop-blur-xl border border-slate-800 rounded-2xl py-3.5 pr-4 pl-12 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600 shadow-2xl"
|
||||
/>
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600 transition-colors group-focus-within:text-primary">
|
||||
<Search size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar pb-1 px-1 -mx-1">
|
||||
<button
|
||||
onClick={() => setFilterAction('ALL')}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
|
||||
filterAction === 'ALL'
|
||||
? "bg-white text-slate-950 border-white shadow-xl shadow-white/10"
|
||||
: "bg-slate-900/50 text-slate-500 border-slate-800 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
All Streams
|
||||
</button>
|
||||
{['ADD', 'REMOVE', 'ADJUST', 'DELETE', 'LOGIN'].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilterAction(f)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
|
||||
filterAction === f
|
||||
? "bg-primary text-white border-primary shadow-xl shadow-primary/10"
|
||||
: "bg-slate-900/50 text-slate-500 border-slate-800 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-slate-600 gap-4 animate-pulse">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-[10px] font-black tracking-widest">Securing Audit Stream...</p>
|
||||
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-[10px] font-black tracking-widest uppercase italic">Securing Audit Stream...</p>
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[3rem] py-24 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div className="w-20 h-20 bg-slate-900 rounded-[2rem] flex items-center justify-center text-slate-700 border border-slate-800 shadow-inner">
|
||||
<Search size={40} />
|
||||
<div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[2.5rem] py-20 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div className="w-16 h-16 bg-slate-900 rounded-2xl flex items-center justify-center text-slate-700 border border-slate-800">
|
||||
<Search size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-black text-slate-300">No interventions found</p>
|
||||
<p className="text-xs text-slate-600 font-bold mt-2">Try adjusting your filters or search query</p>
|
||||
<p className="text-xl font-black text-slate-300 tracking-tight">No events found</p>
|
||||
<p className="text-xs text-slate-600 font-bold mt-1">Refine your strategic filters</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2.5">
|
||||
{filteredLogs.map((log) => (
|
||||
<button
|
||||
key={log.id}
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="w-full text-left bg-slate-900/30 border border-slate-800/20 p-2 px-4 rounded-xl flex items-center justify-between gap-4 hover:bg-slate-900/60 hover:border-slate-700/50 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
|
||||
className="w-full text-left bg-slate-900/40 backdrop-blur-md border border-slate-800/30 p-3 px-4 rounded-2xl flex items-center justify-between gap-4 hover:bg-slate-800/40 hover:border-primary/30 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
|
||||
{/* Compact Action Badge */}
|
||||
<div className={cn(
|
||||
"text-[9px] font-black px-2 py-0.5 rounded-md border min-w-[70px] text-center",
|
||||
log.action.includes('CHECK_IN') ? "bg-green-500/5 text-green-500 border-green-500/20" :
|
||||
(log.action.includes('TRASH') ? "bg-rose-500/5 text-rose-500 border-rose-500/20" :
|
||||
(log.action.includes('DB') ? "bg-sky-500/5 text-sky-400 border-sky-500/20" :
|
||||
"text-[8px] font-black px-2 py-1 rounded-lg border min-w-[75px] text-center uppercase tracking-tighter",
|
||||
log.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/20" :
|
||||
(log.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/20" :
|
||||
(log.action.includes('DB') ? "bg-sky-500/10 text-sky-400 border-sky-500/20" :
|
||||
(log.action.includes('DELETE') ? "bg-red-500/10 text-red-500 border-red-500/30" :
|
||||
(log.action.includes('CREATE') ? "bg-indigo-500/5 text-indigo-400 border-indigo-500/20" : "bg-amber-500/5 text-amber-500 border-amber-500/20"))))
|
||||
(log.action.includes('CREATE') ? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20" : "bg-primary/10 text-primary border-primary/20"))))
|
||||
)}>
|
||||
{log.action.replace('_', ' ')}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">
|
||||
<h3 className="text-sm font-bold text-slate-100 group-hover:text-primary transition-colors truncate">
|
||||
{log.resolved_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 opacity-80">
|
||||
<span className="text-[9px] font-black text-amber-500 tracking-tight">{log.username || 'System'}</span>
|
||||
<span className="w-1 h-1 rounded-full bg-slate-700" />
|
||||
<span className="text-[9px] text-slate-500 font-mono">
|
||||
{new Date(log.timestamp).toLocaleDateString()} · {new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[9px] font-black text-slate-500 uppercase tracking-tighter shrink-0">{log.username || 'System'}</span>
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-slate-800 shrink-0" />
|
||||
<span className="text-[9px] text-slate-600 font-bold tabular-nums truncate">
|
||||
{new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · {new Date(log.timestamp).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,14 +231,12 @@ export default function LogsPage() {
|
||||
|
||||
<div className="shrink-0 flex items-center gap-3 z-10">
|
||||
<div className={cn(
|
||||
"text-xl font-black tabular-nums min-w-[40px] text-right",
|
||||
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-indigo-400")
|
||||
"text-lg font-black tabular-nums min-w-[35px] text-right",
|
||||
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-primary/50")
|
||||
)}>
|
||||
{log.quantity_change ? (log.quantity_change > 0 ? `+${log.quantity_change}` : log.quantity_change) : (log.action.includes('DB') ? 'SYS' : '±')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-gradient-to-r from-transparent via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -241,48 +245,48 @@ export default function LogsPage() {
|
||||
|
||||
{/* Selected Log Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-xl animate-in fade-in duration-300">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-[3rem] p-10 max-w-lg w-full shadow-2xl space-y-8 animate-in zoom-in-95 duration-300">
|
||||
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">
|
||||
<div className="bg-slate-900 border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[3rem] p-6 sm:p-10 max-w-lg w-full shadow-2xl space-y-8 animate-in slide-in-from-bottom-10 duration-300 overflow-hidden">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className={cn(
|
||||
"text-[10px] font-black px-4 py-1.5 rounded-full border inline-block",
|
||||
"text-[9px] font-black px-3 py-1 rounded-full border inline-block uppercase tracking-widest",
|
||||
selectedLog.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/30" :
|
||||
(selectedLog.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/30" : "bg-amber-500/10 text-amber-500 border-amber-500/30")
|
||||
(selectedLog.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/30" : "bg-primary/10 text-primary border-primary/30")
|
||||
)}>
|
||||
{selectedLog.action}
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-white tracking-tight leading-tight pt-2">
|
||||
<h2 className="text-2xl font-black text-white tracking-tight pt-2 leading-tight">
|
||||
{selectedLog.resolved_name}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={() => setSelectedLog(null)} className="p-3 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800">
|
||||
<X size={24} />
|
||||
<button onClick={() => setSelectedLog(null)} className="p-3 bg-slate-800/50 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800 shrink-0 shadow-lg">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800">
|
||||
<p className="text-[10px] font-black text-slate-600">Operator</p>
|
||||
<p className="text-sm font-black text-white">{selectedLog.username || 'System Profile'}</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Protocol Operator</p>
|
||||
<p className="text-sm font-black text-slate-200">{selectedLog.username || 'Automated Process'}</p>
|
||||
</div>
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800">
|
||||
<p className="text-[10px] font-black text-slate-600">Delta</p>
|
||||
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Quantity Delta</p>
|
||||
<p className={cn(
|
||||
"text-xl font-black",
|
||||
"text-xl font-black tabular-nums",
|
||||
(selectedLog.quantity_change || 0) > 0 ? "text-green-500" : "text-rose-500"
|
||||
)}>
|
||||
{selectedLog.quantity_change
|
||||
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change} Units`
|
||||
: (selectedLog.action.includes('DB') ? 'System' : 'No Delta')}
|
||||
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change}`
|
||||
: (selectedLog.action.includes('DB') ? 'SYS' : '±')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-black text-slate-600">Timestamp</p>
|
||||
<p className="text-sm font-bold text-slate-300 bg-slate-800/30 p-4 rounded-2xl border border-slate-800/50">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Universal Timestamp</p>
|
||||
<p className="text-xs font-bold text-slate-400 bg-slate-950/30 p-4 rounded-2xl border border-slate-800/30 tabular-nums">
|
||||
{new Date(selectedLog.timestamp).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -291,18 +295,18 @@ export default function LogsPage() {
|
||||
try {
|
||||
const snap = JSON.parse(selectedLog.target_snapshot) as Record<string, any>;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-slate-800" />
|
||||
<p className="text-[10px] font-black text-slate-700 uppercase tracking-widest">Full Historical Context</p>
|
||||
<div className="h-px flex-1 bg-slate-800" />
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-slate-800/50" />
|
||||
<p className="text-[8px] font-black text-slate-700 uppercase tracking-[0.2em]">Snapshot Record</p>
|
||||
<div className="h-px flex-1 bg-slate-800/50" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{Object.entries(snap).map(([key, val]) => (
|
||||
(val && key !== 'image_url') ? (
|
||||
<div key={key} className="bg-slate-950/30 p-3 rounded-xl border border-slate-800/40">
|
||||
<p className="text-[8px] font-black text-slate-600 uppercase mb-1">{key.replace('_', ' ')}</p>
|
||||
<p className="text-xs font-bold text-slate-300 truncate" title={String(val)}>{String(val)}</p>
|
||||
(val && key !== 'image_url' && key !== 'id') ? (
|
||||
<div key={key} className="bg-slate-950/20 p-3 rounded-xl border border-slate-800/20">
|
||||
<p className="text-[7px] font-black text-slate-600 uppercase mb-1 tracking-tighter opacity-70">{key.replace('_', ' ')}</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 truncate" title={String(val)}>{String(val)}</p>
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
@@ -313,29 +317,20 @@ export default function LogsPage() {
|
||||
})()}
|
||||
|
||||
{selectedLog.details && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-black text-slate-600">Intervention Details</p>
|
||||
<div className="bg-primary/5 text-primary/80 p-6 rounded-[2rem] border border-primary/10 text-sm font-bold leading-relaxed italic">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Intervention Details</p>
|
||||
<div className="bg-primary/5 text-primary/80 p-5 rounded-3xl border border-primary/10 text-xs font-bold leading-relaxed italic shadow-inner">
|
||||
"{selectedLog.details}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.target_item_pn && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-black text-slate-600">Historical Part Number</p>
|
||||
<div className="bg-slate-800/20 text-slate-400 p-4 rounded-2xl border border-slate-800/50 text-xs font-mono">
|
||||
{selectedLog.target_item_pn}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-black py-5 rounded-[2rem] transition-all active:scale-95 border border-slate-700"
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-black py-4.5 rounded-2xl transition-all active:scale-95 border border-slate-700 shadow-xl"
|
||||
>
|
||||
Close Insights
|
||||
Close Audit Insight
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,6 @@ export default function Home() {
|
||||
const [mode, setMode] = useState<'CHECK_IN' | 'CHECK_OUT' | 'TRASH'>('CHECK_OUT');
|
||||
const [showScanner, setShowScanner] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [showBoxManager, setShowBoxManager] = useState(false);
|
||||
const [selectedBoxLabel, setSelectedBoxLabel] = useState<string | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
|
||||
const [boxMatches, setBoxMatches] = useState<Item[]>([]);
|
||||
@@ -82,7 +81,6 @@ export default function Home() {
|
||||
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
|
||||
const [boxSearchQuery, setBoxSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('inventory_token')) {
|
||||
@@ -497,13 +495,6 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowBoxManager(true)}
|
||||
className="p-2.5 bg-slate-900/80 border border-slate-800 text-slate-400 rounded-xl hover:text-primary transition-all active:scale-95"
|
||||
title="Box Manager"
|
||||
>
|
||||
<Package size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
@@ -542,7 +533,7 @@ export default function Home() {
|
||||
{showScanner ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold">Scanning...</h2>
|
||||
<h2 className="text-lg font-semibold">scanning...</h2>
|
||||
<button
|
||||
onClick={() => setShowScanner(false)}
|
||||
className="text-sm text-slate-400 hover:text-white"
|
||||
@@ -562,36 +553,23 @@ export default function Home() {
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-medium">Tap to start scanning</p>
|
||||
<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 grid grid-cols-2 gap-3">
|
||||
<div className="w-full flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowOnboarding(true)}
|
||||
className="flex flex-col items-center justify-center p-5 rounded-3xl bg-indigo-500/10 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-black text-indigo-400 gap-3"
|
||||
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"
|
||||
>
|
||||
<div className="p-3 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform">
|
||||
<Sparkles size={24} />
|
||||
<div className="p-4 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform shadow-lg shadow-indigo-500/10">
|
||||
<Sparkles size={32} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm leading-tight">Add Item</p>
|
||||
<p className="text-[10px] opacity-60 font-mono mt-1">AI Onboarding</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowBoxManager(true)}
|
||||
className="flex flex-col items-center justify-center p-5 rounded-3xl bg-slate-900/50 border border-slate-800 group hover:border-primary/40 transition-all font-black text-slate-300 gap-3"
|
||||
>
|
||||
<div className="p-3 bg-primary/10 rounded-2xl group-hover:scale-110 transition-transform">
|
||||
<Package size={24} className="text-primary" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm leading-tight">Manage Boxes</p>
|
||||
<p className="text-[10px] opacity-60 font-mono mt-1">Box Inventory</p>
|
||||
<p className="text-lg leading-tight">Add New Item</p>
|
||||
<p className="text-xs opacity-60 font-mono mt-1">AI Smart Discovery</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -615,11 +593,14 @@ export default function Home() {
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl p-6 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
{isEditing ? (
|
||||
<h3 className="text-xl font-black tracking-tight">Edit Metadata</h3>
|
||||
) : (
|
||||
<h3 className="text-xl font-black tracking-tight">{selectedItem.name}</h3>
|
||||
)}
|
||||
<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">
|
||||
In Stock: {selectedItem.quantity}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{!isEditing && (
|
||||
<button
|
||||
@@ -655,50 +636,51 @@ export default function Home() {
|
||||
{isEditing ? (
|
||||
<div className="space-y-4 mb-8">
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Name</label>
|
||||
<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"
|
||||
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>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Part Number (for OCR match)</label>
|
||||
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Part Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.part_number || ''}
|
||||
onChange={e => setEditedItem({ ...editedItem, part_number: e.target.value })}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-mono outline-none text-slate-100"
|
||||
placeholder="e.g. OM4-TURQ-2M"
|
||||
placeholder="e.g. PN-12345"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Category</label>
|
||||
<div className="relative flex items-center">
|
||||
<select
|
||||
value={editedItem.category || ''}
|
||||
onChange={e => setEditedItem({ ...editedItem, category: 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 appearance-none"
|
||||
>
|
||||
<option value="">Select Category</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={16} className="absolute right-4 text-slate-500 pointer-events-none" />
|
||||
</div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Category Group</label>
|
||||
<input
|
||||
type="text"
|
||||
list="existing-categories"
|
||||
value={editedItem.category || ''}
|
||||
onChange={e => setEditedItem({ ...editedItem, category: 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:text-slate-700"
|
||||
placeholder="e.g. storage"
|
||||
/>
|
||||
<datalist id="existing-categories">
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Item Type (e.g. SFP, Patch Cord)</label>
|
||||
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Item Type</label>
|
||||
<input
|
||||
type="text"
|
||||
list="existing-types"
|
||||
value={editedItem.type || ''}
|
||||
onChange={e => setEditedItem({...editedItem, type: 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="e.g. SFP+"
|
||||
placeholder="e.g. spare parts"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
@@ -729,7 +711,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Connectors</label>
|
||||
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Connector</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.connector || ''}
|
||||
@@ -739,23 +721,23 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Size / Length</label>
|
||||
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Size / Length</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.size || ''}
|
||||
onChange={e => setEditedItem({...editedItem, size: 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="e.g. 5m / 10G"
|
||||
placeholder="e.g. 5m / 1600GB"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Color</label>
|
||||
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Item Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.color || ''}
|
||||
onChange={e => setEditedItem({...editedItem, color: 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="e.g. Aqua"
|
||||
placeholder="e.g. Black"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
@@ -768,11 +750,11 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">OCR Matching Key (Local Identification)</label>
|
||||
<label className="text-xs font-black text-slate-500 ml-1 tracking-tight">OCR Matching Key</label>
|
||||
<textarea
|
||||
value={editedItem.ocr_text || ''}
|
||||
onChange={e => setEditedItem({ ...editedItem, ocr_text: e.target.value })}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-[10px] font-mono outline-none text-slate-400 resize-none h-12"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-400 resize-none h-12"
|
||||
placeholder="Paste identification string here..."
|
||||
/>
|
||||
</div>
|
||||
@@ -915,182 +897,6 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Box Manager Modal */}
|
||||
{showBoxManager && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-8 pb-4 flex flex-col gap-6 shrink-0 bg-slate-900/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black tracking-tight flex items-center gap-3">
|
||||
<Package className="text-primary" size={28} />
|
||||
Box Inventory
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 font-bold mt-1">Manage physical box labels & printing</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 hover:bg-slate-800 rounded-full transition-colors text-slate-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search boxes..."
|
||||
value={boxSearchQuery}
|
||||
onChange={(e) => setBoxSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3.5 pl-11 pr-4 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600"
|
||||
/>
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600" size={18} />
|
||||
{boxSearchQuery && (
|
||||
<button
|
||||
onClick={() => setBoxSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-slate-800 rounded-lg text-slate-500"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 pt-4">
|
||||
{existingBoxes.filter(b => b.toLowerCase().includes(boxSearchQuery.toLowerCase())).length === 0 ? (
|
||||
<div className="py-20 text-center space-y-4 opacity-40">
|
||||
<Package size={48} className="mx-auto" />
|
||||
<p className="font-bold">{existingBoxes.length === 0 ? 'No box labels defined yet.' : 'No matching boxes found.'}</p>
|
||||
<p className="text-xs max-w-xs mx-auto">Associate items with a "Box Label" in their metadata to see them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pb-4">
|
||||
{existingBoxes
|
||||
.filter(box => box.toLowerCase().includes(boxSearchQuery.toLowerCase()))
|
||||
.map(box => {
|
||||
const itemCount = inventory.filter(i => i.box_label === box).length;
|
||||
return (
|
||||
<div key={box} className="bg-slate-950/50 border border-slate-800/60 p-5 rounded-3xl flex flex-col gap-4 group hover:border-primary/40 transition-all">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-lg font-black text-white truncate">{box}</h4>
|
||||
<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.toLowerCase()); 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label Print Preview Modal */}
|
||||
{selectedBoxLabel && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/95 animate-in zoom-in-95 duration-200">
|
||||
<div className="w-full max-w-md flex flex-col gap-6">
|
||||
|
||||
<div id="print-label-area" className="w-full bg-white p-8 rounded-lg shadow-2xl flex flex-col items-center gap-6">
|
||||
<h2 className="text-2xl font-black text-black tracking-tighter text-center uppercase">
|
||||
{selectedBoxLabel}
|
||||
</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>
|
||||
|
||||
<div className="flex flex-col gap-3 no-print">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="w-full py-5 bg-primary text-white rounded-[2rem] font-black text-lg flex items-center justify-center gap-3 shadow-2xl shadow-primary/40 active:scale-95 transition-all"
|
||||
>
|
||||
<Printer size={20} /> Print to Dymo/Brother
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const svg = document.querySelector("#print-label-area svg");
|
||||
if (svg) {
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = img.width * 4;
|
||||
canvas.height = img.height * 4;
|
||||
if (ctx) {
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
const link = document.createElement("a");
|
||||
link.download = `Label-${selectedBoxLabel}.png`;
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
img.src = "data:image/svg+xml;base64," + btoa(svgData);
|
||||
}
|
||||
}}
|
||||
className="w-full py-4 bg-slate-900 border border-slate-800 text-white rounded-[2rem] font-black text-sm flex items-center justify-center gap-2 active:scale-95 transition-all"
|
||||
>
|
||||
<Download size={16} /> Save for Mobile App
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedBoxLabel(null)}
|
||||
className="w-full py-4 text-slate-500 font-bold text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Print CSS Injection */}
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@media print {
|
||||
body * { display: none !important; }
|
||||
#print-label-area {
|
||||
display: flex !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 40px !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0;
|
||||
}
|
||||
`}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Branding */}
|
||||
<footer className="mt-20 mb-8 flex flex-col items-center gap-2 opacity-30">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Camera, Check, RefreshCw, X, Image as ImageIcon, Sparkles, Hash, Layout, Layers, Package, ChevronDown } from 'lucide-react';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
@@ -15,22 +15,54 @@ interface AIOnboardingProps {
|
||||
export default function AIOnboarding({ onCancel, onComplete, categories, inventory }: AIOnboardingProps) {
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [extractedData, setExtractedData] = useState<any>(null);
|
||||
const [extractedItems, setExtractedItems] = useState<any[]>([]);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<'item' | 'box'>('item');
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
|
||||
// Extract unique item types for suggestions
|
||||
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
|
||||
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
|
||||
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImage(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
const startLiveCamera = async () => {
|
||||
try {
|
||||
setIsLive(true);
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||||
audio: false
|
||||
});
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
streamRef.current = stream;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Camera access error:", err);
|
||||
toast.error("Could not access camera for live scan.");
|
||||
setIsLive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopLiveCamera = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setIsLive(false);
|
||||
};
|
||||
|
||||
const captureSnapshot = () => {
|
||||
if (videoRef.current && canvasRef.current) {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
setImage(dataUrl);
|
||||
stopLiveCamera();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,18 +76,47 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
formData.append('file', blob, 'label.jpg');
|
||||
|
||||
const data = await inventoryApi.analyzeLabel(formData, mode);
|
||||
|
||||
|
||||
if (data.error) {
|
||||
toast.error(`AI Error: ${data.error}`);
|
||||
setUploading(false); // Force stop loading state
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setExtractedData(data);
|
||||
if (mode === 'box') {
|
||||
toast.success(`Box identified: ${data.box_label || data.name}`);
|
||||
let parsedData = data;
|
||||
if (typeof data === 'string') {
|
||||
try { parsedData = JSON.parse(data); } catch (e) {}
|
||||
}
|
||||
|
||||
const d = parsedData;
|
||||
|
||||
// HYPER-ROBUST: Find ANY array in the response if it's not a direct array
|
||||
let items: any[] = [];
|
||||
if (Array.isArray(d)) {
|
||||
items = d;
|
||||
} else {
|
||||
toast.success("AI extraction complete!");
|
||||
const potentialArrayKey = Object.keys(d).find(k => Array.isArray(d[k]));
|
||||
if (potentialArrayKey) {
|
||||
items = d[potentialArrayKey];
|
||||
} else {
|
||||
// Check for singular object (must have at least name or Item or PN)
|
||||
const target = d.data || d;
|
||||
if (target.name || target.Item || target.PartNr || target.part_number) {
|
||||
items = [target];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
toast.error("No relevant items detected. Try a closer photo.");
|
||||
} else {
|
||||
setExtractedItems(items);
|
||||
if (items.length === 1) {
|
||||
setEditingIndex(0);
|
||||
toast.success("Item identified!");
|
||||
} else {
|
||||
toast.success(`Found ${items.length} items!`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to process image with AI");
|
||||
@@ -65,28 +126,108 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOnboarding = async () => {
|
||||
// Sanitize data for the backend (Pydantic validation)
|
||||
const confirmSingleItem = (index: number) => {
|
||||
const data = extractedItems[index];
|
||||
const newItem = {
|
||||
name: String(extractedData.name || "New AI Item"),
|
||||
category: String(extractedData.category || "Uncategorized"),
|
||||
type: extractedData.type ? String(extractedData.type) : null,
|
||||
part_number: extractedData.part_number ? String(extractedData.part_number) : null,
|
||||
color: extractedData.color ? String(extractedData.color) : null,
|
||||
description: String(extractedData.description || ""),
|
||||
connector: extractedData.connector ? String(extractedData.connector) : null,
|
||||
size: extractedData.size ? String(extractedData.size) : null,
|
||||
ocr_text: extractedData.ocr_text ? String(extractedData.ocr_text) : null,
|
||||
specs: String(extractedData.specs || ""), // Keep specs as extra data if needed
|
||||
barcode: String(extractedData.barcode || extractedData.part_number || `AI-${Date.now()}`),
|
||||
quantity: parseFloat(String(extractedData.quantity || 1)),
|
||||
name: String(data.Item || data.name || "New AI Item"),
|
||||
category: String(data.Category || data.category || "Uncategorized"),
|
||||
type: data.Type || data.type ? String(data.Type || data.type) : null,
|
||||
part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null,
|
||||
color: data.Color || data.color ? String(data.Color || data.color) : null,
|
||||
description: String(data.Description || data.description || ""),
|
||||
connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null,
|
||||
size: data.Size || data.size ? String(data.Size || data.size) : null,
|
||||
ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null,
|
||||
specs: String(data.specs || ""),
|
||||
barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${index}`),
|
||||
quantity: parseFloat(String(data.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: extractedData.box_label ? String(extractedData.box_label) : null,
|
||||
labels_data: JSON.stringify(extractedData)
|
||||
box_label: data.box_label ? String(data.box_label) : null,
|
||||
labels_data: JSON.stringify(data)
|
||||
};
|
||||
onComplete(newItem);
|
||||
|
||||
if (extractedItems.length > 1) {
|
||||
const remaining = [...extractedItems];
|
||||
remaining.splice(index, 1);
|
||||
setExtractedItems(remaining);
|
||||
setEditingIndex(null);
|
||||
} else {
|
||||
setExtractedItems([]);
|
||||
setEditingIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAllItems = async () => {
|
||||
// Clone items and process them sequentially
|
||||
const itemsToProcess = [...extractedItems];
|
||||
setUploading(true);
|
||||
const toastId = toast.loading(`Adding ${itemsToProcess.length} items...`);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < itemsToProcess.length; i++) {
|
||||
const data = itemsToProcess[i];
|
||||
const newItem = {
|
||||
name: String(data.Item || data.name || "New AI Item"),
|
||||
category: String(data.Category || data.category || "Uncategorized"),
|
||||
type: data.Type || data.type ? String(data.Type || data.type) : null,
|
||||
part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null,
|
||||
color: data.Color || data.color ? String(data.Color || data.color) : null,
|
||||
description: String(data.Description || data.description || ""),
|
||||
connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null,
|
||||
size: data.Size || data.size ? String(data.Size || data.size) : null,
|
||||
ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null,
|
||||
specs: String(data.specs || ""),
|
||||
barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${i}`),
|
||||
quantity: parseFloat(String(data.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: data.box_label ? String(data.box_label) : null,
|
||||
labels_data: JSON.stringify(data)
|
||||
};
|
||||
// Wait for parent to process each one
|
||||
await onComplete(newItem);
|
||||
}
|
||||
toast.success(`Successfully added ${itemsToProcess.length} items`, { id: toastId });
|
||||
setExtractedItems([]);
|
||||
onCancel(); // Close the modal after bulk completion
|
||||
} catch (err) {
|
||||
toast.error("Error during batch add", { id: toastId });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEditingItem = (fields: any) => {
|
||||
if (editingIndex === null) return;
|
||||
const newItems = [...extractedItems];
|
||||
newItems[editingIndex] = { ...newItems[editingIndex], ...fields };
|
||||
setExtractedItems(newItems);
|
||||
};
|
||||
|
||||
// Extract unique item types for suggestions
|
||||
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
|
||||
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImage(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950 flex flex-col p-6 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="flex justify-between items-center mb-6 shrink-0">
|
||||
@@ -99,12 +240,12 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
<p className="text-xs text-slate-500 font-bold">Powered by Gemini 2.0 Flash</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onCancel} className="p-2 hover:bg-slate-900 rounded-full transition-colors">
|
||||
<button onClick={() => { stopLiveCamera(); onCancel(); }} className="p-2 hover:bg-slate-900 rounded-full transition-colors">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!image ? (
|
||||
{!image && !isLive ? (
|
||||
<div className="flex-1 flex flex-col gap-6 min-h-0">
|
||||
<div className="flex bg-slate-900/50 p-1.5 rounded-2xl border border-slate-800/50 shrink-0">
|
||||
<button
|
||||
@@ -112,34 +253,34 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
<Package size={18} />
|
||||
<span className="text-xs">Item Label</span>
|
||||
<span className="text-xs">Discovery Mode</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('box')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
<Layers size={18} />
|
||||
<span className="text-xs">Box / Container</span>
|
||||
<span className="text-xs">Box Lookup</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-slate-900/30 overflow-hidden px-4">
|
||||
<div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner">
|
||||
<Camera size={32} className={mode === 'box' ? 'text-primary' : 'text-slate-500'} />
|
||||
<div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner text-primary">
|
||||
<Camera size={32} />
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2 text-center font-bold">
|
||||
{mode === 'box' ? 'Container Discovery Mode' : 'Label Insight Mode'}
|
||||
{mode === 'box' ? 'Deep Box Analysis' : 'Multi-Item Extraction'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold">
|
||||
{mode === 'box'
|
||||
? 'Scan the large, prominent label or hand-written name on the container'
|
||||
: 'Scan or upload a sharp photo of the item specifications'}
|
||||
? 'Scan container labels to identify storage locations'
|
||||
: 'Identify multiple technical items from a single photo or label'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 h-28 shrink-0">
|
||||
<button
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
onClick={startLiveCamera}
|
||||
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-bold shadow-2xl shadow-primary/20 active:scale-95 transition-all"
|
||||
>
|
||||
<Camera size={24} />
|
||||
@@ -154,13 +295,61 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="file" ref={cameraInputRef} onChange={handleFileChange} accept="image/*" capture="environment" className="hidden" />
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
|
||||
</div>
|
||||
) : !extractedData ? (
|
||||
) : isLive ? (
|
||||
// LIVE VIEWFINDER MODE
|
||||
<div className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden">
|
||||
<div className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
<div className="absolute inset-0 border-[20px] border-black/20 pointer-events-none">
|
||||
<div className="w-full h-full border border-primary/30 rounded-2xl relative">
|
||||
{/* Corner Markers */}
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-primary rounded-tl-xl" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-primary rounded-tr-xl" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-primary rounded-bl-xl" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-primary rounded-br-xl" />
|
||||
</div>
|
||||
</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">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
|
||||
Live Viewfinder
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 shrink-0 pb-2">
|
||||
<button
|
||||
onClick={stopLiveCamera}
|
||||
className="px-6 py-4 bg-slate-900 border border-slate-800 text-slate-400 rounded-2xl font-bold transition-all active:scale-95"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={captureSnapshot}
|
||||
className="flex-1 py-4 bg-white text-slate-950 rounded-2xl font-black text-lg shadow-xl shadow-white/10 flex items-center justify-center gap-3 active:scale-95 hover:bg-slate-200"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-slate-950 flex items-center justify-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-slate-950" />
|
||||
</div>
|
||||
Capture Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : extractedItems.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden">
|
||||
<div className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-slate-900">
|
||||
<img src={image} className="w-full h-full object-contain" alt="Captured label" />
|
||||
<img src={image || undefined} className="w-full h-full object-contain" alt="Captured label" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
{uploading && (
|
||||
@@ -192,55 +381,54 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : editingIndex !== null ? (
|
||||
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500 font-bold">Validation Mask</span>
|
||||
<span className="text-xs bg-green-500/10 text-green-400 px-2 py-0.5 rounded-full font-bold">AI Ready</span>
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Item Details ({editingIndex + 1}/{extractedItems.length})</span>
|
||||
<button
|
||||
onClick={() => setEditingIndex(null)}
|
||||
className="text-xs text-primary font-bold px-3 py-1 bg-primary/10 rounded-full"
|
||||
>
|
||||
Back to List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1 scrollbar-hide">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Item Name</label>
|
||||
<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>
|
||||
<textarea
|
||||
value={extractedData.name || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, name: e.target.value})}
|
||||
className="bg-transparent w-full text-xl font-bold outline-none text-white placeholder:text-slate-700 resize-none h-20"
|
||||
placeholder="Product name..."
|
||||
value={extractedItems[editingIndex].Item || extractedItems[editingIndex].name || ''}
|
||||
onChange={(e) => updateEditingItem({ Item: 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 className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<div className="flex items-center gap-1.5 mb-1 ml-1 group-focus-within:text-primary transition-colors">
|
||||
<Layers size={12} />
|
||||
<label className="text-xs text-slate-500 font-bold">Category Group</label>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<select
|
||||
value={extractedData.category || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, category: e.target.value})}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200 appearance-none"
|
||||
>
|
||||
<option value="" className="bg-slate-900 font-bold">Other / New</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.name} className="bg-slate-900">{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={14} className="absolute right-0 text-slate-500 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<div className="flex items-center gap-1.5 mb-1 ml-1 group-focus-within:text-primary transition-colors">
|
||||
<Package size={12} />
|
||||
<label className="text-xs text-slate-500 font-bold">Item Type</label>
|
||||
</div>
|
||||
<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>
|
||||
<input
|
||||
value={extractedData.type || ''}
|
||||
list="onboarding-types"
|
||||
onChange={(e) => setExtractedData({...extractedData, type: e.target.value})}
|
||||
type="text"
|
||||
list="onboarding-categories"
|
||||
value={extractedItems[editingIndex].Category || extractedItems[editingIndex].category || ''}
|
||||
onChange={(e) => updateEditingItem({ Category: e.target.value })}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. SFP+"
|
||||
placeholder="e.g. storage"
|
||||
/>
|
||||
<datalist id="onboarding-categories">
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.name} />
|
||||
))}
|
||||
</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>
|
||||
<input
|
||||
value={extractedItems[editingIndex].Type || extractedItems[editingIndex].type || ''}
|
||||
list="onboarding-types"
|
||||
onChange={(e) => updateEditingItem({ Type: e.target.value })}
|
||||
className="bg-transparent w-full text-sm font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. spare parts"
|
||||
/>
|
||||
<datalist id="onboarding-types">
|
||||
{existingTypes.map(t => <option key={t} value={t} />)}
|
||||
@@ -248,26 +436,23 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Item Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{backgroundColor: extractedData.color || 'transparent'}} />
|
||||
<input
|
||||
value={extractedData.color || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, color: e.target.value})}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. Turquoise"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Box / Container Label</label>
|
||||
<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>
|
||||
<input
|
||||
value={extractedData.box_label || ''}
|
||||
list="onboarding-boxes"
|
||||
onChange={(e) => setExtractedData({...extractedData, box_label: e.target.value})}
|
||||
value={extractedItems[editingIndex].Color || extractedItems[editingIndex].color || ''}
|
||||
onChange={(e) => updateEditingItem({ Color: e.target.value })}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. Aqua"
|
||||
/>
|
||||
</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>
|
||||
<input
|
||||
value={extractedItems[editingIndex].box_label || ''}
|
||||
list="onboarding-boxes"
|
||||
onChange={(e) => updateEditingItem({ box_label: e.target.value })}
|
||||
className="bg-transparent w-full text-sm font-bold outline-none text-primary"
|
||||
placeholder="e.g. Box 1"
|
||||
/>
|
||||
<datalist id="onboarding-boxes">
|
||||
@@ -276,98 +461,150 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Description</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-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Description</label>
|
||||
<textarea
|
||||
value={extractedData.description || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, description: e.target.value})}
|
||||
className="bg-transparent w-full text-sm leading-relaxed outline-none resize-none h-16 text-slate-300"
|
||||
value={extractedItems[editingIndex].Description || extractedItems[editingIndex].description || ''}
|
||||
onChange={(e) => updateEditingItem({ Description: e.target.value })}
|
||||
className="bg-transparent w-full text-sm leading-tight outline-none resize-none h-8 text-slate-300 py-0"
|
||||
placeholder="Product description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Connector</label>
|
||||
<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>
|
||||
<input
|
||||
value={extractedData.connector || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, connector: e.target.value})}
|
||||
value={extractedItems[editingIndex].Connector || extractedItems[editingIndex].connector || ''}
|
||||
onChange={(e) => updateEditingItem({ Connector: e.target.value })}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. LC/UPC"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Size / Length</label>
|
||||
<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>
|
||||
<input
|
||||
value={extractedData.size || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, size: e.target.value})}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
value={extractedItems[editingIndex].Size || extractedItems[editingIndex].size || ''}
|
||||
onChange={(e) => updateEditingItem({ Size: e.target.value })}
|
||||
className="bg-transparent w-full text-sm font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. 5m / 10G"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border-2 border-primary/20 p-5 rounded-[1.5rem] shadow-lg">
|
||||
<div className="flex items-center gap-2 mb-2 text-primary">
|
||||
<Sparkles size={14} />
|
||||
<label className="text-xs font-black uppercase tracking-tighter">AI OCR Matching Key</label>
|
||||
</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>
|
||||
<textarea
|
||||
value={extractedData.ocr_text || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, ocr_text: e.target.value})}
|
||||
className="bg-transparent w-full text-[10px] font-mono leading-tight outline-none resize-none h-12 text-slate-400"
|
||||
value={extractedItems[editingIndex].OCR || extractedItems[editingIndex].ocr_text || ''}
|
||||
onChange={(e) => updateEditingItem({ OCR: e.target.value })}
|
||||
className="bg-transparent w-full text-sm font-bold leading-tight outline-none resize-none h-10 text-slate-200 py-0 scrollbar-hide"
|
||||
placeholder="Heuristic string for local matching..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Part Number (P/N)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash size={14} className="text-primary/60" />
|
||||
<input
|
||||
value={extractedData.part_number || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, part_number: e.target.value})}
|
||||
className="bg-transparent w-full font-mono text-sm outline-none text-slate-200"
|
||||
placeholder="ID code..."
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<input
|
||||
value={extractedItems[editingIndex].PartNr || extractedItems[editingIndex].part_number || ''}
|
||||
onChange={(e) => updateEditingItem({ PartNr: e.target.value })}
|
||||
className="bg-transparent w-full font-mono text-sm font-bold outline-none text-slate-200 uppercase"
|
||||
placeholder="ID code..."
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Initial Stock</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout size={14} className="text-amber-500/60" />
|
||||
<input
|
||||
type="number"
|
||||
value={extractedData.quantity || 1}
|
||||
onChange={(e) => setExtractedData({...extractedData, quantity: parseInt(e.target.value)})}
|
||||
className="bg-transparent w-full font-black text-lg outline-none text-slate-200"
|
||||
/>
|
||||
</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>
|
||||
<input
|
||||
type="number"
|
||||
value={extractedItems[editingIndex].quantity || 1}
|
||||
onChange={(e) => updateEditingItem({ quantity: parseFloat(e.target.value) })}
|
||||
className="bg-transparent w-full font-black text-base outline-none text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/30 p-4 rounded-[1.5rem] border border-slate-800/50">
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block">System Metadata (S/N if found)</label>
|
||||
<div className="text-[10px] text-slate-500 font-mono flex flex-wrap gap-2">
|
||||
{extractedData.serial_number && <span>S/N: {extractedData.serial_number}</span>}
|
||||
{extractedData.additional_data && <span>+ More Data</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 shrink-0">
|
||||
<button
|
||||
onClick={confirmOnboarding}
|
||||
onClick={() => confirmSingleItem(editingIndex)}
|
||||
className="py-5 bg-primary text-white rounded-[1.8rem] font-black text-lg shadow-2xl shadow-primary/20 active:scale-95 transition-all hover:bg-blue-500"
|
||||
>
|
||||
Confirm to Catalog
|
||||
Add to Catalog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-black text-slate-400 uppercase tracking-widest">Discovery Dashboard</h3>
|
||||
<span className="text-xs bg-primary/20 text-primary px-3 py-1 rounded-full font-bold">
|
||||
{extractedItems.length} items found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-1 scrollbar-hide">
|
||||
{extractedItems.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-slate-900/50 border border-slate-800 rounded-3xl p-5 flex items-center justify-between group hover:border-primary/40 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => setEditingIndex(idx)}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Package size={16} />
|
||||
</div>
|
||||
<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">
|
||||
{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">
|
||||
{item.PartNr || item.part_number || "No P/N"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newItems = [...extractedItems];
|
||||
newItems.splice(idx, 1);
|
||||
setExtractedItems(newItems);
|
||||
}}
|
||||
className="p-3 text-slate-600 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingIndex(idx)}
|
||||
className="p-3 bg-primary/10 text-primary rounded-2xl hover:bg-primary/20 transition-all"
|
||||
>
|
||||
<ChevronDown size={20} className="-rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 shrink-0">
|
||||
<button
|
||||
onClick={confirmAllItems}
|
||||
className="py-5 bg-white text-slate-950 rounded-[1.8rem] font-black text-lg shadow-2xl shadow-white/10 active:scale-95 transition-all hover:bg-slate-200"
|
||||
>
|
||||
Add {extractedItems.length} items to catalog
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExtractedData(null)}
|
||||
className="py-3 text-xs text-slate-600 font-bold hover:text-slate-400"
|
||||
onClick={() => {
|
||||
setExtractedItems([]);
|
||||
setImage(null);
|
||||
}}
|
||||
className="py-3 text-xs text-slate-600 font-bold hover:text-slate-400 tracking-widest uppercase"
|
||||
>
|
||||
Reset Extraction
|
||||
Discard Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,8 @@ export default function BottomNav({
|
||||
const isAdmin = pathname === '/admin';
|
||||
|
||||
return (
|
||||
<footer className="fixed bottom-0 left-0 right-0 p-4 pb-safe bg-slate-950/80 backdrop-blur-md border-t border-slate-900 z-40">
|
||||
<div className="max-w-4xl mx-auto flex justify-around items-center text-slate-400">
|
||||
<footer className="fixed bottom-0 left-0 right-0 py-3 px-2 pb-safe bg-slate-950/80 backdrop-blur-lg border-t border-slate-900/50 z-40 transition-all duration-300">
|
||||
<div className="max-w-xl mx-auto flex justify-around items-center text-slate-400">
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
|
||||
@@ -56,143 +56,149 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
||||
setSelectedUserForLogin(user);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-login for passwordless users (if any exist beyond Admin)
|
||||
onAuthenticated(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in zoom-in duration-300">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 max-w-sm w-full shadow-2xl space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="w-16 h-16 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto mb-4 border border-primary/20">
|
||||
<User size={32} />
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-2xl animate-in fade-in duration-500">
|
||||
<div className="bg-slate-900/80 border border-slate-800 rounded-[2.5rem] p-8 sm:p-10 max-w-sm w-full shadow-2xl space-y-8 animate-in zoom-in-95 duration-300 relative overflow-hidden">
|
||||
{/* Subtle accent light */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-40 h-1 bg-gradient-to-r from-transparent via-primary/50 to-transparent blur-sm" />
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<div className="w-20 h-20 bg-primary/10 text-primary rounded-[2rem] flex items-center justify-center mx-auto mb-6 border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Shield size={40} className="italic" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white">Identity Check</h2>
|
||||
<p className="text-slate-500 text-sm">Select operator profile to continue</p>
|
||||
<h2 className="text-3xl font-black text-white tracking-tight">Protocol Access</h2>
|
||||
<p className="text-slate-500 text-[10px] font-black uppercase tracking-widest">Select operator profile to initialize</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-3.5">
|
||||
{!selectedUserForLogin && !isEnterprise ? (
|
||||
<>
|
||||
{users.map(user => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className="bg-slate-800/50 hover:bg-slate-800 border border-slate-800 hover:border-primary/40 p-4 rounded-2xl text-left transition-all group flex items-center justify-between"
|
||||
className="bg-slate-800/40 hover:bg-slate-800 border border-slate-800/50 hover:border-primary/40 p-5 rounded-[1.5rem] text-left transition-all group flex items-center justify-between shadow-sm active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-900 border border-slate-700 flex items-center justify-center text-slate-500 group-hover:text-primary transition-colors">
|
||||
{user.role === 'admin' ? <Shield size={14} /> : <User size={14} />}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-2xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-500 group-hover:text-primary transition-all group-hover:scale-110">
|
||||
{user.role === 'admin' ? <Shield size={18} /> : <User size={18} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-black text-sm">{user.username}</p>
|
||||
<p className="text-xs text-slate-500 font-medium mt-1">{user.role}</p>
|
||||
<p className="text-slate-100 font-black text-sm tracking-tight">{user.username}</p>
|
||||
<p className="text-[9px] text-slate-600 font-black uppercase tracking-widest mt-0.5">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-slate-600 group-hover:text-primary transition-colors" />
|
||||
<div className="p-1.5 rounded-full bg-slate-900/50 text-slate-700 group-hover:text-primary group-hover:bg-primary/10 transition-all">
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => setIsEnterprise(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-4 rounded-2xl border border-dashed border-slate-700 text-slate-500 hover:text-primary hover:border-primary/40 transition-all font-bold text-xs"
|
||||
className="w-full flex items-center justify-center gap-3 py-5 rounded-[1.5rem] border border-dashed border-slate-800 text-slate-600 hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-all font-black text-[10px] uppercase tracking-widest"
|
||||
>
|
||||
<Shield size={14} />
|
||||
Enterprise Login
|
||||
<Lock size={14} />
|
||||
Enterprise Directory
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : isEnterprise ? (
|
||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<p className="text-xs font-black text-slate-500">Enterprise Account</p>
|
||||
<p className="text-[10px] font-black text-slate-600 uppercase tracking-widest italic">LDAP Authentication</p>
|
||||
<button
|
||||
onClick={() => setIsEnterprise(false)}
|
||||
className="text-xs font-black text-primary hover:underline"
|
||||
className="text-[10px] font-black text-primary hover:underline uppercase tracking-tighter"
|
||||
>
|
||||
Back to profiles
|
||||
Profile Swap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<div className="relative group">
|
||||
<User className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
|
||||
<input
|
||||
ref={enterpriseUserRef}
|
||||
type="text"
|
||||
autoFocus
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="e.g. jsmith"
|
||||
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="DIRECTORY ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
|
||||
<input
|
||||
ref={enterprisePassRef}
|
||||
type="password"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white/50 focus:text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="Enter password"
|
||||
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="w-full bg-primary text-white font-black py-4 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all"
|
||||
className="w-full bg-primary text-white font-black py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all uppercase text-[10px] tracking-widest border border-primary/20"
|
||||
>
|
||||
Sign In
|
||||
Initialize Session
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="bg-slate-800/30 p-4 rounded-2xl border border-slate-800 flex items-center gap-3">
|
||||
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="bg-slate-950/50 p-5 rounded-[1.5rem] border border-slate-800 flex items-center justify-between group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-lg">
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Active Profile</p>
|
||||
<p className="text-white font-black tracking-tight">{selectedUserForLogin.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedUserForLogin(null)}
|
||||
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-slate-800 rounded-xl transition-all text-slate-600 hover:text-rose-500"
|
||||
title="Change User"
|
||||
>
|
||||
<X size={16} className="text-slate-400" />
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-500">Logging in as</p>
|
||||
<p className="text-white font-black">{selectedUserForLogin.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
|
||||
<input
|
||||
ref={localPassRef}
|
||||
type="password"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white/50 focus:text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="Enter password"
|
||||
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono tracking-widest"
|
||||
placeholder="KEY-PHRASE"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="w-full bg-primary text-white font-black py-4 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all"
|
||||
className="w-full bg-primary text-white font-black py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all uppercase text-[10px] tracking-widest border border-primary/20"
|
||||
>
|
||||
Verify Identity
|
||||
Unlock Account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="text-center p-8 text-slate-500 animate-pulse">
|
||||
Initializing users...
|
||||
<div className="text-center p-8 text-slate-700 animate-pulse font-black text-[10px] uppercase tracking-[0.2em]">
|
||||
Synchronizing User Tokens...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -215,26 +215,27 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
|
||||
{/* Video Viewport Area */}
|
||||
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
|
||||
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-slate-950 border-2 border-slate-800/50">
|
||||
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
|
||||
<div className="w-[320px] h-[320px] border-2 border-primary/50 rounded-3xl relative">
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
|
||||
<div className="w-[85%] h-[85%] border border-primary/30 rounded-[2rem] relative">
|
||||
<div className="absolute top-0 left-0 w-10 h-10 border-t-4 border-l-4 border-primary rounded-tl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||
<div className="absolute top-0 right-0 w-10 h-10 border-t-4 border-r-4 border-primary rounded-tr-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||
<div className="absolute bottom-0 left-0 w-10 h-10 border-b-4 border-l-4 border-primary rounded-bl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||
<div className="absolute bottom-0 right-0 w-10 h-10 border-b-4 border-r-4 border-primary rounded-br-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||
|
||||
{isStarted && !paused && !isSelecting && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary to-transparent shadow-[0_0_20px_rgba(59,130,246,0.8)] animate-scan-fast" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
|
||||
<div id={scannerId} className="w-full h-full bg-slate-900 object-cover" />
|
||||
|
||||
{/* Selection UI */}
|
||||
{isSelecting && capturedImage && (
|
||||
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
|
||||
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
|
||||
<img src={capturedImage} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
|
||||
<img src={capturedImage || undefined} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative" style={{ width: '100%', height: '100%' }}>
|
||||
{detectedWords.map((w, i) => (
|
||||
@@ -253,13 +254,16 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-900 border-t border-slate-800 flex flex-col gap-4">
|
||||
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
|
||||
<div className="p-6 bg-slate-900/90 backdrop-blur-xl border-t border-slate-800 flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-sm font-black text-white italic text-center">Protocol Detected</p>
|
||||
<p className="text-[10px] text-slate-500 font-black uppercase tracking-widest text-center">Select identity from label matrix</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }}
|
||||
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-bold text-xs"
|
||||
className="w-full py-4 bg-slate-800 hover:bg-slate-700 text-white rounded-2xl font-black text-[10px] uppercase tracking-widest transition-all active:scale-95 border border-slate-700"
|
||||
>
|
||||
Cancel & rescans
|
||||
Abort Cycle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,8 +294,8 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
</div>
|
||||
|
||||
{/* External Controls Area */}
|
||||
<div className="flex flex-col gap-4 bg-slate-900/40 p-6 rounded-[2rem] border border-slate-800/50">
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<div className="flex flex-col gap-4 bg-slate-900/40 backdrop-blur-md p-5 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
{hasZoom && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -308,26 +312,26 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
setZoom(nextZoom);
|
||||
}
|
||||
}}
|
||||
className="h-16 px-6 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95"
|
||||
className="h-14 px-5 bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95 shrink-0"
|
||||
>
|
||||
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-[10px] text-primary font-bold">Zoom</span>
|
||||
<span className="text-xs font-black tabular-nums">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-[8px] text-primary font-black uppercase tracking-tighter">Zoom</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 h-16 bg-slate-800/50 border border-slate-700 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden">
|
||||
<div className="flex-1 h-14 bg-slate-950/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden group">
|
||||
{ocrProcessing ? (
|
||||
<>
|
||||
<RefreshCw className="animate-spin text-primary" size={20} />
|
||||
<span className="text-sm font-bold text-slate-200">Analyzing labels...</span>
|
||||
<RefreshCw className="animate-spin text-primary" size={18} />
|
||||
<span className="text-[10px] font-black text-slate-200 uppercase tracking-widest leading-none">Analyzing</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} />
|
||||
<Search className={cn("text-slate-600 transition-colors group-hover:text-primary", !isStarted && "opacity-20")} size={18} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-slate-500 font-bold leading-none">Label Scanning</span>
|
||||
<span className="text-sm font-black text-primary leading-tight">
|
||||
{countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`}
|
||||
<span className="text-[8px] text-slate-500 font-black leading-none uppercase tracking-widest">Optical Assist</span>
|
||||
<span className="text-xs font-black text-primary leading-tight uppercase tabular-nums">
|
||||
{countdown === 0 ? "Scanning" : `${countdown}s Cycle`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -335,16 +339,16 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
|
||||
{/* Visual Progress Bar */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 bg-primary/30 transition-all duration-1000 ease-linear"
|
||||
className="absolute bottom-0 left-0 h-0.5 bg-primary/40 transition-all duration-1000 ease-linear shadow-[0_0_10px_rgba(59,130,246,0.5)]"
|
||||
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<p className="text-[10px] text-slate-500 font-bold">
|
||||
Barcode auto-scan active
|
||||
<div className="w-1 h-1 rounded-full bg-green-500 animate-pulse" />
|
||||
<p className="text-[9px] text-slate-600 font-black uppercase tracking-[0.15em]">
|
||||
Optical Data Stream Active
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
25
frontend/components/StatCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center gap-2 p-3.5 bg-slate-900/50 backdrop-blur-md border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-slate-900/80" role="status">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />}
|
||||
<span className="text-sm md:text-base text-slate-400 font-medium truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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-[9px] font-black mt-0.5 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 py-0.5 rounded-full text-[8px] font-black text-white tracking-tighter">
|
||||
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-xs font-black 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-[10px] 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-[9px] font-black text-slate-500 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-[9px] font-black tracking-tighter 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-[9px] font-black text-slate-500 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-[9px] font-black uppercase tracking-tighter 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-[10px] font-black text-slate-500 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-[10px] 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-[11px] 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-[10px] font-bold text-slate-500 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
@@ -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-xs 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-[10px] 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-[10px] font-black text-slate-500 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-[10px] font-black text-slate-500 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-xs mb-4 tracking-tight">
|
||||
Update Asset Group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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-[10px] font-black text-slate-500 tracking-tight">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-[10px] font-black text-slate-500 tracking-tight">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-[10px] 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-[10px] text-slate-500 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-[9px] font-black text-slate-500 tracking-tight ml-1 flex items-center gap-2">
|
||||
<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-[9px] font-black text-slate-500 tracking-tight ml-1 flex items-center gap-2">
|
||||
<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-[9px] font-black text-slate-500 tracking-tight ml-1 flex items-center gap-2">
|
||||
<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-[10px] font-black text-slate-500 tracking-tight">Recovery Points</label>
|
||||
<div className="text-[10px] font-black 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-[11px] font-bold text-slate-200">{bak.filename}</p>
|
||||
<p className="text-[9px] text-slate-500 font-bold 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-[10px] 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-[10px] 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
@@ -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-xs 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-[9px] text-slate-500 font-black 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-[10px] font-black text-slate-500 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-[10px] font-black text-slate-500 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-[10px] font-black text-slate-500 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
@@ -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-[10px] font-black text-slate-200 tracking-tight leading-none">Ldap Authorization</p>
|
||||
<p className={cn("text-[8px] font-bold mt-1", ldapConfig.ldap_enabled ? "text-green-500/80" : "text-slate-600")}>
|
||||
{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-[10px] font-black text-slate-500 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-[10px] font-black text-slate-500 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-[10px] font-black text-slate-500 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-[10px] font-black text-slate-500 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-[10px] font-black text-slate-200 tracking-tight leading-none">Use Tls</p>
|
||||
<p className={cn("text-[8px] font-bold mt-1", ldapConfig.use_tls ? "text-indigo-400/80" : "text-slate-600")}>
|
||||
{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-xs 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-xs 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>
|
||||
);
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const getNetworkConfig = async () => {
|
||||
} catch (e) {
|
||||
console.warn("Network config not found, using compiled defaults.");
|
||||
// Defaults matching the initial reserve ports in case network.json is missing
|
||||
return { SERVER_IP: 'localhost', BACKEND_PORT: 8906, BACKEND_SSL_PORT: 8908 };
|
||||
return { SERVER_IP: 'localhost', BACKEND_PORT: 8916, BACKEND_SSL_PORT: 8918 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -247,5 +247,21 @@ export const inventoryApi = {
|
||||
updateAiPrompt: async (prompt: string) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/prompt', { value: prompt });
|
||||
return res.data;
|
||||
},
|
||||
getAiConfig: async () => {
|
||||
const res = await axiosInstance.get('/admin/db/settings/ai');
|
||||
return res.data;
|
||||
},
|
||||
updateAiProvider: async (provider: string) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/ai', { provider });
|
||||
return res.data;
|
||||
},
|
||||
updateAiKeys: async (keys: { gemini_api_key?: string, claude_api_key?: string }) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/ai-keys', keys);
|
||||
return res.data;
|
||||
},
|
||||
testAiKey: async (provider: string, key: string) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/test-ai-key', { provider, key });
|
||||
return res.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
@@ -27,10 +28,15 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^2.1.2",
|
||||
"@vitejs/plugin-react": "^4.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"SERVER_IP": "192.168.84.113",
|
||||
"SERVER_IP": "192.168.84.140",
|
||||
"BACKEND_PORT": 8916,
|
||||
"BACKEND_SSL_PORT": 8918,
|
||||
"FRONTEND_PORT": 8917,
|
||||
|
||||