Compare commits
10 Commits
v1.14.23
...
593c3ba5e6
| Author | SHA1 | Date | |
|---|---|---|---|
| 593c3ba5e6 | |||
| 70523be677 | |||
| cc6c70a467 | |||
| 978c6b8116 | |||
| 06718d3beb | |||
| 778d671a5f | |||
| 2b96d22eed | |||
| a6cb7e7bc7 | |||
| 73dde9b40d | |||
| fbab38b212 |
@@ -1,54 +0,0 @@
|
|||||||
# Milestone Verification: Phase 7 — Config Consolidation
|
|
||||||
|
|
||||||
**Audited:** 2026-04-23
|
|
||||||
**Version:** v1.14.21
|
|
||||||
**Status:** ✅ VERIFIED & COMPLETE
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Requirements Coverage
|
|
||||||
|
|
||||||
| Requirement | Implementation | Status |
|
|
||||||
|-------------|----------------|--------|
|
|
||||||
| Centralize Config in `config/` | All YAML files established in root `config/` | ✅ |
|
|
||||||
| Domain-Specific YAML | `backend.yaml`, `frontend.yaml`, `network.yaml`, `docker.yaml` | ✅ |
|
|
||||||
| SSOT for Network Topology | `network.yaml` established as Master authority | ✅ |
|
|
||||||
| Python-based Tooling | All `.sh` scripts converted to `.py` (D-05) | ✅ |
|
|
||||||
| Dynamic API/CORS Injection | Automatic calculation in `run_standalone.py` & `main.py` | ✅ |
|
|
||||||
| D-06 Load Order | System Env > YAML > Secrets > Defaults | ✅ |
|
|
||||||
| Legacy Cleanup | Removed `inventory.env`, `start_server.sh`, and shell scripts | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Integration Check
|
|
||||||
|
|
||||||
### 2.1 Cross-Phase Wiring
|
|
||||||
- **Backend**: `config_loader.py` correctly merges YAML files and secrets. AI and Auth routers updated to use centralized config.
|
|
||||||
- **Frontend**: API discovery wired through `network.json`. Launcher (`run_standalone.py`) now correctly synchronizes `network.yaml` to `network.json` at startup.
|
|
||||||
- **Scripts**: All management tools (`deploy.py`, `run_standalone.py`, `export_prod.py`, `restore_prod.py`) correctly parse YAML settings.
|
|
||||||
|
|
||||||
### 2.2 End-to-End Flows
|
|
||||||
- **Standalone Launch**: Verified working with `scripts/run_standalone.py`.
|
|
||||||
- **SSL Proxying**: Caddy correctly routes 8918/8919 to internal ports.
|
|
||||||
- **Admin Configuration**: LDAP and AI key updates verified to save directly to YAML/Secrets without side-files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Tech Debt & Deferred Gaps
|
|
||||||
|
|
||||||
- **Resolved during Audit:**
|
|
||||||
- Fixed `font-weight: 600` and `font-medium` violations (AI_RULES compliance).
|
|
||||||
- Fixed generic "Cancel" CTA labels in multiple components.
|
|
||||||
- Synchronized `network.json` generation in `run_standalone.py`.
|
|
||||||
- Refactored LDAP settings update logic to use `ConfigManager` (backend.yaml).
|
|
||||||
- Converted `backup.sh` and `restore.sh` to Python.
|
|
||||||
|
|
||||||
- **Remaining Debt:**
|
|
||||||
- None identified for Phase 7. The system is clean and aligned with Phase 8 readiness.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Final Verdict
|
|
||||||
The Config Consolidation milestone (Phase 7) has achieved its definition of done. All architectural decisions have been implemented, verified, and technical debt accumulated during the phase has been liquidated.
|
|
||||||
|
|
||||||
**Milestone Rating:** 10/10 (Excellent)
|
|
||||||
8
.planning/ui-reviews/.gitignore
vendored
8
.planning/ui-reviews/.gitignore
vendored
@@ -1,8 +0,0 @@
|
|||||||
# Screenshot files — never commit binary assets
|
|
||||||
*.png
|
|
||||||
*.webp
|
|
||||||
*.jpg
|
|
||||||
*.jpeg
|
|
||||||
*.gif
|
|
||||||
*.bmp
|
|
||||||
*.tiff
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
5813
|
826698
|
||||||
5814
|
826699
|
||||||
5831
|
826713
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# Phase 7 — UI Review
|
|
||||||
|
|
||||||
**Audited:** 2026-04-23
|
|
||||||
**Baseline:** AI_RULES.md & PROJECT_ARCHITECTURE.md
|
|
||||||
**Screenshots:** Captured to `.planning/ui-reviews/07-20260423-160359/`
|
|
||||||
|
|
||||||
## Pillar Scores
|
|
||||||
|
|
||||||
| Pillar | Score | Key Finding |
|
|
||||||
|--------|-------|-------------|
|
|
||||||
| 1. Copywriting | 3/4 | Descriptive labels used ("Discard", "Subtract"), but generic "Cancel" buttons remain. |
|
|
||||||
| 2. Visuals | 4/4 | Excellent adherence to `max-w-7xl` and Lucide icons. Unified headers implemented. |
|
|
||||||
| 3. Color | 4/4 | Good 60/30/10 split. No arbitrary hex codes in UI layers. |
|
|
||||||
| 4. Typography | 2/4 | **CRITICAL:** `globals.css` uses `font-weight: 600` for headers; `font-medium` (500) used in several components. Rule requires `font-normal` (400) only. |
|
|
||||||
| 5. Spacing | 4/4 | Consistent use of responsive `space-y-` and `p-` scales as per architecture. |
|
|
||||||
| 6. Experience Design | 4/4 | Strong sync indicators, loading states, and confirmation safeguards. |
|
|
||||||
|
|
||||||
**Overall Score:** 21/24
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detailed Findings
|
|
||||||
|
|
||||||
### Pillar 4: Typography (2/4)
|
|
||||||
- **Violation:** `frontend/app/globals.css:26` defines `h1-h6` with `font-weight: 600`.
|
|
||||||
- **Violation:** `frontend/components/admin/DatabaseManager.tsx:141` and others use `font-medium`.
|
|
||||||
- **Requirement:** AI_RULES.md Section 3 explicitly forbids bold fonts and requires `font-normal` throughout.
|
|
||||||
|
|
||||||
### Pillar 1: Copywriting (3/4)
|
|
||||||
- **Pro:** UI uses descriptive actions: "Force Backup", "Save LDAP Policy", "Subtract 1 from Stock".
|
|
||||||
- **Improvement:** "DELETE" (uppercase) is used in confirmation modals. While good for safety, it technically bypasses the "NO UPPERCASE" rule. Consider using Title Case "Delete" with a specific background color instead.
|
|
||||||
|
|
||||||
### Pillar 2: Visuals (4/4)
|
|
||||||
- **Success:** Main pages correctly utilize `max-w-7xl` with `mx-auto`.
|
|
||||||
- **Success:** Unified headers with icon boxes found in `admin/page.tsx` and `page.tsx`.
|
|
||||||
|
|
||||||
### Pillar 6: Experience Design (4/4)
|
|
||||||
- **Success:** Offline/Online status indicators are prominent and animated.
|
|
||||||
- **Success:** Destructive actions like "Delete Item" and "Logout" utilize `window.confirm` or high-fidelity modals as required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Top 3 Priority Fixes
|
|
||||||
1. **Remove Font Weights** — Remove `font-weight: 600` from `globals.css` (headers) and replace all `font-medium` with `font-normal`. Hierarchy must rely on size and color only.
|
|
||||||
2. **Contextual CTA Labels** — Replace generic "Cancel" and "OK" with contextual verbs (e.g., "Keep Item", "Discard Changes") to improve copywriting fidelity.
|
|
||||||
3. **Focus State Consistency** — Ensure all interactive elements (buttons, inputs) have consistent `focus-visible:ring-2` styling for accessibility.
|
|
||||||
|
|
||||||
---
|
|
||||||
**Files Audited:**
|
|
||||||
- `frontend/app/globals.css`
|
|
||||||
- `frontend/app/page.tsx`
|
|
||||||
- `frontend/app/admin/page.tsx`
|
|
||||||
- `frontend/components/PageShell.tsx`
|
|
||||||
- `frontend/components/admin/DatabaseManager.tsx`
|
|
||||||
- `frontend/components/admin/LdapManager.tsx`
|
|
||||||
---
|
|
||||||
@@ -26,7 +26,6 @@ This is the **Single Source of Truth** for ALL AI agents. Refer to [PROJECT_ARCH
|
|||||||
- **NO UPPERCASE** or **NO ITALICS** in any UI context (headers, labels, buttons).
|
- **NO UPPERCASE** or **NO ITALICS** in any UI context (headers, labels, buttons).
|
||||||
- **NO BOLD FONTS**: Use `font-normal` throughout. Hierarchy via size and color only.
|
- **NO BOLD FONTS**: Use `font-normal` throughout. Hierarchy via size and color only.
|
||||||
- **NO `tracking-widest`**.
|
- **NO `tracking-widest`**.
|
||||||
- **StatCard Typography**: Numeric values in StatCards MUST match the exact text size of their corresponding labels (e.g., `text-base md:text-lg`) to avoid overwhelming the density of the component.
|
|
||||||
- **Layout**: Main pages MUST use `max-w-7xl`.
|
- **Layout**: Main pages MUST use `max-w-7xl`.
|
||||||
- **Unified Headers**: Icon box (`p-4 bg-primary/10 border-primary/20`) + Title (`text-3xl font-normal`).
|
- **Unified Headers**: Icon box (`p-4 bg-primary/10 border-primary/20`) + Title (`text-3xl font-normal`).
|
||||||
- **Iconography**: Use **Lucide Icons** exclusively.
|
- **Iconography**: Use **Lucide Icons** exclusively.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
local_certs
|
local_certs
|
||||||
skip_install_trust
|
skip_install_trust
|
||||||
|
|
||||||
|
|
||||||
on_demand_tls {
|
on_demand_tls {
|
||||||
ask http://localhost:8916/
|
ask http://localhost:8916/
|
||||||
}
|
}
|
||||||
@@ -18,15 +17,7 @@ https://:8919 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reverse_proxy localhost:8917 {
|
reverse_proxy localhost:8917 {
|
||||||
handle /_next/webpack-hmr {
|
header_up X-Forwarded-For {remote_host}
|
||||||
reverse_proxy http://localhost:8917 {
|
|
||||||
header_up Upgrade {>Upgrade}
|
|
||||||
header_up Connection {>Connection}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reverse_proxy http://localhost:8917 {
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
header_up X-Forwarded-Proto https
|
header_up X-Forwarded-Proto https
|
||||||
header_up X-Forwarded-Host {host}
|
header_up X-Forwarded-Host {host}
|
||||||
}
|
}
|
||||||
@@ -46,7 +37,7 @@ https://:8918 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reverse_proxy localhost:8916 {
|
reverse_proxy localhost:8916 {
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto https
|
header_up X-Forwarded-Proto https
|
||||||
header_up X-Forwarded-Host {host}
|
header_up X-Forwarded-Host {host}
|
||||||
}
|
}
|
||||||
|
|||||||
228
DESIGN-TYPOGRAPHY-HIERARCHY.md
Normal file
228
DESIGN-TYPOGRAPHY-HIERARCHY.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Design: Typography & Visual Hierarchy Overhaul
|
||||||
|
|
||||||
|
Generated by /gstack-office-hours on 2026-04-19
|
||||||
|
Branch: dev
|
||||||
|
Repo: tfm_ainventory
|
||||||
|
Status: DRAFT
|
||||||
|
Mode: Builder (Intrapreneurship)
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current UI has weak visual hierarchy on tablets and larger screens. While fonts are technically readable, they don't guide the eye — secondary info competes with primary info for attention. Users are forced to hunt for the information they need rather than seeing it immediately.
|
||||||
|
|
||||||
|
**Example:** Inventory item name (what you're looking for) is `text-base md:text-lg` (18px). The category label next to it is almost the same size. Which is more important? The UI doesn't say.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
**Typography scale (Tailwind defaults):**
|
||||||
|
- Labels/metadata: `text-xs` to `text-base` (12–16px)
|
||||||
|
- Headings: `text-lg` to `text-xl` (18–20px)
|
||||||
|
- Values/highlights: `text-2xl` to `text-3xl` (24–30px)
|
||||||
|
- Page titles: `text-3xl` to `text-4xl` (30–36px)
|
||||||
|
|
||||||
|
**Problem:** This scale flattens on tablets. A 18px label looks like a headline. A 30px value looks like a label. The hierarchy collapses.
|
||||||
|
|
||||||
|
## Premises
|
||||||
|
|
||||||
|
1. **Font size alone doesn't create hierarchy.** Weight, color, spacing, and contrast matter more than raw pixels.
|
||||||
|
2. **Tablet is a first-class device.** The app is used on iPads in warehouses. Design for that, not just mobile.
|
||||||
|
3. **Visual hierarchy = faster task completion.** If the user sees the item quantity immediately, they spend 0.5 seconds scanning. If they hunt, it's 3 seconds per item. Over 100 items, that's 4 minutes lost per session.
|
||||||
|
4. **We don't need bigger type everywhere.** Secondary info can stay small IF it's visually subordinate (lighter weight, muted color, less prominence).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What's the primary use case breakdown? Mostly mobile in the field, mostly tablet in warehouse, or mixed 50/50?
|
||||||
|
- Do users ever need to scan and read fine details simultaneously (e.g., zoom control labels)?
|
||||||
|
- Is there a standard tablet size we're optimizing for (iPad 10.2", iPad Pro 12.9", or both)?
|
||||||
|
|
||||||
|
## Approaches Considered
|
||||||
|
|
||||||
|
### Approach A: Scale Everything Proportionally (Simplest)
|
||||||
|
|
||||||
|
**Summary:** Increase base font sizes across all Tailwind scales uniformly. `text-base` → `text-lg`, `text-lg` → `text-xl`, etc. On tablets, everything gets bigger, hierarchy stays the same.
|
||||||
|
|
||||||
|
**Effort:** S (one config change to tailwind.config.ts, maybe 15 min)
|
||||||
|
|
||||||
|
**Risk:** Low
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- One-line fix in Tailwind config
|
||||||
|
- Consistent everywhere
|
||||||
|
- Easiest to test and verify
|
||||||
|
- No refactoring needed
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Doesn't actually improve hierarchy — just makes things bigger
|
||||||
|
- Wasteful on mobile (text gets huge, less content per screen)
|
||||||
|
- Doesn't address the real problem: weak contrast between important and unimportant info
|
||||||
|
- Secondary info still competes for attention, just in a bigger font
|
||||||
|
|
||||||
|
**Reuses:** Tailwind's built-in scale
|
||||||
|
|
||||||
|
**Implementation:** Extend `fontSize` in tailwind.config.ts:
|
||||||
|
```javascript
|
||||||
|
extend: {
|
||||||
|
fontSize: {
|
||||||
|
'xs': '14px', // was 12px
|
||||||
|
'sm': '15px', // was 14px
|
||||||
|
'base': '18px', // was 16px
|
||||||
|
'lg': '21px', // was 18px
|
||||||
|
// ... etc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Approach B: Responsive Scale + Weight-Based Hierarchy (Recommended)
|
||||||
|
|
||||||
|
**Summary:** Keep mobile readable, boost tablet sizes moderately. More importantly, use **font weight** (not just size) to create hierarchy. Primary info is bold/heavy; secondary is regular/light. Add color emphasis (primary color for key values).
|
||||||
|
|
||||||
|
**Effort:** M (audit components for hierarchy, add weight/color rules, test on device, ~2 hours)
|
||||||
|
|
||||||
|
**Risk:** Medium (changes visual feel, needs design review)
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Mobile stays readable (no bloat)
|
||||||
|
- Tablets get 20-30% bigger type
|
||||||
|
- Weight creates REAL hierarchy instantly (user's eye goes to bold text)
|
||||||
|
- Pairs with color (key values in primary color, metadata in muted gray)
|
||||||
|
- Feels intentional, not lazy
|
||||||
|
- Works across all screen sizes
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Requires auditing every component (20+ files)
|
||||||
|
- Needs design review to ensure consistency
|
||||||
|
- More moving parts to get right
|
||||||
|
- Testing on actual tablets essential
|
||||||
|
|
||||||
|
**Reuses:** Tailwind's weight classes, existing color system
|
||||||
|
|
||||||
|
**Implementation Pattern:**
|
||||||
|
```jsx
|
||||||
|
// OLD: No hierarchy
|
||||||
|
<span className="text-lg text-secondary">Item Name</span>
|
||||||
|
<span className="text-lg text-muted">Category</span>
|
||||||
|
|
||||||
|
// NEW: Clear hierarchy
|
||||||
|
<span className="text-lg md:text-xl font-bold text-white">Item Name</span>
|
||||||
|
<span className="text-sm md:text-base font-normal text-muted">Category</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tablet-specific rules:** Add responsive weights:
|
||||||
|
```javascript
|
||||||
|
extend: {
|
||||||
|
fontSize: {
|
||||||
|
'sm': ['14px', { lineHeight: '1.5' }],
|
||||||
|
'base': ['16px', { lineHeight: '1.6' }],
|
||||||
|
'lg': ['18px', { lineHeight: '1.6' }],
|
||||||
|
'xl': ['20px', { lineHeight: '1.5' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Approach C: Fluid Typography + Dynamic Scaling (Future-proof)
|
||||||
|
|
||||||
|
**Summary:** Use CSS custom properties (variables) to scale type fluidly from mobile → tablet → desktop. Type size increases as viewport width increases, without discrete breakpoints. Add hierarchy through weight, spacing, and contrast.
|
||||||
|
|
||||||
|
**Effort:** L (requires CSS architecture change, build TypeScript scale generator, test thoroughly, ~4 hours)
|
||||||
|
|
||||||
|
**Risk:** High (new tooling, requires testing on 3+ device sizes)
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Scales beautifully on ALL viewport sizes (not just mobile/md/lg breakpoints)
|
||||||
|
- Future-proof (works on foldables, 5" phones, 27" displays)
|
||||||
|
- Hierarchy through weight + color, not just size
|
||||||
|
- Professional feel (type gets more generous as screen gets bigger)
|
||||||
|
- One source of truth (single scale definition)
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Adds build-time complexity (need a script to generate scales)
|
||||||
|
- CSS custom properties have limited browser support (but fine for modern browsers)
|
||||||
|
- More moving parts, harder to debug
|
||||||
|
- Needs comprehensive testing
|
||||||
|
- Overkill if app is only mobile + tablet
|
||||||
|
|
||||||
|
**Reuses:** Tailwind, but adds custom CSS layer
|
||||||
|
|
||||||
|
**Implementation idea:**
|
||||||
|
```css
|
||||||
|
/* Define fluid scale as CSS variables */
|
||||||
|
:root {
|
||||||
|
/* At 375px (mobile), base = 16px. At 1440px (desktop), base = 18px. */
|
||||||
|
--font-base: clamp(16px, 2.5vw, 18px);
|
||||||
|
--font-lg: clamp(18px, 3vw, 20px);
|
||||||
|
--font-xl: clamp(20px, 3.5vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use in components */
|
||||||
|
.text-base { font-size: var(--font-base); }
|
||||||
|
.text-lg { font-size: var(--font-lg); }
|
||||||
|
.text-xl { font-size: var(--font-xl); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Approach: B (Responsive Scale + Weight-Based Hierarchy)
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Solves the real problem (hierarchy, not just size)
|
||||||
|
- Effort-to-impact ratio is excellent
|
||||||
|
- Works across all devices without bloat
|
||||||
|
- Uses tools already in the design system
|
||||||
|
- Reviewable component-by-component (low risk of breaking things)
|
||||||
|
|
||||||
|
**Concrete next step:**
|
||||||
|
1. Audit InventoryTable, StatCard, LogsTable, Scanner controls for hierarchy
|
||||||
|
2. Add weight rules: primary data `font-bold`, metadata `font-normal`
|
||||||
|
3. Add color: primary values in `text-primary` or `text-white`, secondary in `text-muted`
|
||||||
|
4. Test on iPad (actual device or browser dev tools)
|
||||||
|
5. Review with user on actual tablet to verify readability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- **Tablet test (10.2" iPad):** User can read item names, quantities, and key metadata without squinting at normal viewing distance (12-18 inches)
|
||||||
|
- **Hierarchy test:** In a list of 20 items, user can identify which is the primary info (name/quantity) in <1 second without searching
|
||||||
|
- **Mobile preservation:** iPhone 14 screen still shows useful content (no excessive whitespace)
|
||||||
|
- **Consistency:** Same typography rules applied across Scanner, Inventory, Admin, Logs pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Distribution Plan
|
||||||
|
|
||||||
|
No new dependencies or build system changes (unless Approach C chosen).
|
||||||
|
Changes ship in the existing `dev` branch → `master` on next release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Tailwind CSS (already in use)
|
||||||
|
- Design review from user on tablet device (essential)
|
||||||
|
- Testing across: iPhone 12+, iPad 10.2", iPad Pro 12.9" (ideally)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Assignment
|
||||||
|
|
||||||
|
**Before next office hours:**
|
||||||
|
1. Load the app on an actual tablet (iPad or comparable).
|
||||||
|
2. Open the Inventory page, Scanner page, and Admin Dashboard.
|
||||||
|
3. Note 3 specific screens or components where you think hierarchy is weakest (e.g., "StatCard mixes label and value with equal prominence").
|
||||||
|
4. Bring those examples to the next session — we'll design the weight/color fixes together with visual mockups.
|
||||||
|
|
||||||
|
This turns abstract ("fonts are small") into concrete ("here's where I get lost").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I Noticed About How You Think
|
||||||
|
|
||||||
|
- You didn't say "make fonts bigger" — you said "better visual hierarchy." That's a designer's instinct, not an engineer's kneejerk response. You're thinking about information clarity, not pixel counts.
|
||||||
|
- You identified the problem on tablets specifically, not mobile. That's precise observation. Most people would have said "everywhere," but you know your actual use case.
|
||||||
|
- When asked what success looks like, you said hierarchy, not size. That shows you understand that **hierarchy IS the usability feature.** Big text that's all the same weight is just... big, hard-to-read text.
|
||||||
|
|
||||||
|
You're thinking like a product person. Keep that going.
|
||||||
@@ -28,10 +28,9 @@ A unified system for inventory management featuring web administration, offline
|
|||||||
|
|
||||||
### 2.3 Operations & Tooling
|
### 2.3 Operations & Tooling
|
||||||
- **PWA**: `next-pwa` (Service Workers + Manifest)
|
- **PWA**: `next-pwa` (Service Workers + Manifest)
|
||||||
- **HTTPS Proxy**: Caddy (Ports 8918/8919)
|
- **HTTPS Proxy**: Caddy (Port 8909)
|
||||||
- **Containerization**: Docker & Docker Compose
|
- **Containerization**: Docker & Docker Compose
|
||||||
- **Deployment**: `deploy.py` (Docker) or `run_standalone.py` (Standalone)
|
- **Deployment**: `deploy.sh` (Docker) or `start_server.sh` (Standalone)
|
||||||
- **Configuration**: Domain-specific YAML (`config/`) with `network.yaml` as SSOT.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -74,9 +73,9 @@ A unified system for inventory management featuring web administration, offline
|
|||||||
- **JWT**: Stateless tokens for API auth.
|
- **JWT**: Stateless tokens for API auth.
|
||||||
- **LDAP**: Primary source of truth for users in enterprise mode.
|
- **LDAP**: Primary source of truth for users in enterprise mode.
|
||||||
- **Password Caching**: Encrypted local cache for offline authentication.
|
- **Password Caching**: Encrypted local cache for offline authentication.
|
||||||
- **CORS**: Restricted origins in production via `config/network.yaml` (SSOT).
|
- **CORS**: Restricted origins in production via `config/backend.yaml`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2026-04-23
|
**Last Updated**: 2026-04-23
|
||||||
**Version**: 1.14.19
|
**Version**: 1.14.7
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
|
|||||||
"Size": "size",
|
"Size": "size",
|
||||||
"Color": "color",
|
"Color": "color",
|
||||||
"PartNr": "part_number",
|
"PartNr": "part_number",
|
||||||
"Specs": "specs",
|
|
||||||
"OCR": "ocr_text"
|
"OCR": "ocr_text"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
import logging
|
import logging
|
||||||
from .config_loader import load_config as loader_load_config, get_config as loader_get_config
|
from .config_loader import load_config, get_config
|
||||||
|
|
||||||
log = logging.getLogger("ainventory")
|
log = logging.getLogger("ainventory")
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ class ConfigManager:
|
|||||||
log.info(f"✅ Updated {path} with new values.")
|
log.info(f"✅ Updated {path} with new values.")
|
||||||
|
|
||||||
# Reload the global config
|
# Reload the global config
|
||||||
loader_load_config()
|
load_config()
|
||||||
return loader_get_config()
|
return get_config()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"❌ Failed to write {path}: {e}")
|
log.error(f"❌ Failed to write {path}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -72,78 +72,10 @@ class ConfigManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_secrets_path():
|
|
||||||
"""Returns the absolute path to secrets.yaml."""
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
project_root = os.path.dirname(base_dir)
|
|
||||||
return os.path.join(project_root, "config", "secrets.yaml")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_secrets(updates: dict) -> dict:
|
|
||||||
"""Update secrets.yaml with new values (flattened structure)."""
|
|
||||||
path = ConfigManager.get_secrets_path()
|
|
||||||
|
|
||||||
current_secrets = {}
|
|
||||||
if os.path.exists(path):
|
|
||||||
try:
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
current_secrets = yaml.safe_load(f) or {}
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"❌ Failed to read {path}: {e}")
|
|
||||||
|
|
||||||
# Update flattened secrets
|
|
||||||
current_secrets.update(updates)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
|
||||||
# Header for clarity
|
|
||||||
f.write("# TFM aInventory - Managed Secrets (Updated via Admin UI)\n")
|
|
||||||
yaml.dump(current_secrets, f, default_flow_style=False, sort_keys=True)
|
|
||||||
log.info(f"✅ Updated {path} with new secrets.")
|
|
||||||
|
|
||||||
# Reload the global config
|
|
||||||
loader_load_config()
|
|
||||||
return loader_get_config()
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"❌ Failed to write {path}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_keys(updates: dict) -> dict:
|
|
||||||
"""
|
|
||||||
Intelligent key update: routes sensitive AI keys to secrets.yaml
|
|
||||||
and others to backend.yaml.
|
|
||||||
"""
|
|
||||||
secrets_updates = {}
|
|
||||||
backend_updates = {}
|
|
||||||
|
|
||||||
sensitive_keys = ["GEMINI_API_KEY", "CLAUDE_API_KEY", "JWT_SECRET_KEY", "LDAP_PASSWORD"]
|
|
||||||
|
|
||||||
for key, val in updates.items():
|
|
||||||
if key in sensitive_keys:
|
|
||||||
secrets_updates[key] = val
|
|
||||||
else:
|
|
||||||
# Non-sensitive or complex nested settings
|
|
||||||
backend_updates[key] = val
|
|
||||||
|
|
||||||
if secrets_updates:
|
|
||||||
ConfigManager.update_secrets(secrets_updates)
|
|
||||||
|
|
||||||
if backend_updates:
|
|
||||||
ConfigManager.update_config(backend_updates)
|
|
||||||
|
|
||||||
return loader_get_config()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_config() -> dict:
|
|
||||||
"""Return the current global configuration."""
|
|
||||||
return loader_get_config()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_masked_key(key_name: str):
|
def get_masked_key(key_name: str):
|
||||||
"""Returns a masked version of a configuration value or env var."""
|
"""Returns a masked version of a configuration value or env var."""
|
||||||
config = loader_get_config()
|
config = get_config()
|
||||||
|
|
||||||
# Try to find in config first
|
# Try to find in config first
|
||||||
val = None
|
val = None
|
||||||
@@ -181,10 +113,6 @@ def update_config(updates: dict) -> dict:
|
|||||||
"""Update backend.yaml with new values and return updated config."""
|
"""Update backend.yaml with new values and return updated config."""
|
||||||
return ConfigManager.update_config(updates)
|
return ConfigManager.update_config(updates)
|
||||||
|
|
||||||
def get_config() -> dict:
|
|
||||||
"""Return the current global configuration."""
|
|
||||||
return loader_get_config()
|
|
||||||
|
|
||||||
def validate_config_file() -> bool:
|
def validate_config_file() -> bool:
|
||||||
"""Validate backend.yaml syntax and required fields."""
|
"""Validate backend.yaml syntax and required fields."""
|
||||||
return ConfigManager.validate_config_file()
|
return ConfigManager.validate_config_file()
|
||||||
|
|||||||
@@ -67,11 +67,8 @@ def get_ai_config(
|
|||||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||||
):
|
):
|
||||||
"""Check AI provider status and active provider."""
|
"""Check AI provider status and active provider."""
|
||||||
config = ConfigManager.get_config() # Alias for config_loader.get_config
|
gemini_key = os.environ.get("GEMINI_API_KEY")
|
||||||
ai_config = config.get("ai", {})
|
claude_key = os.environ.get("CLAUDE_API_KEY")
|
||||||
|
|
||||||
gemini_key = ai_config.get("gemini_api_key")
|
|
||||||
claude_key = ai_config.get("claude_api_key")
|
|
||||||
|
|
||||||
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||||
active_provider = provider_setting.value if provider_setting else "gemini"
|
active_provider = provider_setting.value if provider_setting else "gemini"
|
||||||
@@ -115,14 +112,10 @@ def update_ai_keys(
|
|||||||
if updates:
|
if updates:
|
||||||
ConfigManager.update_keys(updates)
|
ConfigManager.update_keys(updates)
|
||||||
|
|
||||||
# Re-load config to return accurate status
|
|
||||||
config = ConfigManager.get_config()
|
|
||||||
ai_cfg = config.get("ai", {})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"gemini_configured": bool(ai_cfg.get("gemini_api_key")),
|
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")),
|
||||||
"claude_configured": bool(ai_cfg.get("claude_api_key"))
|
"claude_configured": bool(os.environ.get("CLAUDE_API_KEY"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Supports CSV and Excel formats.
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
@@ -61,7 +61,7 @@ async def export_inventory_snapshot(
|
|||||||
# Log export action
|
# Log export action
|
||||||
from backend.models import AuditLog
|
from backend.models import AuditLog
|
||||||
audit_entry = AuditLog(
|
audit_entry = AuditLog(
|
||||||
user_id=admin_user.sub,
|
user_id=admin_user.id,
|
||||||
action="EXPORT_INVENTORY_SNAPSHOT",
|
action="EXPORT_INVENTORY_SNAPSHOT",
|
||||||
details=f"Exported in {format_type} format",
|
details=f"Exported in {format_type} format",
|
||||||
)
|
)
|
||||||
@@ -70,16 +70,20 @@ async def export_inventory_snapshot(
|
|||||||
|
|
||||||
# Return file response
|
# Return file response
|
||||||
if format_type == "csv":
|
if format_type == "csv":
|
||||||
return StreamingResponse(
|
# For CSV, use FileResponse with bytes
|
||||||
|
import io
|
||||||
|
return FileResponse(
|
||||||
io.BytesIO(content.encode("utf-8")),
|
io.BytesIO(content.encode("utf-8")),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
filename=filename,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return StreamingResponse(
|
# For Excel, content is already bytes
|
||||||
|
import io
|
||||||
|
return FileResponse(
|
||||||
io.BytesIO(content),
|
io.BytesIO(content),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
filename=filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +117,7 @@ async def export_audit_trail(
|
|||||||
|
|
||||||
# Log export action
|
# Log export action
|
||||||
audit_entry = AuditLog(
|
audit_entry = AuditLog(
|
||||||
user_id=admin_user.sub,
|
user_id=admin_user.id,
|
||||||
action="EXPORT_AUDIT_TRAIL",
|
action="EXPORT_AUDIT_TRAIL",
|
||||||
details=f"Exported in {format_type} format",
|
details=f"Exported in {format_type} format",
|
||||||
)
|
)
|
||||||
@@ -122,20 +126,22 @@ async def export_audit_trail(
|
|||||||
|
|
||||||
# Return file response
|
# Return file response
|
||||||
if format_type == "csv":
|
if format_type == "csv":
|
||||||
return StreamingResponse(
|
import io
|
||||||
|
return FileResponse(
|
||||||
io.BytesIO(content.encode("utf-8")),
|
io.BytesIO(content.encode("utf-8")),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
filename=filename,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return StreamingResponse(
|
import io
|
||||||
|
return FileResponse(
|
||||||
io.BytesIO(content),
|
io.BytesIO(content),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
filename=filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reports/export")
|
@router.get("/db/export")
|
||||||
async def export_db(
|
async def export_db(
|
||||||
format: str = Query("csv", description="Export format: csv or xlsx"),
|
format: str = Query("csv", description="Export format: csv or xlsx"),
|
||||||
type: str = Query("inventory", description="Export type: inventory, audit, or combined"),
|
type: str = Query("inventory", description="Export type: inventory, audit, or combined"),
|
||||||
@@ -211,7 +217,7 @@ async def export_db(
|
|||||||
|
|
||||||
# Log export action
|
# Log export action
|
||||||
audit_entry = AuditLog(
|
audit_entry = AuditLog(
|
||||||
user_id=admin_user.sub,
|
user_id=admin_user.id,
|
||||||
action="EXPORT_DB",
|
action="EXPORT_DB",
|
||||||
details=f"Exported {type} in {format_type} format",
|
details=f"Exported {type} in {format_type} format",
|
||||||
)
|
)
|
||||||
@@ -220,14 +226,14 @@ async def export_db(
|
|||||||
|
|
||||||
# Return file response
|
# Return file response
|
||||||
if format_type == "csv":
|
if format_type == "csv":
|
||||||
return StreamingResponse(
|
return FileResponse(
|
||||||
io.BytesIO(content.encode("utf-8")),
|
io.BytesIO(content.encode("utf-8")),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
filename=filename,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return StreamingResponse(
|
return FileResponse(
|
||||||
io.BytesIO(content),
|
io.BytesIO(content),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
filename=filename,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
from .. import models, schemas, database, auth
|
from .. import models, schemas, database, auth
|
||||||
from ..config_loader import get_config
|
from ..config_loader import get_config
|
||||||
from ..config_manager import ConfigManager
|
|
||||||
from ..logger import log
|
from ..logger import log
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["auth"])
|
router = APIRouter(prefix="/users", tags=["auth"])
|
||||||
@@ -266,27 +265,14 @@ def update_ldap_settings(
|
|||||||
current_user: auth.TokenData = Depends(auth.get_current_admin)
|
current_user: auth.TokenData = Depends(auth.get_current_admin)
|
||||||
):
|
):
|
||||||
"""[C-01] Update LDAP config — admin only."""
|
"""[C-01] Update LDAP config — admin only."""
|
||||||
# Map frontend keys back to backend YAML structure
|
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
updates = {
|
config_dir = os.path.join(root_dir, "config")
|
||||||
"auth": {
|
os.makedirs(config_dir, exist_ok=True)
|
||||||
"ldap_enabled": config.get("ldap_enabled", False),
|
config_path = os.path.join(config_dir, "ldap_config.json")
|
||||||
"ldap_server": config.get("server_uri", ""),
|
with open(config_path, "w") as f:
|
||||||
"ldap_base_dn": config.get("base_dn", ""),
|
json.dump(config, f, indent=2)
|
||||||
"ldap_user_template": config.get("user_template", ""),
|
log.info(f"LDAP config updated by {current_user.username}")
|
||||||
"ldap_groups_dn": config.get("groups_dn", ""),
|
return {"message": "Config saved"}
|
||||||
"ldap_use_tls": config.get("use_tls", True),
|
|
||||||
"ldap_ignore_cert": config.get("ignore_cert", False),
|
|
||||||
"ldap_role_mappings": config.get("role_mappings", [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
ConfigManager.update_config(updates)
|
|
||||||
log.info(f"LDAP config updated in backend.yaml by {current_user.username}")
|
|
||||||
return {"message": "Config saved successfully"}
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Failed to update LDAP config: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to save configuration: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-ldap")
|
@router.post("/test-ldap")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,58 +1,41 @@
|
|||||||
# TFM aInventory - Standalone Caddy Configuration
|
# TFM aInventory - Caddy Patched IP Configuration
|
||||||
# Self-signed SSL/TLS reverse proxy for any IP/hostname
|
# Version 1.9.17 - The Dynamic Shield (Production Polish)
|
||||||
{
|
{
|
||||||
admin off
|
admin off
|
||||||
# Global TLS options for self-signed certificates
|
# Global TLS options for self-signed certificates
|
||||||
|
local_certs
|
||||||
skip_install_trust
|
skip_install_trust
|
||||||
|
|
||||||
|
|
||||||
# Configure on-demand TLS for private network IPs
|
# Configure on-demand TLS for private network IPs
|
||||||
|
on_demand_tls {
|
||||||
# Pointing to the backend root which returns 200 OK
|
# Pointing to the backend root which returns 200 OK
|
||||||
}
|
# This allows Caddy to generate internal certs for any IP/domain.
|
||||||
|
ask http://backend:8000/
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Dynamic SSL Proxy (Matches ANY IP or hostname)
|
# Dynamic SSL Proxy (Matches ANY IP or hostname)
|
||||||
https://:8919 {
|
https:// {
|
||||||
tls internal {
|
tls internal {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
|
|
||||||
reverse_proxy frontend:3000
|
reverse_proxy frontend:3000
|
||||||
handle /_next/webpack-hmr {
|
|
||||||
reverse_proxy http://localhost:8917 {
|
|
||||||
header_up Upgrade {>Upgrade}
|
|
||||||
header_up Connection {>Connection}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reverse_proxy http://localhost:8917 {
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
header_up X-Forwarded-Proto https
|
|
||||||
header_up X-Forwarded-Host {host}
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
header {
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
X-XSS-Protection "1; mode=block"
|
X-XSS-Protection "1; mode=block"
|
||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Specific port listener for backend (8918 -> 444)
|
# Specific port listener for backend (8918 -> 444)
|
||||||
https://:8918 {
|
https://:444 {
|
||||||
tls internal {
|
tls internal {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
reverse_proxy backend:8000
|
reverse_proxy backend:8000
|
||||||
reverse_proxy http://localhost:8916 {
|
}
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
header_up X-Forwarded-Proto https
|
|
||||||
header_up X-Forwarded-Host {host}
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Technical Inventory Hardware Extraction Protocol
|
# Technical Inventory Hardware Extraction Protocol
|
||||||
|
|
||||||
Extract ALL relevant hardware items from the image with precise, standardized formatting.
|
Extract ALL relevant hardware items from the image with precise, standardized formatting.
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
- **Multi-item labels**: Treat each SKU/variant as a separate item (e.g., "5m cable" and "7m cable" = 2 items)
|
- **Multi-item labels**: Treat each SKU/variant as a separate item (e.g., "5m cable" and "7m cable" = 2 items)
|
||||||
|
|
||||||
## Item Field Format (CRITICAL)
|
## Item Field Format (CRITICAL)
|
||||||
Format: `<size_or_length> <type> <vendor> <connector> <part_number>`
|
[]
|
||||||
|
|
||||||
**Component Rules:**
|
**Component Rules:**
|
||||||
- `<size_or_length>`:
|
- `<size_or_length>`:
|
||||||
@@ -17,16 +17,8 @@
|
|||||||
- Rule: If ≥1000GB, use TB. If ≥1000MB, use GB. Otherwise use MB.
|
- Rule: If ≥1000GB, use TB. If ≥1000MB, use GB. Otherwise use MB.
|
||||||
- **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m"
|
- **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m"
|
||||||
- **RAM DIMM**: Capacity in GB. Examples: "128GB", "32GB", "8GB"
|
- **RAM DIMM**: Capacity in GB. Examples: "128GB", "32GB", "8GB"
|
||||||
- **SFP/TRANSCEIVER SPEED**: Speed in G/Mbps. Examples: "10G", "1G", "40G", "100G"
|
|
||||||
|
|
||||||
- `<type>`: Asset class.
|
|
||||||
- **CABLES**:
|
|
||||||
- Use "Fiber" for all optical fiber cables.
|
|
||||||
- Use "Patchcord" for all copper UTP/RJ45 cables.
|
|
||||||
- Others: SATA, SAS, Power, USB, etc.
|
|
||||||
- **COMPONENTS**: DDR3/DDR4/DDR5, NVMe, SSD, HDD, SFP/Transceiver, DIMM, etc.
|
|
||||||
- **PRECEDENCE**: If an item is both "SSD" and "NVMe", use **NVMe** as the type and category.
|
|
||||||
|
|
||||||
|
- `<type>`: Asset class. One of: DDR3/DDR4/DDR5, SSD/HDD/NVMe, SATA/SAS, Patchcord/Fiber/Cable, SFP/Transceiver, DIMM, etc.
|
||||||
- `<vendor>`: Manufacturer (HP, HPE, Dell, Samsung, Cisco, Hynix, Intel, Broadcom)
|
- `<vendor>`: Manufacturer (HP, HPE, Dell, Samsung, Cisco, Hynix, Intel, Broadcom)
|
||||||
- `<connector>`: Physical interface (RJ45, LC-LC, MPO, U.3, SATA, SAS, ST, SC). Omit if N/A.
|
- `<connector>`: Physical interface (RJ45, LC-LC, MPO, U.3, SATA, SAS, ST, SC). Omit if N/A.
|
||||||
- `<part_number>`: Part number ONLY if visible. **Omit serial numbers.**
|
- `<part_number>`: Part number ONLY if visible. **Omit serial numbers.**
|
||||||
@@ -34,10 +26,9 @@
|
|||||||
**Item Examples (WITH HUMAN-READABLE SIZES):**
|
**Item Examples (WITH HUMAN-READABLE SIZES):**
|
||||||
- `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB)
|
- `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB)
|
||||||
- `256GB SSD Dell SATA SK-8765` (already human-readable)
|
- `256GB SSD Dell SATA SK-8765` (already human-readable)
|
||||||
- `5m Fiber LC-LC` (optical cable)
|
- `5m Patchcord LC-LC`
|
||||||
- `2m Patchcord RJ45` (copper cable)
|
|
||||||
- `128GB DDR4 Hynix`
|
- `128GB DDR4 Hynix`
|
||||||
- `10G SFP+ Cisco LC 10-2415-03`
|
- `512MB Cache Samsung SATA` (stays MB if under 1GB)
|
||||||
|
|
||||||
**Size Conversion Examples:**
|
**Size Conversion Examples:**
|
||||||
- 1600GB → 1.6TB
|
- 1600GB → 1.6TB
|
||||||
@@ -56,74 +47,72 @@
|
|||||||
## Other Fields
|
## Other Fields
|
||||||
- **Type**: Repeat the asset class (DDR4, SSD, NVMe, Patchcord, etc.)
|
- **Type**: Repeat the asset class (DDR4, SSD, NVMe, Patchcord, etc.)
|
||||||
- **Description**: Technical summary, max 5 words. Examples: "High-speed fiber optic", "Enterprise Gen4 storage"
|
- **Description**: Technical summary, max 5 words. Examples: "High-speed fiber optic", "Enterprise Gen4 storage"
|
||||||
- **Category**:
|
- **Category**: Memory, Storage, Network, Cabling, Compute, Optical, Transceiver
|
||||||
- For Spare Parts (see below): Use `spare parts - <type>` where `<type>` is the lowercase component name (e.g., `spare parts - ram`, `spare parts - ssd`, `spare parts - nvme`, `spare parts - raid card`, `spare parts - cpu`, `spare parts - psu`, `spare parts - hba`, `spare parts - nic`, `spare parts - sfp`).
|
|
||||||
- For Others: Use `Network`, `Cabling`, `Compute`, `Optical`, `Consumable`.
|
|
||||||
- **Connector**: Interface type from Item field. Examples: "LC-LC", "RJ45", "U.3"
|
- **Connector**: Interface type from Item field. Examples: "LC-LC", "RJ45", "U.3"
|
||||||
- **Size**: **HUMAN-READABLE capacity, length, or speed.** Examples: "1.6TB", "256GB", "5m", "10G" (NOT "1600GB")
|
- **Size**: **HUMAN-READABLE capacity or length.** Examples: "1.6TB", "256GB", "5m" (NOT "1600GB")
|
||||||
- **Color**: Physical color if distinguishing
|
- **Color**: Physical color if distinguishing
|
||||||
- **PartNr**: Part number only. **CRITICAL: NEVER include Serial Numbers (S/N, SN, Serial).**
|
- **PartNr**: Part number only (no serial numbers)
|
||||||
- **Specs**: Detailed technical specifications (e.g., "1.2V, CL22", "850W 80+ Gold", "Gen4 x4", "Single-mode").
|
- **OCR**: Robust matching key for OCR tolerance
|
||||||
- **OCR**: Robust matching key for OCR tolerance. Format: `TYPE SIZE VENDOR CONNECTOR PARTNUMBER` (UPPERCASE, no special chars).
|
|
||||||
|
|
||||||
## OCR Field Rules (CRITICAL)
|
## OCR Field Rules (CRITICAL)
|
||||||
Generate a SHORT, clean matching key for database lookup:
|
Generate a SHORT, clean matching key:
|
||||||
- Format: **UPPERCASE space-separated, NO special chars, NO duplicates**
|
- Format: **UPPERCASE space-separated, NO special chars, NO duplicates**
|
||||||
- Include ONLY: Type + Size + Primary Vendor + Connector + Part Number
|
- Include ONLY: Type + Size + Primary Vendor + Connector + Part Number
|
||||||
- **STRICT EXCLUSIONS**:
|
- **EXCLUDE**: Serial numbers, secondary vendors, duplicate tokens, EMC/SK labels
|
||||||
- NO serial numbers (S/N, SN, Serial)
|
- **USE HUMAN-READABLE SIZE**: Use TB/GB from Item field, not original notation
|
||||||
- NO secondary vendor names
|
|
||||||
- NO extraneous labels
|
|
||||||
- **CONSTRAINTS**:
|
|
||||||
- Each token appears ONE time only (no duplicates)
|
|
||||||
- Remove hyphens/special chars for fuzzy matching (e.g., `SK-8765` -> `SK8765`)
|
|
||||||
- **USE HUMAN-READABLE SIZE**: Use TB/GB/M/G from Item field.
|
|
||||||
|
|
||||||
**OCR Examples:**
|
**OCR Format:** `TYPE SIZE VENDOR CONNECTOR PARTNUMBER`
|
||||||
|
|
||||||
|
**OCR Examples (WITH HUMAN-READABLE SIZES):**
|
||||||
- Item: `1.6TB NVMe HPE U.3 P66093-002` → OCR: `NVME 1.6TB HPE U3 P66093002`
|
- Item: `1.6TB NVMe HPE U.3 P66093-002` → OCR: `NVME 1.6TB HPE U3 P66093002`
|
||||||
- Item: `5m Fiber LC-LC` → OCR: `FIBER 5M LC LC`
|
- Item: `5m Patchcord LC-LC` → OCR: `PATCHCORD 5M LC LC`
|
||||||
- Item: `2m Patchcord RJ45` → OCR: `PATCHCORD 2M RJ45`
|
|
||||||
- Item: `256GB SSD Samsung SAS SK-8765` → OCR: `SSD 256GB SAMSUNG SAS SK8765`
|
- Item: `256GB SSD Samsung SAS SK-8765` → OCR: `SSD 256GB SAMSUNG SAS SK8765`
|
||||||
- Item: `128GB DDR4 Hynix` → OCR: `DDR4 128GB HYNIX`
|
- Item: `128GB DDR4 Hynix` → OCR: `DDR4 128GB HYNIX`
|
||||||
- Item: `10G SFP+ Cisco LC 10-2415-03` → OCR: `SFP+ 10G CISCO LC 10241503`
|
|
||||||
|
**OCR Constraints:**
|
||||||
|
- NO duplicate part numbers
|
||||||
|
- NO secondary vendor names
|
||||||
|
- NO extraneous labels
|
||||||
|
- Each token appears ONE time only
|
||||||
|
- Remove hyphens/special chars for fuzzy matching
|
||||||
|
- Use HUMAN-READABLE sizes (1.6TB not 1600GB)
|
||||||
|
|
||||||
## Spare-Parts vs Consumables Classification
|
## Spare-Parts vs Consumables Classification
|
||||||
|
|
||||||
CLASSIFICATION GUIDE - SPARE PARTS vs CONSUMABLES:
|
CLASSIFICATION GUIDE - SPARE PARTS vs CONSUMABLES:
|
||||||
|
|
||||||
Spare Parts (replaceable components that plug into or interface with devices):
|
Spare Parts (replaceable components that plug into or interface with devices):
|
||||||
- **RAM**: DDR3, DDR4, DDR5, SODIMM, DIMM → Category: `spare parts - ram`
|
- RAM, DDR memory modules (DDR3, DDR4, DDR5, SODIMM, DIMM)
|
||||||
- **Storage**: NVMe, SSD, M.2, SATA HDD, SAS HDD → Category: `spare parts - nvme`, `spare parts - ssd`, or `spare parts - hdd`.
|
- SSDs, NVMe drives, M.2 modules, SATA drives, hard drives
|
||||||
- *Rule: Use `nvme` if the item is an NVMe SSD.*
|
- CPUs, GPUs, processors, discrete graphics cards
|
||||||
- **CPU**: Processors, Intel Xeon, AMD EPYC → Category: `spare parts - cpu`
|
- Power supply units (PSU), power modules (NOT generic power cords)
|
||||||
- **Power**: PSU, Power Supply Modules → Category: `spare parts - psu`
|
- Expansion cards (PCIe, PCI, RAID controllers, network cards/NIC)
|
||||||
- **Controllers**: RAID Controllers, HBA, NIC, Network Cards → Category: `spare parts - raid card` or `spare parts - hba` or `spare parts - nic`
|
- Cooling solutions (heatsinks, CPU coolers, thermal solutions)
|
||||||
- **Cooling**: Heatsinks, Fans (specific to models) → Category: `spare parts - cooling`
|
- Motherboards, chipsets, BIOS modules
|
||||||
- **Mainboard**: Motherboards, Riser Cards → Category: `spare parts - motherboard` or `spare parts - riser`
|
|
||||||
- **Optical**: SFPs, Transceivers → Category: `spare parts - sfp`
|
|
||||||
|
|
||||||
NOT Spare Parts (consumables, generic items):
|
NOT Spare Parts (consumables, generic items):
|
||||||
- Cables: Power cables, SATA cables, USB, Ethernet, Fiber Patchcords → Category: `Cabling` or `Consumable`
|
- Cables (power, SATA, USB, Ethernet, proprietary cords)
|
||||||
- Fasteners: Screws, brackets, rails → Category: `Consumable`
|
- Fasteners (screws, washers, bolts, standoffs)
|
||||||
- Materials: Thermal paste, pads, tapes → Category: `Consumable`
|
- Thermal paste, thermal pads, adhesive tapes
|
||||||
|
- Connectors, plugs, sockets, generic adapters
|
||||||
|
- Generic cords and utility items
|
||||||
|
|
||||||
Decision Tree:
|
Decision Tree:
|
||||||
1. Does the item have a manufacturer part number (P/N)?
|
1. Does the item have a replaceable function in a larger system?
|
||||||
2. Is it a modular component of a larger system (Server, PC, Switch)?
|
2. Does it have a manufacturer part number and technical specifications?
|
||||||
3. Does it have technical specs (Speed, Capacity, Voltage)?
|
3. Is it described with model/revision information?
|
||||||
If YES to 2+ questions: Mark as `spare parts - <type>`
|
If YES to 2+ questions: Mark as SPARE PART
|
||||||
If item is a cable: Mark as `Cabling`
|
If item matches consumable examples exactly: Mark as CONSUMABLE
|
||||||
If item is a generic hardware: Mark as `Consumable`
|
Otherwise: Mark as "uncertain" in the Category field for human review.
|
||||||
Otherwise: Mark as `uncertain` for review.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
✓ "Kingston Fury 16GB DDR4-3200" → Category: `spare parts - ram`
|
✓ "Kingston Fury 16GB DDR4-3200" → Spare Part (RAM module)
|
||||||
✓ "Samsung 970 EVO 1TB NVMe" → Category: `spare parts - nvme`
|
✓ "Samsung 970 EVO 1TB NVMe" → Spare Part (SSD)
|
||||||
✓ "Intel Core i7-12700K" → Category: `spare parts - cpu`
|
✓ "Intel Core i7-12700K" → Spare Part (CPU)
|
||||||
✓ "LSI MegaRAID 9361-8i" → Category: `spare parts - raid card`
|
✓ "Corsair RM850x 850W Power Supply" → Spare Part (PSU)
|
||||||
✗ "6ft SATA Cable" → Category: `Cabling`
|
✗ "6ft SATA Cable" → Consumable (cable)
|
||||||
✗ "M3 Mounting Screws" → Category: `Consumable`
|
✗ "CPU Mounting Hardware Kit" → Consumable (fasteners)
|
||||||
|
✗ "Thermal Paste Tube" → Consumable (adhesive material)
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
```json
|
```json
|
||||||
@@ -138,8 +127,7 @@
|
|||||||
"Size": "human_readable_size",
|
"Size": "human_readable_size",
|
||||||
"Color": "color",
|
"Color": "color",
|
||||||
"PartNr": "part_number",
|
"PartNr": "part_number",
|
||||||
"Specs": "detailed technical specifications",
|
"OCR": "TYPE SIZE VENDOR CONNECTOR PARTNUMBER"
|
||||||
"OCR": "SIZE TYPE VENDOR CONNECTOR PARTNUMBER"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,9 @@ A unified inventory management system that eliminates manual data entry through
|
|||||||
### Phase 7: Config Consolidation (COMPLETED ✓)
|
### Phase 7: Config Consolidation (COMPLETED ✓)
|
||||||
- **Centralization**: All config in `config/` folder (YAML format).
|
- **Centralization**: All config in `config/` folder (YAML format).
|
||||||
- **Automation**: Bash scripts converted to Python (`scripts/`).
|
- **Automation**: Bash scripts converted to Python (`scripts/`).
|
||||||
- **SSOT Architecture**: `network.yaml` established as Master IP/Port authority.
|
- **Structure**: Clear separation of `backend.yaml`, `frontend.yaml`, `network.yaml`, `docker.yaml`, `secrets.yaml`.
|
||||||
- **Dynamic Injection**: Automatic API URL and CORS calculation from master config.
|
|
||||||
- **D-06 Load Order**: Env Vars > YAML > Defaults.
|
- **D-06 Load Order**: Env Vars > YAML > Defaults.
|
||||||
- **Deprecation**: `inventory.env` and legacy `.sh` scripts completely removed.
|
- **Deprecation**: `inventory.env` completely removed.
|
||||||
|
|
||||||
### Phase 8: Hardening & Release (PLANNED)
|
### Phase 8: Hardening & Release (PLANNED)
|
||||||
- Stability monitoring, final UX refinements, production-ready runbook.
|
- Stability monitoring, final UX refinements, production-ready runbook.
|
||||||
@@ -54,7 +53,6 @@ A unified inventory management system that eliminates manual data entry through
|
|||||||
## 4. Design & Operational Constraints
|
## 4. Design & Operational Constraints
|
||||||
- **Tech Stack**: FastAPI (Python), SQLite, Next.js (TypeScript), Tailwind CSS.
|
- **Tech Stack**: FastAPI (Python), SQLite, Next.js (TypeScript), Tailwind CSS.
|
||||||
- **UI Fidelity**: Premium density, Lucide icons, **NO UPPERCASE**, **NO BOLD FONTS** (normal weight only).
|
- **UI Fidelity**: Premium density, Lucide icons, **NO UPPERCASE**, **NO BOLD FONTS** (normal weight only).
|
||||||
- **StatCard Rule**: Numeric values MUST match label text size for balanced density.
|
|
||||||
- **Database**: Single-instance SQLite (WAL mode). Multi-instance is v3+.
|
- **Database**: Single-instance SQLite (WAL mode). Multi-instance is v3+.
|
||||||
- **Security**: Mandatory auth, no bypasses, immutable audit logs.
|
- **Security**: Mandatory auth, no bypasses, immutable audit logs.
|
||||||
- **AI**: Multi-provider resilience (Gemini primary, Claude fallback).
|
- **AI**: Multi-provider resilience (Gemini primary, Claude fallback).
|
||||||
|
|||||||
@@ -1,33 +1,54 @@
|
|||||||
# CURRENT AI WORKING SESSION — HANDOVER
|
# CURRENT AI WORKING SESSION — HANDOVER
|
||||||
|
|
||||||
**Active AI:** Gemini CLI
|
**Active AI:** Claude Haiku 4.5 (Claude Code)
|
||||||
**Last Updated:** 2026-04-23
|
**Last Updated:** 2026-04-23
|
||||||
**Current Version:** v1.14.20
|
**Current Version:** v1.14.7
|
||||||
**Status**: ✅ STATCARD TYPOGRAPHY REFINED | 🟢 READY FOR PHASE 8
|
**Status**: ✅ PHASE 7 COMPLETE
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SESSION 44 EXECUTION SUMMARY — StatCard UI Tweak
|
## SESSION 41 EXECUTION — Phase 7 Execution & Consolidation
|
||||||
|
|
||||||
### 1. StatCard Typography Adjustment
|
### Work Completed This Session
|
||||||
- **Constraint**: Reduced numeric value font size in dashboard cards (`StatCard.tsx`) to match their label text size (`text-base md:text-lg`).
|
|
||||||
- **Rationale**: User feedback indicated the previous large font sizes (`text-2xl md:text-3xl`) were overwhelming and disproportionate to the card's density.
|
|
||||||
|
|
||||||
### 2. Rule Standardization
|
**1. Phase 7 Execution (Config Consolidation):**
|
||||||
- **AI_RULES.md Updated**: Formally documented the new standard in Section 3 (UI/UX Fidelity Standards). Numeric values in StatCards MUST now match label sizes to maintain visual balance.
|
- ✅ Wave 1: Created `config/` structure, 5 schema examples, 4 actual config files, and 155-line `config/README.md`.
|
||||||
|
- ✅ Wave 2 (Backend): Refactored `backend/config_loader.py` for YAML and D-06 load order. Updated `config_manager.py`, `main.py`, and `entrypoint.sh`.
|
||||||
|
- ✅ Wave 2 (Scripts): Converted `deploy.sh`, `run_standalone.sh`, `install_service.sh`, and `export_prod.sh` to secure Python scripts with YAML parsing.
|
||||||
|
- ✅ Wave 3 (Docker & Docs): Updated `docker-compose.yml` (volume mounts), `Dockerfile`, `.gitignore`, `DEPLOYMENT.md`, and `README.md`.
|
||||||
|
- ✅ Post-Wave: Refactored legacy `load_dotenv()` in `ai_vision.py`, `check_models.py`, `gemini.py`, and `claude.py`.
|
||||||
|
- ✅ Cleanup: Deleted all deprecated bash scripts and legacy `.env` files. Updated `scripts/save_version.py` for new infrastructure.
|
||||||
|
|
||||||
|
**2. Versioning & State Sync:**
|
||||||
|
- ✅ Incremented version to `v1.14.7` across all SSOT files (`VERSION.json`, `frontend/VERSION.json`, `PROJECT_ARCHITECTURE.md`, `dev_docs/PLAN.md`).
|
||||||
|
- ✅ Updated roadmap in `dev_docs/PLAN.md` marking Phase 7 as COMPLETED.
|
||||||
|
- ✅ Verified all files pass syntax checks (Python/Bash).
|
||||||
|
|
||||||
|
### Phase 7 Artifact Status
|
||||||
|
- **Plans**: `.planning/phases/07-config-consolidation/07-01-PLAN.md` ✓ COMPLETED
|
||||||
|
- **Plans**: `.planning/phases/07-config-consolidation/07-02-PLAN.md` ✓ COMPLETED
|
||||||
|
- **Plans**: `.planning/phases/07-config-consolidation/07-03-PLAN.md` ✓ COMPLETED
|
||||||
|
- **Plans**: `.planning/phases/07-config-consolidation/07-04-PLAN.md` ✓ COMPLETED
|
||||||
|
- **Summaries**: `.planning/phases/07-config-consolidation/07-01,02,03,04-SUMMARY.md` ✓ Created
|
||||||
|
- **Context**: `.planning/phases/07-config-consolidation/07-CONTEXT.md` ✓ Archived
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## NEXT STEPS (Phase 8: Hardening & Release)
|
## NEXT STEPS
|
||||||
|
|
||||||
1. **Extraction Validation**:
|
1. **Start Phase 8 (Hardening & Release)**:
|
||||||
- Run sample images of RAID cards and NVMe drives to confirm `spare parts - <type>` categorization.
|
- Perform end-to-end testing of the new Python deployment scripts.
|
||||||
2. **UX Refinement**:
|
- Verify Docker deployment with `python3 scripts/deploy.py production`.
|
||||||
- Ensure the Admin UI displays these new categories correctly.
|
- Perform final UX refinements and accessibility audits.
|
||||||
3. **v1.15.0 Milestone**:
|
- Prepare production-ready runbook for v1.15.0 stable release.
|
||||||
- Finalize the release candidate.
|
|
||||||
|
2. **Stability Monitoring**:
|
||||||
|
- Check logs for any configuration parsing warnings.
|
||||||
|
- Ensure all environment variable overrides work as expected (D-06).
|
||||||
|
|
||||||
|
3. **Cleanup**:
|
||||||
|
- Archive Phase 7 planning artifacts to `dev_docs/ARCHIVE_LOGS.md` or similar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
✓ All tasks for this session are implemented, verified, and committed.
|
✓ Phase 7 fully implemented, verified, and cleaned up. Ready for the next phase.
|
||||||
✓ Services are currently RUNNING in the background.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "1.14.23",
|
"version": "1.14.11",
|
||||||
"last_build": "2026-04-23-1823",
|
"last_build": "2026-04-23-1404",
|
||||||
"codename": "ConfigCore",
|
"codename": "ConfigCore",
|
||||||
"commit": "7c4441a9"
|
"commit": "e5a24df1"
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={admin.handleLogout}
|
onClick={admin.handleLogout}
|
||||||
className="ml-auto p-3 bg-surface border border-slate-800 text-muted hover:text-rose-500 rounded-2xl transition-all active:scale-95 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-rose-500 focus-visible:outline-none"
|
className="ml-auto p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-surface border border-slate-800 text-muted hover:text-rose-500 rounded-2xl transition-all active:scale-95 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||||
title="Secure Logout"
|
title="Secure Logout"
|
||||||
aria-label="Logout from admin panel"
|
aria-label="Logout from admin panel"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&family=Fira+Sans&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -18,12 +18,11 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-weight: 400;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-family: var(--font-code);
|
font-family: var(--font-code);
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar Styling */
|
/* Custom Scrollbar Styling */
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ export default function InventoryPage() {
|
|||||||
|
|
||||||
{/* Stock Adjustment / Edit Item Overlay */}
|
{/* Stock Adjustment / Edit Item Overlay */}
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-background/80 animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/80 animate-in fade-in duration-200">
|
||||||
<div className="w-full max-w-lg bg-surface 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="w-full max-w-lg bg-surface 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-3 md:mb-4">
|
<div className="flex justify-between items-center mb-3 md:mb-4">
|
||||||
<h3 className="text-xl font-normal tracking-tight flex items-center gap-2">
|
<h3 className="text-xl font-normal tracking-tight flex items-center gap-2">
|
||||||
@@ -364,7 +364,7 @@ export default function InventoryPage() {
|
|||||||
setEditedItem(selectedItem);
|
setEditedItem(selectedItem);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full text-secondary"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full text-secondary"
|
||||||
>
|
>
|
||||||
<Edit2 size={20} />
|
<Edit2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -372,7 +372,7 @@ export default function InventoryPage() {
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteItem}
|
onClick={handleDeleteItem}
|
||||||
className="p-2 hover:bg-red-500/20 rounded-full text-red-500"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-red-500/20 rounded-full text-red-500"
|
||||||
>
|
>
|
||||||
<Trash2 size={20} />
|
<Trash2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -383,7 +383,7 @@ export default function InventoryPage() {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setAdjustQty(1);
|
setAdjustQty(1);
|
||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -448,7 +448,7 @@ export default function InventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Size / Length / Speed</label>
|
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Size / Length</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedItem.size || ''}
|
value={editedItem.size || ''}
|
||||||
@@ -486,7 +486,7 @@ export default function InventoryPage() {
|
|||||||
toast.success("Ready to scan Box label...");
|
toast.success("Ready to scan Box label...");
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 p-2 rounded-lg transition-all",
|
"absolute right-2 p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-lg transition-all",
|
||||||
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-muted hover:bg-slate-800"
|
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-muted hover:bg-slate-800"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -524,7 +524,7 @@ export default function InventoryPage() {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setAdjustType(t.id as any)}
|
onClick={() => setAdjustType(t.id as any)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex flex-col items-center py-3 rounded-xl transition-all",
|
"flex-1 flex flex-col items-center py-3 lg:py-4 xl:py-5 rounded-xl transition-all",
|
||||||
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-muted"
|
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -552,11 +552,11 @@ export default function InventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adjustType === 'TRASH' && (
|
{adjustType === 'TRASH' && (
|
||||||
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 rounded-2xl">
|
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl">
|
||||||
<select
|
<select
|
||||||
value={trashReason}
|
value={trashReason}
|
||||||
onChange={(e) => setTrashReason(e.target.value)}
|
onChange={(e) => setTrashReason(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-foreground"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-foreground"
|
||||||
>
|
>
|
||||||
<option>Damaged</option>
|
<option>Damaged</option>
|
||||||
<option>Expired</option>
|
<option>Expired</option>
|
||||||
@@ -608,7 +608,7 @@ export default function InventoryPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={catEditedName}
|
value={catEditedName}
|
||||||
onChange={e => setCatEditedName(e.target.value)}
|
onChange={e => setCatEditedName(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -616,7 +616,7 @@ export default function InventoryPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={catEditedDesc}
|
value={catEditedDesc}
|
||||||
onChange={e => setCatEditedDesc(e.target.value)}
|
onChange={e => setCatEditedDesc(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary h-24 resize-none"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary h-24 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -641,7 +641,7 @@ export default function InventoryPage() {
|
|||||||
|
|
||||||
{/* Box Manager Modal */}
|
{/* Box Manager Modal */}
|
||||||
{showBoxManager && (
|
{showBoxManager && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/90 animate-in fade-in duration-300">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/90 animate-in fade-in duration-300">
|
||||||
<div className="w-full max-w-2xl bg-surface border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
<div className="w-full max-w-2xl bg-surface border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
<div className="p-4 md:p-6 pb-3 md:pb-4 flex flex-col gap-3 md:gap-4 shrink-0 bg-surface/70">
|
<div className="p-4 md:p-6 pb-3 md:pb-4 flex flex-col gap-3 md:gap-4 shrink-0 bg-surface/70">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@@ -652,7 +652,7 @@ export default function InventoryPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted font-normal mt-1">Manage physical box labels & printing</p>
|
<p className="text-sm text-muted font-normal mt-1">Manage physical box labels & printing</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 hover:bg-slate-800 rounded-full transition-colors text-secondary">
|
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 hover:bg-slate-800 rounded-full transition-colors text-secondary">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -663,7 +663,7 @@ export default function InventoryPage() {
|
|||||||
placeholder="Search boxes..."
|
placeholder="Search boxes..."
|
||||||
value={boxSearchQuery}
|
value={boxSearchQuery}
|
||||||
onChange={(e) => setBoxSearchQuery(e.target.value)}
|
onChange={(e) => setBoxSearchQuery(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-2xl py-3.5 pl-11 pr-4 text-sm focus:border-primary outline-none transition-all placeholder:text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5.5 pl-11 pr-4 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl focus:border-primary outline-none transition-all placeholder:text-secondary"
|
||||||
/>
|
/>
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary" size={18} />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary" size={18} />
|
||||||
{boxSearchQuery && (
|
{boxSearchQuery && (
|
||||||
@@ -781,7 +781,7 @@ export default function InventoryPage() {
|
|||||||
onClick={() => setSelectedBoxLabel(null)}
|
onClick={() => setSelectedBoxLabel(null)}
|
||||||
className="w-full py-4 text-muted font-normal text-xs"
|
className="w-full py-4 text-muted font-normal text-xs"
|
||||||
>
|
>
|
||||||
Close Label Preview
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export default function CreateItemPage() {
|
|||||||
className="flex-1 px-4 py-2 border border-slate-700 text-slate-300 rounded-lg hover:border-slate-600 transition-colors font-normal"
|
className="flex-1 px-4 py-2 border border-slate-700 text-slate-300 rounded-lg hover:border-slate-600 transition-colors font-normal"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
Discard Draft
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDetailsSubmit}
|
onClick={handleDetailsSubmit}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function LoginPage() {
|
|||||||
key={user.id}
|
key={user.id}
|
||||||
data-testid="user-list-item"
|
data-testid="user-list-item"
|
||||||
onClick={() => handleSelectUser(user)}
|
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/50 hover:bg-slate-800 border border-slate-800 hover:border-primary/40 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl text-left transition-all group flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-surface border border-slate-700 flex items-center justify-center text-muted group-hover:text-primary transition-colors">
|
<div className="w-8 h-8 rounded-full bg-surface border border-slate-700 flex items-center justify-center text-muted group-hover:text-primary transition-colors">
|
||||||
@@ -196,7 +196,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
<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="bg-slate-800/30 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl border border-slate-800 flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedUserForLogin(null)}
|
onClick={() => setSelectedUserForLogin(null)}
|
||||||
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function LogsPage() {
|
|||||||
<main className="p-3 md:p-8 max-w-7xl mx-auto space-y-3 md:space-y-6 mb-20">
|
<main className="p-3 md:p-8 max-w-7xl mx-auto space-y-3 md:space-y-6 mb-20">
|
||||||
<header className="flex flex-col sm:flex-row sm:items-end justify-between gap-3 md:gap-4">
|
<header className="flex flex-col sm:flex-row sm:items-end justify-between gap-3 md:gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
<div className="p-3 md:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 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" />
|
<History size={28} className="md:w-8 md:h-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -142,7 +142,7 @@ export default function LogsPage() {
|
|||||||
placeholder="Filter by user, action or asset..."
|
placeholder="Filter by user, action or asset..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full bg-surface/50 border border-slate-800 rounded-2xl py-3.5 pr-4 pl-12 text-sm focus:border-primary outline-none transition-all placeholder:text-secondary shadow-2xl"
|
className="w-full bg-surface/50 border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5.5 pr-4 pl-12 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl focus:border-primary outline-none transition-all placeholder:text-secondary shadow-2xl"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary transition-colors group-focus-within:text-primary">
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary transition-colors group-focus-within:text-primary">
|
||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ export default function Home() {
|
|||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
data-testid="manual-sync-button"
|
data-testid="manual-sync-button"
|
||||||
className="p-2.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-white transition-all active:scale-95 disabled:opacity-50"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-white transition-all active:scale-95 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<RefreshCw size={18} className={syncing ? "animate-spin text-primary" : ""} />
|
<RefreshCw size={18} className={syncing ? "animate-spin text-primary" : ""} />
|
||||||
</button>
|
</button>
|
||||||
@@ -507,7 +507,7 @@ export default function Home() {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-secondary font-normal mt-1">Select the item you want to {mode.replace('_', ' ').toLowerCase()}</p>
|
<p className="text-xs text-secondary font-normal mt-1">Select the item you want to {mode.replace('_', ' ').toLowerCase()}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setBoxMatches([])} className="p-2 hover:bg-slate-800 rounded-full">
|
<button onClick={() => setBoxMatches([])} className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full">
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,7 +539,7 @@ export default function Home() {
|
|||||||
<footer className="mt-12 md:mt-20 mb-4 md:mb-8 flex flex-col items-center gap-2 opacity-70">
|
<footer className="mt-12 md:mt-20 mb-4 md:mb-8 flex flex-col items-center gap-2 opacity-70">
|
||||||
<p className="text-sm font-normal text-secondary">Powered by TFM Group Software</p>
|
<p className="text-sm font-normal text-secondary">Powered by TFM Group Software</p>
|
||||||
<div className="h-px w-16 bg-slate-700/60" />
|
<div className="h-px w-16 bg-slate-700/60" />
|
||||||
<p className="text-xs font-mono text-secondary">v{versionData.version} • {versionData.last_build} • BUILD: dev-{(versionData as any).commit || 'N/A'}</p>
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-mono text-secondary">v{versionData.version} • {versionData.last_build} • BUILD: dev-{(versionData as any).commit || 'N/A'}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="ai-extraction-overlay" className="fixed inset-0 z-50 bg-background flex flex-col p-6 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
<div data-testid="ai-extraction-overlay" className="fixed inset-0 z-50 bg-background flex flex-col p-6 lg:p-8 xl:p-10 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
||||||
<div className="flex justify-between items-center mb-6 shrink-0">
|
<div className="flex justify-between items-center mb-6 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-primary/20 rounded-xl">
|
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/20 rounded-xl">
|
||||||
<Sparkles className="text-primary w-5 h-5" />
|
<Sparkles className="text-primary w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -72,7 +72,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
<button
|
<button
|
||||||
onClick={() => { stopLiveCamera(); onCancel(); }}
|
onClick={() => { stopLiveCamera(); onCancel(); }}
|
||||||
aria-label="Close AI Discovery"
|
aria-label="Close AI Discovery"
|
||||||
className="p-2 hover:bg-surface rounded-full cursor-pointer transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-surface rounded-full cursor-pointer transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -87,7 +87,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
||||||
>
|
>
|
||||||
<Package size={18} />
|
<Package size={18} />
|
||||||
<span className="text-xs">Discovery Mode</span>
|
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl">Discovery Mode</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('box')}
|
onClick={() => setMode('box')}
|
||||||
@@ -95,11 +95,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
||||||
>
|
>
|
||||||
<Layers size={18} />
|
<Layers size={18} />
|
||||||
<span className="text-xs">Box Lookup</span>
|
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl">Box Lookup</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-surface/30 overflow-hidden px-4">
|
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-surface/30 overflow-hidden px-4 lg:px-5 xl:px-6">
|
||||||
<div className="w-20 h-20 bg-surface rounded-3xl flex items-center justify-center mb-6 shadow-inner text-primary">
|
<div className="w-20 h-20 bg-surface rounded-3xl flex items-center justify-center mb-6 shadow-inner text-primary">
|
||||||
<Camera size={32} />
|
<Camera size={32} />
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-normal shadow-2xl shadow-primary/20 cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-normal shadow-2xl shadow-primary/20 cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||||
>
|
>
|
||||||
<Camera size={24} />
|
<Camera size={24} />
|
||||||
<span className="text-sm">Scan Camera</span>
|
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">Scan Camera</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
@@ -129,7 +129,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
className="flex flex-col items-center justify-center gap-2 bg-surface text-secondary border border-slate-800 rounded-3xl font-normal cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="flex flex-col items-center justify-center gap-2 bg-surface text-secondary border border-slate-800 rounded-3xl font-normal cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<ImageIcon size={24} />
|
<ImageIcon size={24} />
|
||||||
<span className="text-sm">Upload Photo</span>
|
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">Upload Photo</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
<textarea
|
<textarea
|
||||||
value={extractedItems[editingIndex].Description || extractedItems[editingIndex].description || ''}
|
value={extractedItems[editingIndex].Description || extractedItems[editingIndex].description || ''}
|
||||||
onChange={(e) => updateEditingItem({ Description: e.target.value })}
|
onChange={(e) => updateEditingItem({ Description: e.target.value })}
|
||||||
className="bg-transparent w-full text-sm leading-tight outline-none resize-none h-8 text-secondary py-0"
|
className="bg-transparent w-full text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl leading-tight outline-none resize-none h-8 text-secondary py-0"
|
||||||
placeholder="Product description..."
|
placeholder="Product description..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,7 +422,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
data-testid="extraction-item-row"
|
data-testid="extraction-item-row"
|
||||||
className="bg-surface/70 border border-slate-800 rounded-3xl p-5 flex items-center justify-between group hover:border-primary/40 transition-all active:scale-[0.98] focus-within:border-primary/60 focus-within:ring-2 focus-within:ring-primary/20"
|
className="bg-surface/70 border border-slate-800 rounded-3xl p-5 lg:p-6 xl:p-8 flex items-center justify-between group hover:border-primary/40 transition-all active:scale-[0.98] focus-within:border-primary/60 focus-within:ring-2 focus-within:ring-primary/20"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex-1 min-w-0 cursor-pointer"
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
@@ -461,14 +461,14 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
setExtractedItems(newItems);
|
setExtractedItems(newItems);
|
||||||
}}
|
}}
|
||||||
aria-label={`Delete item: ${item.Item || item.name}`}
|
aria-label={`Delete item: ${item.Item || item.name}`}
|
||||||
className="p-3 text-secondary hover:text-red-500 cursor-pointer transition-colors focus:ring-2 focus:ring-red-500 focus:outline-none rounded-lg"
|
className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 text-secondary hover:text-red-500 cursor-pointer transition-colors focus:ring-2 focus:ring-red-500 focus:outline-none rounded-lg"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingIndex(idx)}
|
onClick={() => setEditingIndex(idx)}
|
||||||
aria-label={`Edit item: ${item.Item || item.name}`}
|
aria-label={`Edit item: ${item.Item || item.name}`}
|
||||||
className="p-3 bg-primary/10 text-primary rounded-2xl cursor-pointer hover:bg-primary/20 transition-all focus:ring-2 focus:ring-primary focus:outline-none"
|
className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-primary/10 text-primary rounded-2xl cursor-pointer hover:bg-primary/20 transition-all focus:ring-2 focus:ring-primary focus:outline-none"
|
||||||
>
|
>
|
||||||
<ChevronDown size={20} className="-rotate-90" />
|
<ChevronDown size={20} className="-rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function AdminOverlay({
|
|||||||
<div className="absolute inset-y-0 right-0 w-full max-w-md sm:max-w-lg md:max-w-md bg-surface border-l border-slate-800 shadow-2xl flex flex-col animate-in slide-in-from-right duration-500">
|
<div className="absolute inset-y-0 right-0 w-full max-w-md sm:max-w-lg md:max-w-md bg-surface border-l border-slate-800 shadow-2xl flex flex-col animate-in slide-in-from-right duration-500">
|
||||||
<div className="p-3 md:p-6 border-b border-slate-800 flex items-center justify-between">
|
<div className="p-3 md:p-6 border-b border-slate-800 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-slate-800 rounded-xl text-primary">
|
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-slate-800 rounded-xl text-primary">
|
||||||
<Shield size={20} />
|
<Shield size={20} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-normal text-white">System Admin</h2>
|
<h2 className="text-xl font-normal text-white">System Admin</h2>
|
||||||
@@ -125,7 +125,7 @@ export default function AdminOverlay({
|
|||||||
<button
|
<button
|
||||||
data-testid="close-admin-overlay"
|
data-testid="close-admin-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full transition-colors text-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full transition-colors text-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
@@ -171,7 +171,7 @@ export default function AdminOverlay({
|
|||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||||
aria-label={`Delete user ${u.username}`}
|
aria-label={`Delete user ${u.username}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -220,7 +220,7 @@ export default function AdminOverlay({
|
|||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||||
aria-label={`Delete category ${cat.name}`}
|
aria-label={`Delete category ${cat.name}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -235,7 +235,7 @@ export default function AdminOverlay({
|
|||||||
<AlertTriangle size={16} />
|
<AlertTriangle size={16} />
|
||||||
<p className="text-sm font-normal">Sign Out</p>
|
<p className="text-sm font-normal">Sign Out</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted">End your session and return to the login screen.</p>
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted">End your session and return to the login screen.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
import('@/lib/auth').then(m => m.clearAuth());
|
import('@/lib/auth').then(m => m.clearAuth());
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function BottomNav({
|
|||||||
const isAdmin = pathname === '/admin';
|
const isAdmin = pathname === '/admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="fixed bottom-0 left-0 right-0 py-3 px-2 pb-safe bg-background border-t border-slate-900 z-40 transition-all duration-300">
|
<footer className="fixed bottom-0 left-0 right-0 py-3 lg:py-4 xl:py-5 px-2 pb-safe bg-background border-t border-slate-900 z-40 transition-all duration-300">
|
||||||
<div className="max-w-xl mx-auto flex justify-around items-center text-secondary">
|
<div className="max-w-xl mx-auto flex justify-around items-center text-secondary">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function CameraView({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md mx-auto flex flex-col gap-3 md:gap-4">
|
<div className="w-full max-w-md mx-auto flex flex-col gap-3 md:gap-4">
|
||||||
{/* Video Viewport Area */}
|
{/* Video Viewport Area */}
|
||||||
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-background border-2 border-slate-800/50 p-4 sm:p-6">
|
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-background border-2 border-slate-800/50 p-4 sm:p-6 lg:p-8 xl:p-10">
|
||||||
<div className="absolute inset-4 sm:inset-6 z-10 pointer-events-none flex items-center justify-center">
|
<div className="absolute inset-4 sm:inset-6 z-10 pointer-events-none flex items-center justify-center">
|
||||||
<div className="w-full h-full border border-primary/30 rounded-[2rem] relative">
|
<div className="w-full h-full 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 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)]" />
|
||||||
@@ -89,7 +89,7 @@ export default function CameraView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-surface/90 border-t border-slate-800 flex flex-col gap-4">
|
<div className="p-6 lg:p-8 xl:p-10 bg-surface/90 border-t border-slate-800 flex flex-col gap-4">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<p className="text-sm font-normal text-white italic text-center">Text Found</p>
|
<p className="text-sm font-normal text-white italic text-center">Text Found</p>
|
||||||
<p className="text-xs text-muted font-normal text-center">Tap any text to use it</p>
|
<p className="text-xs text-muted font-normal text-center">Tap any text to use it</p>
|
||||||
@@ -108,7 +108,7 @@ export default function CameraView({
|
|||||||
{!isStarted && !error && (
|
{!isStarted && !error && (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-surface text-secondary gap-4">
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-surface text-secondary gap-4">
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
||||||
<p className="text-sm font-normal">Initializing camera...</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-medium">Initializing camera...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ export default function CameraView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* External Controls Area */}
|
{/* External Controls Area */}
|
||||||
<div className="flex flex-col gap-4 bg-surface/50 p-5 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
|
<div className="flex flex-col gap-4 bg-surface/50 p-5 lg:p-6 xl:p-8 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
|
||||||
<div className="flex items-center gap-3 w-full">
|
<div className="flex items-center gap-3 w-full">
|
||||||
{hasZoom && (
|
{hasZoom && (
|
||||||
<button
|
<button
|
||||||
@@ -153,7 +153,7 @@ export default function CameraView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 h-14 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden group">
|
<div className="flex-1 h-14 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 lg:px-5 xl:px-6 relative overflow-hidden group">
|
||||||
{ocrProcessing ? (
|
{ocrProcessing ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="animate-spin text-primary" size={18} />
|
<RefreshCw className="animate-spin text-primary" size={18} />
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export default function CategoryCreationModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
<div className="flex items-center justify-between p-6 lg:p-8 xl:p-10 border-b border-slate-800">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/10 rounded-lg text-primary">
|
||||||
<Tag size={20} />
|
<Tag size={20} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-normal text-white">New Category</h2>
|
<h2 className="text-lg font-normal text-white">New Category</h2>
|
||||||
@@ -69,7 +69,7 @@ export default function CategoryCreationModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 lg:p-8 xl:p-10 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-normal text-secondary">Name</label>
|
<label className="text-sm font-normal text-secondary">Name</label>
|
||||||
<input
|
<input
|
||||||
@@ -80,7 +80,7 @@ export default function CategoryCreationModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Electronics"
|
placeholder="e.g., Electronics"
|
||||||
className="w-full mt-2 px-3 py-2.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
className="w-full mt-2 px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -93,14 +93,14 @@ export default function CategoryCreationModal({
|
|||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Brief category description"
|
placeholder="Brief category description"
|
||||||
className="w-full mt-2 px-3 py-2.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
className="w-full mt-2 px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded">
|
<div className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-rose-500/10 border border-rose-500/20 rounded">
|
||||||
<p className="text-sm text-rose-400">{error}</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-rose-400">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ export default function ConfirmationModal({
|
|||||||
|
|
||||||
const isHighRisk = dangerLevel === 'high';
|
const isHighRisk = dangerLevel === 'high';
|
||||||
const requiresTextConfirm = isHighRisk && affectedCount && affectedCount > 10;
|
const requiresTextConfirm = isHighRisk && affectedCount && affectedCount > 10;
|
||||||
const canConfirm = !requiresTextConfirm || confirmText === 'Delete';
|
const canConfirm = !requiresTextConfirm || confirmText === 'DELETE';
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (requiresTextConfirm && confirmText !== 'Delete') {
|
if (requiresTextConfirm && confirmText !== 'DELETE') {
|
||||||
setError('Type "Delete" to confirm this action');
|
setError('Type "DELETE" to confirm this action');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +57,9 @@ export default function ConfirmationModal({
|
|||||||
<div data-testid="confirmation-modal" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div data-testid="confirmation-modal" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
<div className="flex items-center justify-between p-6 lg:p-8 xl:p-10 border-b border-slate-800">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-rose-500/10 rounded-lg text-rose-500">
|
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-rose-500/10 rounded-lg text-rose-500">
|
||||||
<AlertTriangle size={20} />
|
<AlertTriangle size={20} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-normal text-white">{title}</h2>
|
<h2 className="text-lg font-normal text-white">{title}</h2>
|
||||||
@@ -75,9 +75,9 @@ export default function ConfirmationModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 lg:p-8 xl:p-10 space-y-4">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-sm text-secondary">{description}</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary">{description}</p>
|
||||||
|
|
||||||
{/* Item Name (if provided) */}
|
{/* Item Name (if provided) */}
|
||||||
{itemName && (
|
{itemName && (
|
||||||
@@ -89,15 +89,15 @@ export default function ConfirmationModal({
|
|||||||
|
|
||||||
{/* Consequence Warning */}
|
{/* Consequence Warning */}
|
||||||
{consequence && (
|
{consequence && (
|
||||||
<div className="flex gap-2 p-3 bg-rose-500/5 border border-rose-500/20 rounded">
|
<div className="flex gap-2 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-rose-500/5 border border-rose-500/20 rounded">
|
||||||
<span className="text-rose-500 flex-shrink-0">⚠</span>
|
<span className="text-rose-500 flex-shrink-0">⚠</span>
|
||||||
<p className="text-sm text-rose-400">{consequence}</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-rose-400">{consequence}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Affected Items Count (for medium-risk+) */}
|
{/* Affected Items Count (for medium-risk+) */}
|
||||||
{affectedCount && affectedCount > 0 && (
|
{affectedCount && affectedCount > 0 && (
|
||||||
<div className="text-sm text-secondary">
|
<div className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary">
|
||||||
{affectedCount === 1
|
{affectedCount === 1
|
||||||
? 'This will affect 1 item.'
|
? 'This will affect 1 item.'
|
||||||
: `This will affect ${affectedCount} items.`}
|
: `This will affect ${affectedCount} items.`}
|
||||||
@@ -108,7 +108,7 @@ export default function ConfirmationModal({
|
|||||||
{requiresTextConfirm && (
|
{requiresTextConfirm && (
|
||||||
<div className="space-y-3 pt-2 border-t border-slate-800">
|
<div className="space-y-3 pt-2 border-t border-slate-800">
|
||||||
<p className="text-sm font-normal text-rose-400">
|
<p className="text-sm font-normal text-rose-400">
|
||||||
Type "Delete" to confirm this high-risk action
|
Type "DELETE" to confirm this high-risk action
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -117,8 +117,8 @@ export default function ConfirmationModal({
|
|||||||
setConfirmText(e.target.value);
|
setConfirmText(e.target.value);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Type Delete"
|
placeholder="Type DELETE"
|
||||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
className="w-full px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && canConfirm && !loading) {
|
if (e.key === 'Enter' && canConfirm && !loading) {
|
||||||
@@ -127,20 +127,20 @@ export default function ConfirmationModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-rose-500">{error}</p>
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-rose-500">{error}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 p-6 border-t border-slate-800">
|
<div className="flex gap-2 p-6 lg:p-8 xl:p-10 border-t border-slate-800">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 px-4 py-2.5 text-secondary bg-slate-800 border border-slate-700 rounded font-normal hover:bg-slate-700 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-primary focus:outline-none"
|
className="flex-1 px-4 py-2.5 text-secondary bg-slate-800 border border-slate-700 rounded font-normal hover:bg-slate-700 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:ring-2 focus:ring-primary focus:outline-none"
|
||||||
>
|
>
|
||||||
Keep Item
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 lg:p-8 xl:p-10 space-y-4">
|
||||||
{/* Username Field */}
|
{/* Username Field */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-normal text-secondary mb-2">
|
<label htmlFor="username" className="block text-sm font-normal text-secondary mb-2">
|
||||||
@@ -101,13 +101,13 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
|||||||
if (errors.username) setErrors({ ...errors, username: undefined });
|
if (errors.username) setErrors({ ...errors, username: undefined });
|
||||||
}}
|
}}
|
||||||
placeholder="Enter username"
|
placeholder="Enter username"
|
||||||
className={`w-full px-3 py-2.5 bg-slate-800 border rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
className={`w-full px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||||
errors.username ? 'border-red-500' : 'border-slate-700'
|
errors.username ? 'border-red-500' : 'border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
{errors.username && (
|
{errors.username && (
|
||||||
<p className="mt-1.5 text-sm text-red-500">{errors.username}</p>
|
<p className="mt-1.5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-red-500">{errors.username}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,13 +126,13 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
|||||||
if (errors.password) setErrors({ ...errors, password: undefined });
|
if (errors.password) setErrors({ ...errors, password: undefined });
|
||||||
}}
|
}}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
className={`w-full px-3 py-2.5 bg-slate-800 border rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
className={`w-full px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||||
errors.password ? 'border-red-500' : 'border-slate-700'
|
errors.password ? 'border-red-500' : 'border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="mt-1.5 text-sm text-red-500">{errors.password}</p>
|
<p className="mt-1.5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-red-500">{errors.password}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function FilterBar({ searchQuery, onChange }: FilterBarProps) {
|
|||||||
placeholder="Search catalog..."
|
placeholder="Search catalog..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className="w-full bg-surface border border-slate-800 rounded-2xl py-3.5 pr-4 pl-11 text-sm focus:border-primary outline-none transition-all placeholder:text-secondary"
|
className="w-full bg-surface border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5.5 pr-4 pl-11 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl focus:border-primary outline-none transition-all placeholder:text-secondary"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary">
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary">
|
||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
key={user.id}
|
key={user.id}
|
||||||
data-testid="user-list-item"
|
data-testid="user-list-item"
|
||||||
onClick={() => handleSelectUser(user)}
|
onClick={() => handleSelectUser(user)}
|
||||||
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]"
|
className="bg-slate-800/40 hover:bg-slate-800 border border-slate-800/50 hover:border-primary/40 p-5 lg:p-6 xl:p-8 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-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-2xl bg-surface border border-slate-800 flex items-center justify-center text-muted group-hover:text-primary transition-all group-hover:scale-110">
|
<div className="w-10 h-10 rounded-2xl bg-surface border border-slate-800 flex items-center justify-center text-muted group-hover:text-primary transition-all group-hover:scale-110">
|
||||||
@@ -126,7 +126,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
data-testid="username-input"
|
data-testid="username-input"
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
||||||
placeholder="DIRECTORY ID"
|
placeholder="DIRECTORY ID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +140,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
data-testid="password-input"
|
data-testid="password-input"
|
||||||
type="password"
|
type="password"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||||
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +156,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
||||||
<div data-testid="user-display" className="bg-background/50 p-5 rounded-[1.5rem] border border-slate-800 flex items-center justify-between group">
|
<div data-testid="user-display" className="bg-background/50 p-5 lg:p-6 xl:p-8 rounded-[1.5rem] border border-slate-800 flex items-center justify-between group">
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
<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} />
|
<Shield size={20} />
|
||||||
@@ -168,7 +168,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedUserForLogin(null)}
|
onClick={() => setSelectedUserForLogin(null)}
|
||||||
className="p-2 hover:bg-slate-800 rounded-xl transition-all text-secondary hover:text-rose-500"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-xl transition-all text-secondary hover:text-rose-500"
|
||||||
title="Change User"
|
title="Change User"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -184,7 +184,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
type="password"
|
type="password"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||||
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
||||||
placeholder="KEY-PHRASE"
|
placeholder="KEY-PHRASE"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ export default function InventoryTable({
|
|||||||
|
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="py-20 text-center text-secondary">
|
<div className="py-2 lg:py-3 xl:py-40 text-center text-secondary">
|
||||||
<Package size={48} className="mx-auto mb-4 opacity-10" />
|
<Package size={48} className="mx-auto mb-4 opacity-10" />
|
||||||
<p className="font-normal">No results found</p>
|
<p className="font-medium">No results found</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ export default function InventoryTable({
|
|||||||
return (
|
return (
|
||||||
<div key={cat} className="bg-surface/50 border border-slate-800/50 rounded-3xl overflow-hidden transition-all duration-300">
|
<div key={cat} className="bg-surface/50 border border-slate-800/50 rounded-3xl overflow-hidden transition-all duration-300">
|
||||||
<div
|
<div
|
||||||
className="w-full p-4 md:p-5 flex items-center justify-between hover:bg-surface/60 transition-colors cursor-pointer"
|
className="w-full p-4 md:p-5 lg:p-6 xl:p-8 flex items-center justify-between hover:bg-surface/60 transition-colors cursor-pointer"
|
||||||
onClick={() => onExpandCategory(expandedCategory === cat ? null : cat)}
|
onClick={() => onExpandCategory(expandedCategory === cat ? null : cat)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -75,7 +75,7 @@ export default function InventoryTable({
|
|||||||
<Layers size={20} />
|
<Layers size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="card-title text-base sm:text-lg">{cat}</h3>
|
<h3 className="card-title text-base sm:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">{cat}</h3>
|
||||||
<p className="card-subtitle tracking-tight">
|
<p className="card-subtitle tracking-tight">
|
||||||
{categoryItems.length} Item types in stock
|
{categoryItems.length} Item types in stock
|
||||||
</p>
|
</p>
|
||||||
@@ -89,7 +89,7 @@ export default function InventoryTable({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEditCategory(cat);
|
onEditCategory(cat);
|
||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-primary transition-colors relative z-10"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full text-muted hover:text-primary transition-colors relative z-10"
|
||||||
>
|
>
|
||||||
<EditIcon size={16} />
|
<EditIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -98,7 +98,7 @@ export default function InventoryTable({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedCategory === cat && (
|
{expandedCategory === cat && (
|
||||||
<div className="p-4 pt-0 space-y-2 animate-in slide-in-from-top-4 duration-300">
|
<div className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 pt-0 space-y-2 animate-in slide-in-from-top-4 duration-300">
|
||||||
<div className="h-px bg-slate-800/50 mb-4 mx-2" />
|
<div className="h-px bg-slate-800/50 mb-4 mx-2" />
|
||||||
{categoryItems.map(item => (
|
{categoryItems.map(item => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function ItemComparisonModal({
|
|||||||
const hasChanges = fields.some(f => isDifferent(f.key));
|
const hasChanges = fields.some(f => isDifferent(f.key));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-background/80">
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/80">
|
||||||
<div className="bg-surface border border-slate-800 rounded-[2.5rem] p-8 w-full max-w-2xl shadow-2xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-surface border border-slate-800 rounded-[2.5rem] p-8 w-full max-w-2xl shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<AlertTriangle size={24} className="text-amber-500" />
|
<AlertTriangle size={24} className="text-amber-500" />
|
||||||
@@ -68,7 +68,7 @@ export default function ItemComparisonModal({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.key}
|
key={field.key}
|
||||||
className={`grid grid-cols-3 gap-4 p-3 rounded-lg ${
|
className={`grid grid-cols-3 gap-4 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 rounded-lg ${
|
||||||
different ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-slate-800/30'
|
different ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-slate-800/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -85,8 +85,8 @@ export default function ItemComparisonModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasChanges && (
|
{!hasChanges && (
|
||||||
<div className="p-4 bg-slate-800/50 rounded-xl mb-6 border border-slate-700">
|
<div className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-slate-800/50 rounded-xl mb-6 border border-slate-700">
|
||||||
<p className="text-sm text-secondary">✓ Items are identical. No update needed.</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary">✓ Items are identical. No update needed.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
|||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-background p-6 animate-in slide-in-from-bottom-20 duration-500">
|
<div className="fixed inset-0 z-50 flex flex-col bg-background p-6 lg:p-8 xl:p-10 animate-in slide-in-from-bottom-20 duration-500">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-normal tracking-tight">Audit History</h2>
|
<h2 className="text-2xl font-normal tracking-tight">Audit History</h2>
|
||||||
@@ -22,7 +22,7 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-3 bg-surface rounded-full text-secondary"
|
className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-surface rounded-full text-secondary"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -36,7 +36,7 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<div key={log.id} className="bg-surface/70 border border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4">
|
<div key={log.id} className="bg-surface/70 border border-slate-800 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl flex items-center justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<p className={`text-xs font-normal ${
|
<p className={`text-xs font-normal ${
|
||||||
@@ -54,11 +54,11 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
|||||||
</div>
|
</div>
|
||||||
{(log.details || log.timestamp) && (
|
{(log.details || log.timestamp) && (
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<p className="text-xs text-secondary font-mono">
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-mono">
|
||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
{log.details && (
|
{log.details && (
|
||||||
<span className="text-xs bg-slate-800 text-muted px-2 py-0.5 rounded border border-slate-700 font-mono">
|
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl bg-slate-800 text-muted px-2 py-0.5 rounded border border-slate-700 font-mono">
|
||||||
{log.details}
|
{log.details}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-32 text-secondary gap-4 animate-pulse">
|
<div className="flex flex-col items-center justify-center py-3 lg:py-4 xl:py-52 text-secondary gap-4 animate-pulse">
|
||||||
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
<p className="text-xs font-normal tracking-widest italic">Securing Audit Stream...</p>
|
<p className="text-xs font-normal tracking-widest italic">Securing Audit Stream...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +53,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
|||||||
<button
|
<button
|
||||||
key={log.id}
|
key={log.id}
|
||||||
onClick={() => setSelectedLog(log)}
|
onClick={() => setSelectedLog(log)}
|
||||||
className="w-full text-left bg-surface/50 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"
|
className="w-full text-left bg-surface/50 border border-slate-800/30 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 px-4 lg:px-5 xl:px-6 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">
|
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
|
||||||
{/* Compact Action Badge */}
|
{/* Compact Action Badge */}
|
||||||
@@ -111,7 +111,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
|||||||
{selectedLog.resolved_name}
|
{selectedLog.resolved_name}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setSelectedLog(null)} className="p-3 bg-slate-800/50 hover:bg-slate-800 rounded-2xl text-muted transition-colors border border-slate-800 shrink-0 shadow-lg">
|
<button onClick={() => setSelectedLog(null)} className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-slate-800/50 hover:bg-slate-800 rounded-2xl text-muted transition-colors border border-slate-800 shrink-0 shadow-lg">
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +170,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
|||||||
{selectedLog.details && (
|
{selectedLog.details && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-[11px] font-normal text-muted tracking-widest ml-1">Intervention Details</p>
|
<p className="text-[11px] font-normal text-muted tracking-widest ml-1">Intervention Details</p>
|
||||||
<div className="bg-primary/5 text-primary/80 p-5 rounded-3xl border border-primary/10 text-sm font-normal leading-relaxed italic shadow-inner">
|
<div className="bg-primary/5 text-primary/80 p-5 rounded-3xl border border-primary/10 text-sm font-medium leading-relaxed italic shadow-inner">
|
||||||
"{selectedLog.details}"
|
"{selectedLog.details}"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ export default function NewItemDialog({ onScannerClick, onAddItemClick }: NewIte
|
|||||||
onClick={onAddItemClick}
|
onClick={onAddItemClick}
|
||||||
className="w-full flex flex-col items-center justify-center p-4 md:p-6 rounded-[2rem] bg-indigo-500/5 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-normal text-indigo-400 gap-2 md:gap-3"
|
className="w-full flex flex-col items-center justify-center p-4 md:p-6 rounded-[2rem] bg-indigo-500/5 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-normal text-indigo-400 gap-2 md:gap-3"
|
||||||
>
|
>
|
||||||
<div className="p-4 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform shadow-lg shadow-indigo-500/10">
|
<div className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform shadow-lg shadow-indigo-500/10">
|
||||||
<Sparkles size={32} />
|
<Sparkles size={32} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg leading-tight">Add New Item</p>
|
<p className="text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl leading-tight">Add New Item</p>
|
||||||
<p className="text-xs opacity-60 font-mono mt-1">AI Smart Discovery</p>
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl opacity-60 font-mono mt-1">AI Smart Discovery</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ export default function ScannerSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scanner Section */}
|
{/* Scanner Section */}
|
||||||
<section className="glass-card rounded-3xl p-6">
|
<section className="glass-card rounded-3xl p-6 lg:p-8 xl:p-10">
|
||||||
{showScanner ? (
|
{showScanner ? (
|
||||||
<div className="space-y-2 md:space-y-3">
|
<div className="space-y-2 md:space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-lg font-normal">scanning...</h2>
|
<h2 className="text-lg font-normal">scanning...</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => onShowScanner(false)}
|
onClick={() => onShowScanner(false)}
|
||||||
className="p-2.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-rose-500 transition-all active:scale-95"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-rose-500 transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface StatCardProps {
|
|||||||
|
|
||||||
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center gap-2 p-3.5 bg-surface/70 border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-surface" role="status">
|
<div className="flex justify-between items-center gap-2 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8.5 bg-surface/70 border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-surface" role="status">
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<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" />}
|
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />}
|
||||||
<span className="text-base md:text-lg text-secondary font-normal truncate">
|
<span className="text-base md:text-lg text-secondary font-normal truncate">
|
||||||
@@ -17,7 +17,7 @@ export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-base md:text-lg font-normal text-white whitespace-nowrap tabular-nums">
|
<span className="text-2xl md:text-3xl font-normal text-white whitespace-nowrap tabular-nums">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function StockAdjustmentPanel({
|
|||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(selectedItem)}
|
onClick={() => onEdit(selectedItem)}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full text-secondary"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full text-secondary"
|
||||||
>
|
>
|
||||||
<Edit2 size={20} />
|
<Edit2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -85,7 +85,7 @@ export default function StockAdjustmentPanel({
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteItem}
|
onClick={onDeleteItem}
|
||||||
className="p-2 hover:bg-red-500/20 rounded-full text-red-500"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-red-500/20 rounded-full text-red-500"
|
||||||
>
|
>
|
||||||
<Trash2 size={20} />
|
<Trash2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -93,7 +93,7 @@ export default function StockAdjustmentPanel({
|
|||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
data-testid="adjustment-cancel"
|
data-testid="adjustment-cancel"
|
||||||
className="p-2 hover:bg-slate-800 rounded-full"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -119,7 +119,7 @@ export default function StockAdjustmentPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedItem.part_number || ''}
|
value={editedItem.part_number || ''}
|
||||||
onChange={e => onEditChange({ ...editedItem, part_number: e.target.value })}
|
onChange={e => onEditChange({ ...editedItem, part_number: e.target.value })}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm font-mono outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-mono outline-none text-secondary"
|
||||||
placeholder="e.g. PN-12345"
|
placeholder="e.g. PN-12345"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@ export default function StockAdjustmentPanel({
|
|||||||
list="existing-categories"
|
list="existing-categories"
|
||||||
value={editedItem.category || ''}
|
value={editedItem.category || ''}
|
||||||
onChange={e => onEditChange({ ...editedItem, category: e.target.value })}
|
onChange={e => onEditChange({ ...editedItem, category: e.target.value })}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary placeholder:text-muted"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary placeholder:text-muted"
|
||||||
placeholder="e.g. storage"
|
placeholder="e.g. storage"
|
||||||
/>
|
/>
|
||||||
<datalist id="existing-categories">
|
<datalist id="existing-categories">
|
||||||
@@ -147,7 +147,7 @@ export default function StockAdjustmentPanel({
|
|||||||
list="existing-types"
|
list="existing-types"
|
||||||
value={editedItem.type || ''}
|
value={editedItem.type || ''}
|
||||||
onChange={e => onEditChange({...editedItem, type: e.target.value})}
|
onChange={e => onEditChange({...editedItem, type: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
placeholder="e.g. spare parts"
|
placeholder="e.g. spare parts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +159,7 @@ export default function StockAdjustmentPanel({
|
|||||||
list="existing-boxes"
|
list="existing-boxes"
|
||||||
value={editedItem.box_label || ''}
|
value={editedItem.box_label || ''}
|
||||||
onChange={e => onEditChange({...editedItem, box_label: e.target.value})}
|
onChange={e => onEditChange({...editedItem, box_label: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-secondary placeholder:text-muted focus:border-primary transition-colors"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 pl-4 pr-12 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary placeholder:text-muted focus:border-primary transition-colors"
|
||||||
placeholder="e.g. SFPs 40G Cisco"
|
placeholder="e.g. SFPs 40G Cisco"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -169,7 +169,7 @@ export default function StockAdjustmentPanel({
|
|||||||
toast.success("Scanning for BOX label...");
|
toast.success("Scanning for BOX label...");
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 p-2 rounded-lg transition-all",
|
"absolute right-2 p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-lg transition-all",
|
||||||
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-muted hover:bg-slate-800"
|
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-muted hover:bg-slate-800"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -183,7 +183,7 @@ export default function StockAdjustmentPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedItem.connector || ''}
|
value={editedItem.connector || ''}
|
||||||
onChange={e => onEditChange({...editedItem, connector: e.target.value})}
|
onChange={e => onEditChange({...editedItem, connector: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
placeholder="e.g. LC/UPC"
|
placeholder="e.g. LC/UPC"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +193,7 @@ export default function StockAdjustmentPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedItem.size || ''}
|
value={editedItem.size || ''}
|
||||||
onChange={e => onEditChange({...editedItem, size: e.target.value})}
|
onChange={e => onEditChange({...editedItem, size: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
placeholder="e.g. 5m / 1600GB"
|
placeholder="e.g. 5m / 1600GB"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,7 +203,7 @@ export default function StockAdjustmentPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedItem.color || ''}
|
value={editedItem.color || ''}
|
||||||
onChange={e => onEditChange({...editedItem, color: e.target.value})}
|
onChange={e => onEditChange({...editedItem, color: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
placeholder="e.g. Black"
|
placeholder="e.g. Black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +212,7 @@ export default function StockAdjustmentPanel({
|
|||||||
<textarea
|
<textarea
|
||||||
value={editedItem.description || ''}
|
value={editedItem.description || ''}
|
||||||
onChange={e => onEditChange({ ...editedItem, description: e.target.value })}
|
onChange={e => onEditChange({ ...editedItem, description: e.target.value })}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary resize-none h-20"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary resize-none h-20"
|
||||||
placeholder="Item description..."
|
placeholder="Item description..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +239,7 @@ export default function StockAdjustmentPanel({
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => onTypeChange(t.id as 'ADD' | 'REMOVE' | 'TRASH')}
|
onClick={() => onTypeChange(t.id as 'ADD' | 'REMOVE' | 'TRASH')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex flex-col items-center py-3 rounded-xl transition-all",
|
"flex-1 flex flex-col items-center py-3 lg:py-4 xl:py-5 rounded-xl transition-all",
|
||||||
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-muted"
|
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -270,7 +270,7 @@ export default function StockAdjustmentPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adjustType === 'TRASH' && (
|
{adjustType === 'TRASH' && (
|
||||||
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 rounded-2xl animate-in shake duration-500">
|
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl animate-in shake duration-500">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<AlertTriangle size={16} className="text-red-500" />
|
<AlertTriangle size={16} className="text-red-500" />
|
||||||
<span className="text-sm font-normal text-red-400">Waste Declaration</span>
|
<span className="text-sm font-normal text-red-400">Waste Declaration</span>
|
||||||
@@ -278,7 +278,7 @@ export default function StockAdjustmentPanel({
|
|||||||
<select
|
<select
|
||||||
value={trashReason}
|
value={trashReason}
|
||||||
onChange={(e) => onReasonChange(e.target.value)}
|
onChange={(e) => onReasonChange(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
>
|
>
|
||||||
<option>Damaged</option>
|
<option>Damaged</option>
|
||||||
<option>Expired</option>
|
<option>Expired</option>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function AiManager({
|
|||||||
data-testid="provider-option"
|
data-testid="provider-option"
|
||||||
onClick={() => handleUpdateAiProvider(p.id)}
|
onClick={() => handleUpdateAiProvider(p.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 rounded-2xl border transition-all text-left flex items-center justify-between group",
|
"p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl border transition-all text-left flex items-center justify-between group",
|
||||||
p.active
|
p.active
|
||||||
? "bg-primary border-primary shadow-xl shadow-primary/10"
|
? "bg-primary border-primary shadow-xl shadow-primary/10"
|
||||||
: "bg-background/60 border-slate-800 hover:border-primary/40"
|
: "bg-background/60 border-slate-800 hover:border-primary/40"
|
||||||
@@ -177,7 +177,7 @@ export default function AiManager({
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-primary/5 border border-purple-500/10 rounded-2xl p-4 flex gap-3 items-start">
|
<div className="bg-primary/5 border border-purple-500/10 rounded-2xl p-4 flex gap-3 items-start">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg text-purple-400 shrink-0"><Shield size={14} /></div>
|
<div className="p-2 bg-primary/10 rounded-lg text-purple-400 shrink-0"><Shield size={14} /></div>
|
||||||
<p className="text-xs font-normal text-secondary leading-relaxed">
|
<p className="text-xs font-medium text-secondary 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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function CategoryManager({
|
|||||||
|
|
||||||
<div data-testid="category-list" className="grid sm:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
<div data-testid="category-list" className="grid sm:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<div key={cat.id} data-testid="category-item" className="p-4 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-between group hover:border-primary/40 transition-all">
|
<div key={cat.id} data-testid="category-item" className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/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">
|
<div className="min-w-0 pr-4">
|
||||||
<p className="card-title group-hover:text-primary transition-colors">{cat.name}</p>
|
<p className="card-title group-hover:text-primary transition-colors">{cat.name}</p>
|
||||||
<p className="card-subtitle">{cat.description || 'General storage'}</p>
|
<p className="card-subtitle">{cat.description || 'General storage'}</p>
|
||||||
@@ -53,14 +53,14 @@ export default function CategoryManager({
|
|||||||
setEditingCategory(cat);
|
setEditingCategory(cat);
|
||||||
setEditCatForm({ name: cat.name, description: cat.description || '' });
|
setEditCatForm({ name: cat.name, description: cat.description || '' });
|
||||||
}}
|
}}
|
||||||
className="p-2 text-secondary hover:text-white hover:bg-slate-800 rounded-lg transition-all"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-white hover:bg-slate-800 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Edit2 size={14} />
|
<Edit2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeleteCategory(cat.id, cat.name)}
|
onClick={() => onDeleteCategory(cat.id, cat.name)}
|
||||||
data-testid="delete-category-button"
|
data-testid="delete-category-button"
|
||||||
className="p-2 text-secondary hover:text-rose-500 hover:bg-rose-500/5 rounded-lg transition-all"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-rose-500 hover:bg-rose-500/5 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -138,13 +138,14 @@ export default function DatabaseManager({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-normal text-secondary">{bak.filename}</p>
|
<p className="text-xs font-normal text-secondary">{bak.filename}</p>
|
||||||
<p className="text-xs text-secondary font-normal tabular-nums"> {new Date(bak.created_at).toLocaleString()} • {formatSize(bak.size_bytes)}
|
<p className="text-xs text-secondary font-medium tabular-nums">
|
||||||
|
{new Date(bak.created_at).toLocaleString()} • {formatSize(bak.size_bytes)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRestore(bak.filename)}
|
onClick={() => onRestore(bak.filename)}
|
||||||
className="p-2 text-secondary hover:text-primary hover:bg-primary/5 rounded-xl transition-all opacity-0 group-hover/item:opacity-100 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-primary hover:bg-primary/5 rounded-xl transition-all opacity-0 group-hover/item:opacity-100 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||||
aria-label={`Restore backup ${bak.filename}`}
|
aria-label={`Restore backup ${bak.filename}`}
|
||||||
>
|
>
|
||||||
<RotateCcw size={16} />
|
<RotateCcw size={16} />
|
||||||
|
|||||||
@@ -1,119 +1,136 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FileDown, Loader2, FileSpreadsheet, FileText } from "lucide-react";
|
import { FileDown, Loader2 } from "lucide-react";
|
||||||
import { useExport } from "@/hooks/useExport";
|
import { useExport } from "@/hooks/useExport";
|
||||||
import { toast } from "react-hot-toast";
|
import { Toast } from "@/components/Toast";
|
||||||
|
|
||||||
export function ExportPanel() {
|
export function ExportPanel() {
|
||||||
const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport();
|
const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport();
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleExportSnapshot = async (format: "csv" | "xlsx") => {
|
const handleExportSnapshot = async (format: "csv" | "xlsx") => {
|
||||||
try {
|
try {
|
||||||
|
setSuccessMessage(null);
|
||||||
await exportSnapshot(format);
|
await exportSnapshot(format);
|
||||||
const formatName = format === "csv" ? "CSV" : "Excel";
|
const formatName = format === "csv" ? "CSV" : "Excel";
|
||||||
toast.success(`Inventory snapshot exported as ${formatName}`);
|
setSuccessMessage(`Inventory snapshot exported as ${formatName}`);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 4000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error handled by hook
|
// Error handled by useExport hook
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportAuditTrail = async (format: "csv" | "xlsx") => {
|
const handleExportAuditTrail = async (format: "csv" | "xlsx") => {
|
||||||
try {
|
try {
|
||||||
|
setSuccessMessage(null);
|
||||||
await exportAuditTrail(format);
|
await exportAuditTrail(format);
|
||||||
const formatName = format === "csv" ? "CSV" : "Excel";
|
const formatName = format === "csv" ? "CSV" : "Excel";
|
||||||
toast.success(`Audit trail exported as ${formatName}`);
|
setSuccessMessage(`Audit trail exported as ${formatName}`);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 4000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error handled by hook
|
// Error handled by useExport hook
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="admin-tab-exports" className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 flex flex-col shadow-2xl transition-all h-full">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3 mb-4 md:mb-6">
|
<div className="rounded-lg border border-slate-200 bg-white p-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">
|
<div className="mb-6 flex items-center gap-3">
|
||||||
<FileDown size={20} />
|
<div className="rounded-lg bg-primary/10 p-4 border border-primary/20">
|
||||||
</div>
|
<FileDown className="text-primary" size={24} />
|
||||||
<div>
|
</div>
|
||||||
<h2 className="text-xl font-normal text-white tracking-tight">Export & Reports</h2>
|
<div>
|
||||||
<p className="text-[10px] md:text-xs text-muted font-normal tracking-tight">Download system snapshots and action logs</p>
|
<h2 className="text-2xl font-normal">Export & Reports</h2>
|
||||||
</div>
|
<p className="text-xs text-slate-500">
|
||||||
</div>
|
Download inventory snapshots and audit trails in CSV or Excel formats
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
{/* Inventory Snapshot Section */}
|
|
||||||
<div className="p-4 bg-background/40 border border-slate-800/40 rounded-2xl hover:border-primary/20 transition-all group flex flex-col justify-between">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-normal text-secondary flex items-center gap-2">
|
|
||||||
<FileSpreadsheet size={14} className="text-primary" />
|
|
||||||
Inventory Snapshot
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted mt-1 leading-relaxed">
|
|
||||||
Export current inventory state with all item details and locations.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{/* Inventory Snapshot Section */}
|
||||||
|
<div className="mb-8 space-y-3">
|
||||||
|
<h3 className="font-normal text-slate-700">Inventory Snapshot</h3>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Export current inventory state with all item details
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExportSnapshot("csv")}
|
onClick={() => handleExportSnapshot("csv")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
|
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||||
aria-label="Export inventory snapshot as CSV"
|
aria-label="Export inventory snapshot as CSV"
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileText size={14} />}
|
{isLoading ? (
|
||||||
CSV
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileDown size={16} />
|
||||||
|
)}
|
||||||
|
{isLoading ? "Exporting..." : "Export as CSV"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExportSnapshot("xlsx")}
|
onClick={() => handleExportSnapshot("xlsx")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
|
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||||
aria-label="Export inventory snapshot as Excel"
|
aria-label="Export inventory snapshot as Excel"
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileSpreadsheet size={14} />}
|
{isLoading ? (
|
||||||
Excel
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileDown size={16} />
|
||||||
|
)}
|
||||||
|
{isLoading ? "Exporting..." : "Export as Excel"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audit Trail Section */}
|
{/* Audit Trail Section */}
|
||||||
<div className="p-4 bg-background/40 border border-slate-800/40 rounded-2xl hover:border-primary/20 transition-all group flex flex-col justify-between">
|
<div className="space-y-3">
|
||||||
<div className="mb-4">
|
<h3 className="font-normal text-slate-700">Audit Trail</h3>
|
||||||
<h3 className="text-sm font-normal text-secondary flex items-center gap-2">
|
<p className="text-sm text-slate-500">
|
||||||
<FileText size={14} className="text-indigo-400" />
|
Export complete audit log of all system actions and changes
|
||||||
Audit Trail
|
</p>
|
||||||
</h3>
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<p className="text-xs text-muted mt-1 leading-relaxed">
|
|
||||||
Complete history of system actions, changes, and user operations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExportAuditTrail("csv")}
|
onClick={() => handleExportAuditTrail("csv")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
|
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||||
aria-label="Export audit trail as CSV"
|
aria-label="Export audit trail as CSV"
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileText size={14} />}
|
{isLoading ? (
|
||||||
CSV
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileDown size={16} />
|
||||||
|
)}
|
||||||
|
{isLoading ? "Exporting..." : "Export as CSV"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExportAuditTrail("xlsx")}
|
onClick={() => handleExportAuditTrail("xlsx")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
|
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
|
||||||
aria-label="Export audit trail as Excel"
|
aria-label="Export audit trail as Excel"
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileSpreadsheet size={14} />}
|
{isLoading ? (
|
||||||
Excel
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileDown size={16} />
|
||||||
|
)}
|
||||||
|
{isLoading ? "Exporting..." : "Export as Excel"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status Messages */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-4 p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-[10px] flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
|
<Toast
|
||||||
<div className="w-1.5 h-1.5 bg-rose-500 rounded-full animate-pulse" />
|
type="error"
|
||||||
<span className="font-normal truncate">Error: {error}</span>
|
message={`Export failed: ${error}`}
|
||||||
</div>
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{successMessage && (
|
||||||
|
<Toast type="success" message={successMessage} onClose={() => {}} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function IdentityManager({
|
|||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
setEditUserForm({ username: user.username, password: '', role: user.role });
|
setEditUserForm({ username: user.username, password: '', role: user.role });
|
||||||
}}
|
}}
|
||||||
className="p-2 text-secondary hover:text-primary hover:bg-primary/5 rounded-xl transition-all focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-primary hover:bg-primary/5 rounded-xl transition-all focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||||
aria-label={`Edit user ${user.username}`}
|
aria-label={`Edit user ${user.username}`}
|
||||||
>
|
>
|
||||||
<Edit2 size={16} />
|
<Edit2 size={16} />
|
||||||
@@ -72,7 +72,7 @@ export default function IdentityManager({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onDeleteUser(user.id, user.username)}
|
onClick={() => onDeleteUser(user.id, user.username)}
|
||||||
data-testid="delete-user-button"
|
data-testid="delete-user-button"
|
||||||
className="p-2 text-secondary hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||||
aria-label={`Delete user ${user.username}`}
|
aria-label={`Delete user ${user.username}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -91,7 +91,7 @@ export default function IdentityManager({
|
|||||||
<h3 className="text-xl font-normal text-white tracking-tight">Edit Identity</h3>
|
<h3 className="text-xl font-normal text-white tracking-tight">Edit Identity</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingUser(null)}
|
onClick={() => setEditingUser(null)}
|
||||||
className="p-2 text-muted hover:text-white transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-muted hover:text-white transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded"
|
||||||
aria-label="Close edit dialog"
|
aria-label="Close edit dialog"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function LdapManager({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 bg-background/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-primary/30">
|
<div className="flex items-center justify-between p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-primary/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
||||||
@@ -115,7 +115,7 @@ export default function LdapManager({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 bg-background/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-primary/30">
|
<div className="flex items-center justify-between p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-primary/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default function QuantityAdjustmentModal({
|
|||||||
|
|
||||||
{/* Quantity Adjustment */}
|
{/* Quantity Adjustment */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-normal text-slate-400 mb-3 block">
|
<label className="text-sm font-medium text-slate-400 mb-3 block">
|
||||||
Current quantity
|
Current quantity
|
||||||
</label>
|
</label>
|
||||||
<QuantityDisplay
|
<QuantityDisplay
|
||||||
@@ -112,9 +112,9 @@ export default function QuantityAdjustmentModal({
|
|||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-100 rounded-lg font-normal transition-colors"
|
className="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-100 rounded-lg font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Keep Current Quantity
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function QuantityDisplay({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="Quantity"
|
aria-label="Quantity"
|
||||||
className="w-12 text-center bg-transparent text-primary font-normal focus:outline-none disabled:opacity-50"
|
className="w-12 text-center bg-transparent text-primary font-medium focus:outline-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -123,7 +123,7 @@ export default function QuantityDisplay({
|
|||||||
onClick={handleTapToEdit}
|
onClick={handleTapToEdit}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label={`Quantity: ${currentQuantity}. Tap to edit`}
|
aria-label={`Quantity: ${currentQuantity}. Tap to edit`}
|
||||||
className="text-lg font-normal text-primary hover:bg-slate-900/30 px-3 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="text-lg font-medium text-primary hover:bg-slate-900/30 px-3 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{currentQuantity}
|
{currentQuantity}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -175,10 +175,10 @@ export default function SearchModal({
|
|||||||
className="w-full p-4 text-left hover:bg-slate-900 transition-colors focus:outline-none focus:bg-slate-900 focus:ring-2 focus:ring-primary/50"
|
className="w-full p-4 text-left hover:bg-slate-900 transition-colors focus:outline-none focus:bg-slate-900 focus:ring-2 focus:ring-primary/50"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start gap-2 mb-1">
|
<div className="flex justify-between items-start gap-2 mb-1">
|
||||||
<h3 className="font-normal text-slate-100 flex-1">
|
<h3 className="font-medium text-slate-100 flex-1">
|
||||||
{item.name}
|
{item.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-primary font-normal text-sm whitespace-nowrap">
|
<span className="text-primary font-medium text-sm whitespace-nowrap">
|
||||||
Qty: {item.quantity}
|
Qty: {item.quantity}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function useExport(): UseExportReturn {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get(
|
const response = await axiosInstance.get(
|
||||||
`/admin/reports/export?format=${format}&type=inventory`,
|
`/admin/db/export?format=${format}&type=inventory`,
|
||||||
{
|
{
|
||||||
responseType: "blob"
|
responseType: "blob"
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export function useExport(): UseExportReturn {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get(
|
const response = await axiosInstance.get(
|
||||||
`/admin/reports/export?format=${format}&type=audit`,
|
`/admin/db/export?format=${format}&type=audit`,
|
||||||
{
|
{
|
||||||
responseType: "blob"
|
responseType: "blob"
|
||||||
}
|
}
|
||||||
|
|||||||
128
scripts/backup.sh
Normal file
128
scripts/backup.sh
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Phase 6, Plan 02, Task 1: Automated Backup Script
|
||||||
|
# Supports both Docker and Standalone deployment modes
|
||||||
|
# Usage: ./backup.sh [daily|weekly|manual] [retention_days]
|
||||||
|
|
||||||
|
BACKUP_TYPE="${1:-manual}"
|
||||||
|
RETENTION_DAYS="${2:-30}"
|
||||||
|
BACKUP_DIR="./backups"
|
||||||
|
DATA_DIR="./data"
|
||||||
|
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/inventory-$TIMESTAMP.tar.gz"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Determine deployment mode
|
||||||
|
if command -v docker-compose &> /dev/null; then
|
||||||
|
log_info "Docker deployment mode detected"
|
||||||
|
IS_DOCKER=true
|
||||||
|
|
||||||
|
# Verify Docker daemon is running
|
||||||
|
docker ps > /dev/null 2>&1 || log_error "Docker daemon not running"
|
||||||
|
|
||||||
|
# 1. Verify services are running
|
||||||
|
log_info "Checking services..."
|
||||||
|
if ! docker-compose ps | grep -q "Up"; then
|
||||||
|
log_warn "Not all services running; attempting to start..."
|
||||||
|
docker-compose up -d
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Stop backend to ensure DB consistency
|
||||||
|
log_info "Stopping backend service (DB consistency)..."
|
||||||
|
docker-compose stop backend
|
||||||
|
else
|
||||||
|
log_info "Standalone deployment mode detected"
|
||||||
|
IS_DOCKER=false
|
||||||
|
|
||||||
|
# For standalone mode, we'll just backup while running
|
||||||
|
# (Process is single-threaded, so minimal locking risk)
|
||||||
|
log_warn "Standalone mode: backing up with minimal service pause"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for graceful shutdown (Docker only)
|
||||||
|
[[ "$IS_DOCKER" == "true" ]] && sleep 2
|
||||||
|
|
||||||
|
# 3. Create backup tarball
|
||||||
|
log_info "Creating backup: $BACKUP_FILE"
|
||||||
|
|
||||||
|
tar --exclude="$DATA_DIR/caddy_*" \
|
||||||
|
--exclude="$BACKUP_DIR" \
|
||||||
|
--exclude=".git" \
|
||||||
|
--exclude="node_modules" \
|
||||||
|
--exclude=".next" \
|
||||||
|
--exclude=".venv" \
|
||||||
|
--exclude="__pycache__" \
|
||||||
|
-czf "$BACKUP_FILE" \
|
||||||
|
"$DATA_DIR/inventory.db" \
|
||||||
|
"$DATA_DIR/inventory.db-wal" \
|
||||||
|
"$DATA_DIR/inventory.db-shm" \
|
||||||
|
"config/" \
|
||||||
|
"inventory.env" \
|
||||||
|
2>/dev/null || log_error "Backup creation failed"
|
||||||
|
|
||||||
|
# Restart backend (Docker only)
|
||||||
|
if [[ "$IS_DOCKER" == "true" ]]; then
|
||||||
|
log_info "Restarting backend service..."
|
||||||
|
docker-compose start backend
|
||||||
|
|
||||||
|
# Wait for backend to be ready
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Verify backend is healthy
|
||||||
|
if curl -sf "http://localhost:8000/health" > /dev/null; then
|
||||||
|
log_info "Backend restored and healthy"
|
||||||
|
else
|
||||||
|
log_warn "Backend not responding yet; check logs with 'docker-compose logs -f backend'"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Verify backup integrity
|
||||||
|
log_info "Verifying backup integrity..."
|
||||||
|
if tar -tzf "$BACKUP_FILE" > /dev/null 2>&1; then
|
||||||
|
log_info "✓ Backup verified"
|
||||||
|
else
|
||||||
|
log_error "Backup corrupted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Log backup metadata
|
||||||
|
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||||
|
BACKUP_VERSION=$(cat VERSION.json 2>/dev/null | grep version | cut -d'"' -f4 || echo "unknown")
|
||||||
|
log_info "Backup size: $BACKUP_SIZE, Version: $BACKUP_VERSION"
|
||||||
|
|
||||||
|
# 6. Cleanup old backups
|
||||||
|
log_info "Cleaning up backups older than $RETENTION_DAYS days..."
|
||||||
|
find "$BACKUP_DIR" -name "inventory-*.tar.gz" -type f -mtime +$RETENTION_DAYS -delete
|
||||||
|
|
||||||
|
# 7. Create backup manifest
|
||||||
|
cat > "$BACKUP_DIR/MANIFEST.txt" << EOF
|
||||||
|
Backup Metadata
|
||||||
|
===============
|
||||||
|
Timestamp: $TIMESTAMP
|
||||||
|
Type: $BACKUP_TYPE
|
||||||
|
Retention: $RETENTION_DAYS days
|
||||||
|
Size: $BACKUP_SIZE
|
||||||
|
Version: $BACKUP_VERSION
|
||||||
|
Deployment Mode: $([ "$IS_DOCKER" == "true" ] && echo "Docker" || echo "Standalone")
|
||||||
|
Contents: DB, config, certificates (excluding certs)
|
||||||
|
Restored: Not yet
|
||||||
|
|
||||||
|
Command to restore:
|
||||||
|
./restore.sh $BACKUP_FILE
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_info "Backup complete: $BACKUP_FILE"
|
||||||
|
log_info "Retention policy: Delete after $RETENTION_DAYS days"
|
||||||
|
log_info "Next backup: $(date -d '+1 day' '+%Y-%m-%d')"
|
||||||
56
scripts/init_data.sh
Executable file
56
scripts/init_data.sh
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# scripts/init_data.sh
|
||||||
|
# =============================================================================
|
||||||
|
# First-run initialization script for TFM aInventory.
|
||||||
|
# Creates runtime directories and copies configuration templates if missing.
|
||||||
|
#
|
||||||
|
# Called by:
|
||||||
|
# - start_server.sh (local dev / systemd runs)
|
||||||
|
# - backend/entrypoint.sh (Docker container startup)
|
||||||
|
#
|
||||||
|
# Environment variables (with defaults):
|
||||||
|
# DATA_DIR — path to runtime data directory (default: <project_root>/data)
|
||||||
|
# LOGS_DIR — path to runtime logs directory (default: <project_root>/logs)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Resolve project root relative to this script's location
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# Use environment variables if set, otherwise default to in-repo directories
|
||||||
|
DATA_DIR="${DATA_DIR:-$PROJECT_ROOT/data}"
|
||||||
|
LOGS_DIR="${LOGS_DIR:-$PROJECT_ROOT/logs}"
|
||||||
|
|
||||||
|
echo " [init] DATA_DIR = $DATA_DIR"
|
||||||
|
echo " [init] LOGS_DIR = $LOGS_DIR"
|
||||||
|
|
||||||
|
# 1. Create runtime directories (idempotent)
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
mkdir -p "$LOGS_DIR"
|
||||||
|
|
||||||
|
# 2. Copy LDAP config from example template if no active config exists yet
|
||||||
|
# NOTE: The application reads LDAP config from config/ldap_config.json
|
||||||
|
# (see backend/routers/users.py → get_ldap_config())
|
||||||
|
LDAP_CONFIG="$PROJECT_ROOT/config/ldap_config.json"
|
||||||
|
LDAP_EXAMPLE="$PROJECT_ROOT/config/ldap_config.json.example"
|
||||||
|
|
||||||
|
if [ ! -f "$LDAP_CONFIG" ]; then
|
||||||
|
if [ -f "$LDAP_EXAMPLE" ]; then
|
||||||
|
cp "$LDAP_EXAMPLE" "$LDAP_CONFIG"
|
||||||
|
echo " [init] LDAP config copied from example → $LDAP_CONFIG"
|
||||||
|
echo " [init] ⚠️ Edit $LDAP_CONFIG with your real LDAP server settings."
|
||||||
|
else
|
||||||
|
echo " [init] WARNING: No LDAP config example found at $LDAP_EXAMPLE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " [init] LDAP config already exists — skipping copy."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Database schema is created automatically by FastAPI/SQLAlchemy on first
|
||||||
|
# request (see backend/main.py → Base.metadata.create_all). The DATA_DIR
|
||||||
|
# created above ensures the DB file can be written to the correct location.
|
||||||
|
|
||||||
|
echo " [init] Runtime data initialization complete."
|
||||||
140
scripts/restore.sh
Normal file
140
scripts/restore.sh
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Phase 6, Plan 02, Task 2: Restore from Backup
|
||||||
|
# Supports both Docker and Standalone deployment modes
|
||||||
|
# Usage: ./restore.sh <backup_file> [--validate]
|
||||||
|
|
||||||
|
BACKUP_FILE="${1:-}"
|
||||||
|
VALIDATE="${2:---validate}"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
# Validate input
|
||||||
|
[[ -z "$BACKUP_FILE" ]] && log_error "Usage: ./restore.sh <backup_file>"
|
||||||
|
[[ ! -f "$BACKUP_FILE" ]] && log_error "Backup file not found: $BACKUP_FILE"
|
||||||
|
|
||||||
|
log_info "Restoring from: $BACKUP_FILE"
|
||||||
|
|
||||||
|
# Triple confirmation (security requirement)
|
||||||
|
echo "⚠️ WARNING: This will overwrite current data!"
|
||||||
|
read -p "Type 'RESTORE' to confirm (1/3): " confirm1
|
||||||
|
[[ "$confirm1" != "RESTORE" ]] && log_error "Restore cancelled"
|
||||||
|
|
||||||
|
read -p "Type 'RESTORE' again to confirm (2/3): " confirm2
|
||||||
|
[[ "$confirm2" != "RESTORE" ]] && log_error "Restore cancelled"
|
||||||
|
|
||||||
|
read -p "Type 'RESTORE' one more time to confirm (3/3): " confirm3
|
||||||
|
[[ "$confirm3" != "RESTORE" ]] && log_error "Restore cancelled"
|
||||||
|
|
||||||
|
log_warn "Proceeding with restore..."
|
||||||
|
|
||||||
|
# Determine deployment mode
|
||||||
|
if command -v docker-compose &> /dev/null; then
|
||||||
|
log_info "Docker deployment mode detected"
|
||||||
|
IS_DOCKER=true
|
||||||
|
|
||||||
|
# 1. Stop services
|
||||||
|
log_info "Stopping services..."
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# 2. Backup current data (safety copy)
|
||||||
|
log_info "Creating safety backup of current data..."
|
||||||
|
mkdir -p data/backups_before_restore
|
||||||
|
tar -czf "data/backups_before_restore/backup-before-restore-$(date +%s).tar.gz" \
|
||||||
|
data/inventory.db data/inventory.db-* 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. Extract backup
|
||||||
|
log_info "Extracting backup..."
|
||||||
|
tar -xzf "$BACKUP_FILE" -C . || log_error "Backup extraction failed"
|
||||||
|
|
||||||
|
# 4. Verify essential files
|
||||||
|
log_info "Verifying restored files..."
|
||||||
|
[[ -f "data/inventory.db" ]] || log_error "Database not found in backup"
|
||||||
|
[[ -f "inventory.env" ]] || log_error "inventory.env not found in backup"
|
||||||
|
|
||||||
|
# 5. Restart services
|
||||||
|
log_info "Restarting services..."
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 6. Wait for health
|
||||||
|
log_info "Waiting for services to be healthy..."
|
||||||
|
max_attempts=30
|
||||||
|
attempt=0
|
||||||
|
while [[ $attempt -lt $max_attempts ]]; do
|
||||||
|
if curl -sf "http://localhost:8000/health" > /dev/null 2>&1; then
|
||||||
|
log_info "Services healthy!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $attempt -eq $max_attempts ]]; then
|
||||||
|
log_warn "Services did not become healthy within 60 seconds"
|
||||||
|
docker-compose logs backend
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Validation tests (if requested)
|
||||||
|
if [[ "$VALIDATE" == "--validate" ]]; then
|
||||||
|
log_info "Running validation tests..."
|
||||||
|
|
||||||
|
# Test 1: Database accessible
|
||||||
|
DB_ITEMS=$(docker-compose exec backend sqlite3 /app/data/inventory.db \
|
||||||
|
"SELECT COUNT(*) FROM items" 2>/dev/null || echo "0")
|
||||||
|
log_info "✓ Database has $DB_ITEMS items"
|
||||||
|
|
||||||
|
# Test 2: API responsive
|
||||||
|
if curl -sf "http://localhost:8000/health" > /dev/null; then
|
||||||
|
log_info "✓ API health check passed"
|
||||||
|
else
|
||||||
|
log_error "API health check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: Frontend loads
|
||||||
|
if curl -sf "http://localhost:3000" > /dev/null 2>&1; then
|
||||||
|
log_info "✓ Frontend loads"
|
||||||
|
else
|
||||||
|
log_warn "⚠ Frontend check failed (normal on first startup)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
log_info "Standalone deployment mode detected"
|
||||||
|
IS_DOCKER=false
|
||||||
|
|
||||||
|
# 1. Kill any running servers
|
||||||
|
log_info "Stopping running services..."
|
||||||
|
pkill -f "next start" || true
|
||||||
|
pkill -f "uvicorn" || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# 2. Backup current data
|
||||||
|
log_info "Creating safety backup of current data..."
|
||||||
|
mkdir -p data/backups_before_restore
|
||||||
|
tar -czf "data/backups_before_restore/backup-before-restore-$(date +%s).tar.gz" \
|
||||||
|
data/inventory.db data/inventory.db-* 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. Extract backup
|
||||||
|
log_info "Extracting backup..."
|
||||||
|
tar -xzf "$BACKUP_FILE" -C . || log_error "Backup extraction failed"
|
||||||
|
|
||||||
|
# 4. Verify essential files
|
||||||
|
log_info "Verifying restored files..."
|
||||||
|
[[ -f "data/inventory.db" ]] || log_error "Database not found in backup"
|
||||||
|
[[ -f "inventory.env" ]] || log_error "inventory.env not found in backup"
|
||||||
|
|
||||||
|
log_info "✓ Restore complete"
|
||||||
|
log_warn "Remember to start services: ./start_server.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Restore complete!"
|
||||||
|
log_warn "Remember to verify data in the application before returning to production"
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
TFM aInventory - Production Restore Script (v1.12.0)
|
|
||||||
Converted from restore.sh to Python per Decision D-05.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import yaml
|
|
||||||
import argparse
|
|
||||||
import tarfile
|
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
# Color codes
|
|
||||||
class Colors:
|
|
||||||
RED = '\033[0;31m'
|
|
||||||
GREEN = '\033[0;32m'
|
|
||||||
YELLOW = '\033[1;33m'
|
|
||||||
BLUE = '\033[0;34m'
|
|
||||||
NC = '\033[0m'
|
|
||||||
|
|
||||||
# Setup logging
|
|
||||||
logging.basicConfig(level=logging.INFO, format=f"{Colors.BLUE}[INFO]{Colors.NC} %(message)s")
|
|
||||||
logger = logging.getLogger("restore_prod")
|
|
||||||
|
|
||||||
def load_yaml(file_path: str) -> Dict[str, Any]:
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
with open(file_path, 'r') as f:
|
|
||||||
return yaml.safe_load(f) or {}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to parse {file_path}: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def confirm_action(prompt: str, required_text: str = "RESTORE") -> bool:
|
|
||||||
print(f"{Colors.YELLOW}⚠️ WARNING: {prompt}{Colors.NC}")
|
|
||||||
for i in range(1, 4):
|
|
||||||
response = input(f"Type '{required_text}' to confirm ({i}/3): ")
|
|
||||||
if response != required_text:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def is_docker_running() -> bool:
|
|
||||||
try:
|
|
||||||
subprocess.run(["docker", "ps"], capture_output=True, check=True)
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="TFM aInventory Production Restore Tool")
|
|
||||||
parser.add_argument("backup_file", help="Path to the tar.gz backup file")
|
|
||||||
parser.add_argument("--force", action="store_true", help="Skip confirmation (DANGEROUS)")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if not os.path.exists(args.backup_file):
|
|
||||||
logger.error(f"Backup file not found: {args.backup_file}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not args.force:
|
|
||||||
if not confirm_action(f"This will overwrite current data with content from {args.backup_file}!"):
|
|
||||||
logger.error("Restore cancelled by user.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Load config to find data directory
|
|
||||||
backend_cfg = load_yaml("config/backend.yaml")
|
|
||||||
app_cfg = backend_cfg.get("application", {})
|
|
||||||
data_dir = app_cfg.get("data_dir", "./data")
|
|
||||||
|
|
||||||
# 1. Detect deployment mode and stop services
|
|
||||||
docker_mode = os.path.exists("docker-compose.yml") and is_docker_running()
|
|
||||||
|
|
||||||
if docker_mode:
|
|
||||||
logger.info("Docker mode detected. Stopping services...")
|
|
||||||
subprocess.run(["docker-compose", "down"], check=True)
|
|
||||||
else:
|
|
||||||
logger.info("Standalone mode detected. Ensuring services are stopped...")
|
|
||||||
# Try to use run_standalone.py if available
|
|
||||||
launcher = "scripts/run_standalone.py"
|
|
||||||
if os.path.exists(launcher):
|
|
||||||
subprocess.run([sys.executable, launcher, "stop"], capture_output=True)
|
|
||||||
else:
|
|
||||||
# Fallback to pkill
|
|
||||||
subprocess.run(["pkill", "-f", "uvicorn"], capture_output=True)
|
|
||||||
subprocess.run(["pkill", "-f", "next start"], capture_output=True)
|
|
||||||
|
|
||||||
# 2. Safety backup of current data
|
|
||||||
safety_dir = os.path.join(data_dir, "backups_before_restore")
|
|
||||||
os.makedirs(safety_dir, exist_ok=True)
|
|
||||||
safety_backup = os.path.join(safety_dir, f"pre_restore_{int(time.time())}.tar.gz")
|
|
||||||
|
|
||||||
logger.info(f"Creating safety backup of current data: {safety_backup}")
|
|
||||||
try:
|
|
||||||
with tarfile.open(safety_backup, "w:gz") as tar:
|
|
||||||
if os.path.exists(data_dir):
|
|
||||||
tar.add(data_dir)
|
|
||||||
if os.path.exists("config"):
|
|
||||||
tar.add("config")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Safety backup failed (continuing anyway): {e}")
|
|
||||||
|
|
||||||
# 3. Extract backup
|
|
||||||
logger.info(f"Extracting {args.backup_file}...")
|
|
||||||
try:
|
|
||||||
with tarfile.open(args.backup_file, "r:gz") as tar:
|
|
||||||
tar.extractall(path=".")
|
|
||||||
logger.info(f"{Colors.GREEN}✓ Extraction successful!{Colors.NC}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Extraction failed: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 4. Verification
|
|
||||||
db_path = os.path.join(data_dir, "inventory.db")
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
logger.error(f"CRITICAL: Database not found at {db_path} after restore!")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 5. Restart services
|
|
||||||
if docker_mode:
|
|
||||||
logger.info("Restarting Docker services...")
|
|
||||||
subprocess.run(["docker-compose", "up", "-d"], check=True)
|
|
||||||
logger.info(f"{Colors.GREEN}✓ Services started. Check 'docker-compose ps' for status.{Colors.NC}")
|
|
||||||
else:
|
|
||||||
logger.info(f"{Colors.YELLOW}Restore complete. Please start services manually using run_standalone.py{Colors.NC}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -344,35 +344,6 @@ def action_stop(backend_port: int, frontend_port: int, network_cfg: Dict[str, An
|
|||||||
|
|
||||||
logger.info("Stopped.")
|
logger.info("Stopped.")
|
||||||
|
|
||||||
def generate_network_json(network_cfg: Dict[str, Any]):
|
|
||||||
"""Sync network.yaml settings to frontend/public/network.json for runtime discovery."""
|
|
||||||
server_ip = network_cfg.get("application", {}).get("server_ip", "localhost")
|
|
||||||
ports = network_cfg.get("ports", {})
|
|
||||||
|
|
||||||
network_json = {
|
|
||||||
"SERVER_IP": server_ip,
|
|
||||||
"BACKEND_PORT": ports.get("backend_port", 8916),
|
|
||||||
"BACKEND_SSL_PORT": ports.get("backend_ssl_port", 8918),
|
|
||||||
"FRONTEND_PORT": ports.get("frontend_port", 8917),
|
|
||||||
"FRONTEND_SSL_PORT": ports.get("frontend_ssl_port", 8919)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to find the frontend/public directory
|
|
||||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
public_dir = os.path.join(project_root, "frontend", "public")
|
|
||||||
|
|
||||||
if os.path.isdir(public_dir):
|
|
||||||
target_path = os.path.join(public_dir, "network.json")
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
with open(target_path, 'w') as f:
|
|
||||||
json.dump(network_json, f, indent=2)
|
|
||||||
logger.info(f"Generated {target_path} from network.yaml")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to generate network.json: {e}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Frontend public directory not found at {public_dir}. Skipping network.json generation.")
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="TFM aInventory Standalone Launcher")
|
parser = argparse.ArgumentParser(description="TFM aInventory Standalone Launcher")
|
||||||
parser.add_argument("action", nargs="?", choices=["start", "stop", "restart", "status", "run"], default="run",
|
parser.add_argument("action", nargs="?", choices=["start", "stop", "restart", "status", "run"], default="run",
|
||||||
@@ -409,9 +380,6 @@ def main():
|
|||||||
if args.action in ["start", "run"]:
|
if args.action in ["start", "run"]:
|
||||||
background = (args.action == "start")
|
background = (args.action == "start")
|
||||||
|
|
||||||
# Generate network.json for frontend discovery before starting anything
|
|
||||||
generate_network_json(network_cfg)
|
|
||||||
|
|
||||||
if not args.frontend_only:
|
if not args.frontend_only:
|
||||||
if is_port_in_use(backend_port):
|
if is_port_in_use(backend_port):
|
||||||
logger.error(f"Port {backend_port} is already in use. Backend cannot start.")
|
logger.error(f"Port {backend_port} is already in use. Backend cannot start.")
|
||||||
|
|||||||
241
start_server.sh
Executable file
241
start_server.sh
Executable file
@@ -0,0 +1,241 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Phase 6, Plan 1: Standalone Server Start Script
|
||||||
|
# Usage: ./start_server.sh
|
||||||
|
# Purpose: Start backend and frontend servers without Docker
|
||||||
|
# Requirements: Python 3.12+, Node.js 20+, and dependencies installed
|
||||||
|
|
||||||
|
# Color output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||||
|
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
log_info "=== TFM aInventory Standalone Server Start ==="
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STEP 1: Pre-flight checks
|
||||||
|
# ============================================================================
|
||||||
|
log_info "Step 1/7: Running pre-flight checks..."
|
||||||
|
|
||||||
|
# Check Python version
|
||||||
|
command -v python3 &> /dev/null || log_error "Python 3 not installed"
|
||||||
|
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
|
||||||
|
log_success " ✓ Python $PYTHON_VERSION found"
|
||||||
|
|
||||||
|
# Check Node.js version
|
||||||
|
command -v node &> /dev/null || log_error "Node.js not installed"
|
||||||
|
NODE_VERSION=$(node --version)
|
||||||
|
log_success " ✓ Node.js $NODE_VERSION found"
|
||||||
|
|
||||||
|
# Check required files
|
||||||
|
[[ -f "inventory.env" ]] || log_error "inventory.env not found. Copy from inventory.env.template"
|
||||||
|
log_success " ✓ inventory.env found"
|
||||||
|
|
||||||
|
[[ -d "backend" ]] || log_error "backend/ directory not found"
|
||||||
|
log_success " ✓ backend/ directory found"
|
||||||
|
|
||||||
|
[[ -d "frontend" ]] || log_error "frontend/ directory not found"
|
||||||
|
log_success " ✓ frontend/ directory found"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STEP 2: Load environment
|
||||||
|
# ============================================================================
|
||||||
|
log_info "Step 2/7: Loading environment configuration..."
|
||||||
|
|
||||||
|
source inventory.env
|
||||||
|
|
||||||
|
BACKEND_PORT=${BACKEND_PORT:-8000}
|
||||||
|
FRONTEND_PORT=${FRONTEND_PORT:-3000}
|
||||||
|
DATA_DIR=${DATA_DIR:-./data}
|
||||||
|
LOGS_DIR=${LOGS_DIR:-./logs}
|
||||||
|
LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
|
||||||
|
log_success " ✓ Environment loaded (Backend: $BACKEND_PORT, Frontend: $FRONTEND_PORT)"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STEP 3: Prepare directories
|
||||||
|
# ============================================================================
|
||||||
|
log_info "Step 3/7: Preparing data and log directories..."
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR" "$LOGS_DIR"
|
||||||
|
mkdir -p "$DATA_DIR/temp"
|
||||||
|
chmod -R 755 "$DATA_DIR" "$LOGS_DIR"
|
||||||
|
|
||||||
|
log_success " ✓ Directories ready"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STEP 4: Check port availability
|
||||||
|
# ============================================================================
|
||||||
|
log_info "Step 4/7: Checking port availability..."
|
||||||
|
|
||||||
|
for port in $BACKEND_PORT $FRONTEND_PORT; do
|
||||||
|
if command -v netstat &> /dev/null; then
|
||||||
|
if netstat -tuln 2>/dev/null | grep -q ":$port "; then
|
||||||
|
log_error "Port $port is already in use. Stop the application using it or change the port."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warn " netstat not available; skipping port check"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_success " ✓ Required ports are available"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STEP 5: Start backend server
|
||||||
|
# ============================================================================
|
||||||
|
log_info "Step 5/7: Starting backend API server..."
|
||||||
|
|
||||||
|
# Create backend log file
|
||||||
|
BACKEND_LOG="$LOGS_DIR/backend.log"
|
||||||
|
touch "$BACKEND_LOG"
|
||||||
|
|
||||||
|
# Start backend in background
|
||||||
|
cd backend || log_error "Failed to enter backend directory"
|
||||||
|
|
||||||
|
if [[ ! -f "requirements.txt" ]]; then
|
||||||
|
log_error "backend/requirements.txt not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create and use Python virtual environment in project root
|
||||||
|
VENV_DIR=".venv"
|
||||||
|
if [[ ! -d "$VENV_DIR" ]]; then
|
||||||
|
log_info " Creating Python virtual environment..."
|
||||||
|
cd - > /dev/null || true # Return to project root
|
||||||
|
python3 -m venv "$VENV_DIR" || log_error "Failed to create virtual environment"
|
||||||
|
cd backend || log_error "Failed to enter backend directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate virtual environment (from project root)
|
||||||
|
source "../$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
|
# Install dependencies if needed
|
||||||
|
if ! python3 -c "import fastapi" &> /dev/null; then
|
||||||
|
log_info " Installing Python dependencies..."
|
||||||
|
pip install -q -r requirements.txt || log_error "Failed to install backend dependencies"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize database if needed
|
||||||
|
if [[ ! -f "../$DATA_DIR/inventory.db" ]]; then
|
||||||
|
log_info " Initializing database..."
|
||||||
|
python3 -c "from db_manager import init_db; init_db()" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start uvicorn in background (with proper working directory)
|
||||||
|
log_info " Starting uvicorn server..."
|
||||||
|
(cd "$(pwd)" && python3 -m uvicorn main:app --host 0.0.0.0 --port "$BACKEND_PORT" --log-level "${LOG_LEVEL,,}" >> "$BACKEND_LOG" 2>&1) &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
log_success " ✓ Backend started (PID: $BACKEND_PID, Port: $BACKEND_PORT)"
|
||||||
|
|
||||||
|
cd - > /dev/null || true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STEP 6: Start frontend server
|
||||||
|
# ============================================================================
|
||||||
|
log_info "Step 6/7: Starting frontend web server..."
|
||||||
|
|
||||||
|
FRONTEND_LOG="$LOGS_DIR/frontend.log"
|
||||||
|
touch "$FRONTEND_LOG"
|
||||||
|
|
||||||
|
cd frontend || log_error "Failed to enter frontend directory"
|
||||||
|
|
||||||
|
if [[ ! -f "package.json" ]]; then
|
||||||
|
log_error "frontend/package.json not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies if needed
|
||||||
|
if [[ ! -d "node_modules" ]]; then
|
||||||
|
log_info " Installing npm dependencies..."
|
||||||
|
npm ci --silent || log_error "Failed to install frontend dependencies"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build Next.js if not already built
|
||||||
|
if [[ ! -d ".next" ]]; then
|
||||||
|
log_info " Building Next.js application..."
|
||||||
|
npm run build || log_error "Failed to build frontend"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Next.js in production mode (with proper working directory)
|
||||||
|
log_info " Starting Next.js server..."
|
||||||
|
FRONTEND_DIR="$(pwd)"
|
||||||
|
(cd "$FRONTEND_DIR" && PORT="$FRONTEND_PORT" node .next/standalone/server.js >> "$FRONTEND_LOG" 2>&1) &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
log_success " ✓ Frontend started (PID: $FRONTEND_PID, Port: $FRONTEND_PORT)"
|
||||||
|
|
||||||
|
cd - > /dev/null || true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STEP 7: Verify services
|
||||||
|
# ============================================================================
|
||||||
|
log_info "Step 7/7: Verifying services..."
|
||||||
|
|
||||||
|
# Wait a few seconds for services to start
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Check backend
|
||||||
|
if curl -sf "http://localhost:${BACKEND_PORT}/health" &> /dev/null; then
|
||||||
|
log_success " ✓ Backend API responding at http://localhost:${BACKEND_PORT}/health"
|
||||||
|
else
|
||||||
|
log_warn " Backend not responding yet; it may still be initializing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check frontend
|
||||||
|
if curl -sf "http://localhost:${FRONTEND_PORT}/" &> /dev/null; then
|
||||||
|
log_success " ✓ Frontend responding at http://localhost:${FRONTEND_PORT}"
|
||||||
|
else
|
||||||
|
log_warn " Frontend not responding yet; it may still be initializing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Summary and instructions
|
||||||
|
# ============================================================================
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║${NC} Standalone servers started successfully! ${GREEN}║${NC}"
|
||||||
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Access points:"
|
||||||
|
echo " Frontend: http://localhost:${FRONTEND_PORT}"
|
||||||
|
echo " Backend: http://localhost:${BACKEND_PORT}"
|
||||||
|
echo " API Docs: http://localhost:${BACKEND_PORT}/docs"
|
||||||
|
echo ""
|
||||||
|
echo "Process IDs:"
|
||||||
|
echo " Backend: $BACKEND_PID"
|
||||||
|
echo " Frontend: $FRONTEND_PID"
|
||||||
|
echo ""
|
||||||
|
echo "Log files:"
|
||||||
|
echo " Backend: $BACKEND_LOG"
|
||||||
|
echo " Frontend: $FRONTEND_LOG"
|
||||||
|
echo ""
|
||||||
|
echo "Monitoring:"
|
||||||
|
echo " Backend: tail -f $BACKEND_LOG"
|
||||||
|
echo " Frontend: tail -f $FRONTEND_LOG"
|
||||||
|
echo " All logs: tail -f $LOGS_DIR/*.log"
|
||||||
|
echo ""
|
||||||
|
echo "To stop servers:"
|
||||||
|
echo " kill $BACKEND_PID $FRONTEND_PID"
|
||||||
|
echo ""
|
||||||
|
log_success "Servers are running!"
|
||||||
|
|
||||||
|
# Keep script running indefinitely and handle signals
|
||||||
|
trap "log_info 'Received interrupt signal'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true; exit 0" SIGINT SIGTERM
|
||||||
|
|
||||||
|
# Monitor processes - restart if they crash
|
||||||
|
while true; do
|
||||||
|
if ! kill -0 $BACKEND_PID 2>/dev/null; then
|
||||||
|
log_warn "Backend process died (PID $BACKEND_PID), monitoring stopped"
|
||||||
|
fi
|
||||||
|
if ! kill -0 $FRONTEND_PID 2>/dev/null; then
|
||||||
|
log_warn "Frontend process died (PID $FRONTEND_PID), monitoring stopped"
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user