Compare commits

..

4 Commits

Author SHA1 Message Date
Claude Agent
ac8a01eb3e chore: add .playwright-mcp to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:21:17 +00:00
Claude Agent
c4fa643eca feat(sync): add order_total field to SQLite tracking
Parse order total from GoMag JSON, store in SQLite orders table,
and expose via sync run API. Enables total display in mobile flat rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:20:57 +00:00
Claude Agent
9a6bec33ff docs: rewrite CLAUDE.md and README.md, remove VFP references
- Remove all Visual FoxPro references (VFP fully replaced by Python)
- Add TeamCreate workflow for parallel UI development
- Document before/preview/after visual verification with Playwright
- Add mandatory rule: use TeamCreate, not superpowers subagents
- Update architecture, tech stack, project structure to current state
- Update import flow to reference Python services

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:20:36 +00:00
Claude Agent
680f670037 feat(ui): mobile UI polish with segmented controls and responsive navbar
- Replace filter pills with btn-group segmented controls on mobile (all pages)
- Add renderMobileSegmented() shared utility with colored count badges
- Compact sync card and logs run selector on mobile
- Unified flat-row format: dot + date + name + count (0.875rem throughout)
- Responsive navbar with short labels on mobile (Acasa/Mapari/Lipsa/Jurnale)
- Vertical dots icon (bi-three-dots-vertical) without dropdown caret
- Shorter "Mapare" button text on mobile, Re-scan in context menu
- Top pagination on logs page, hide per-page selector on mobile
- Cache-bust static assets to v=5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:20:24 +00:00
18 changed files with 1069 additions and 820 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@
*.err *.err
*.ERR *.ERR
*.log *.log
/screenshots
/.playwright-mcp
# Python # Python
__pycache__/ __pycache__/

326
CLAUDE.md
View File

@@ -1,270 +1,128 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## REGULI OBLIGATORII
**Pentru task-uri paralele foloseste INTOTDEAUNA TeamCreate + TaskCreate, NU Agent tool cu subagenti paraleli.**
Skill-ul `superpowers:dispatching-parallel-agents` NU se aplica in acest proiect. In loc de dispatch cu Agent tool, creeaza o echipa cu TeamCreate, defineste task-uri cu TaskCreate, si spawneaza teammates cu Agent tool + `team_name`.
## Project Overview ## Project Overview
**System:** Import Comenzi Web → Sistem ROA Oracle **System:** Import Comenzi Web GoMag → Sistem ROA Oracle
This is a multi-tier system that automatically imports orders from web platforms (GoMag, etc.) into the ROA Oracle ERP system. The project combines Oracle PL/SQL packages, Visual FoxPro orchestration, and a FastAPI web admin/dashboard interface. Importa automat comenzi din GoMag in sistemul ERP ROA Oracle. Stack complet Python/FastAPI.
**Current Status:** Phase 4 Complete, Phase 5 In Progress
- ✅ Phase 1: Database Foundation (ARTICOLE_TERTI, IMPORT_PARTENERI, IMPORT_COMENZI)
- ✅ Phase 2: VFP Integration (gomag-vending.prg, sync-comenzi-web.prg)
- ✅ Phase 3-4: FastAPI Admin + Dashboard (mappings CRUD, sync orchestration, pre-validation)
- 🔄 Phase 5: Production (file logging done, auth + notifications pending)
## Architecture
```
[Web Platform API] → [VFP Orchestrator] → [Oracle PL/SQL] → [Web Admin Interface]
↓ ↓ ↑ ↑
JSON Orders Process & Log Store/Update Configuration
```
### Tech Stack ### Tech Stack
- **Backend:** Oracle PL/SQL packages - **API + Admin:** FastAPI + Jinja2 + Bootstrap 5.3
- **Integration:** Visual FoxPro 9 - **GoMag Integration:** Python (`gomag_client.py` — API download with pagination)
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite - **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
- **Data:** Oracle 11g/12c (ROA system), SQLite (local tracking) - **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking)
## Core Components
### Oracle PL/SQL Packages
#### 1. IMPORT_PARTENERI Package
**Location:** `api/database-scripts/02_import_parteneri.sql`
**Functions:**
- `cauta_sau_creeaza_partener()` - Search/create partners with priority: cod_fiscal → denumire → create new
- `parseaza_adresa_semicolon()` - Parse addresses in format "JUD:București;BUCURESTI;Str.Victoriei;10"
**Logic:**
- Individual vs company detection (CUI 13 digits)
- Automatic address defaults to București Sectorul 1
- All new partners get ID_UTIL = -3 (system)
#### 2. IMPORT_COMENZI Package
**Location:** `api/database-scripts/03_import_comenzi.sql`
**Functions:**
- `gaseste_articol_roa()` - Complex SKU mapping with pipelined functions
- `importa_comanda_web()` - Complete order import with JSON parsing
**Mapping Types:**
- Simple: SKU found directly in nom_articole (not stored in ARTICOLE_TERTI)
- Repackaging: SKU → CODMAT with different quantities
- Complex sets: One SKU → multiple CODMATs with percentage pricing
### Visual FoxPro Integration
#### gomag-vending.prg
**Location:** `vfp/gomag-vending.prg`
Current functionality:
- GoMag API integration with pagination
- JSON data retrieval and processing
- HTML entity cleaning (ă→a, ș→s, ț→t, î→i, â→a)
**Future:** Will be adapted for JSON output to Oracle packages
#### sync-comenzi-web.prg (Phase 2)
**Planned orchestrator with:**
- 5-minute timer automation
- Oracle package integration
- Comprehensive logging system
- Error handling and retry logic
### Database Schema
#### ARTICOLE_TERTI Table
**Location:** `api/database-scripts/01_create_table.sql`
```sql
CREATE TABLE ARTICOLE_TERTI (
sku VARCHAR2(100), -- SKU from web platform
codmat VARCHAR2(50), -- CODMAT from nom_articole
cantitate_roa NUMBER(10,3), -- ROA units per web unit
procent_pret NUMBER(5,2), -- Price percentage for sets
activ NUMBER(1), -- 1=active, 0=inactive
PRIMARY KEY (sku, codmat)
);
```
### FastAPI Admin/Dashboard
#### app/main.py
**Location:** `api/app/main.py`
**Features:**
- FastAPI with lifespan (Oracle pool + SQLite init)
- File logging to `logs/sync_comenzi_YYYYMMDD_HHMMSS.log`
- Routers: health, dashboard, mappings, articles, validation, sync
- Services: mapping, article, import, sync, validation, order_reader, sqlite, scheduler
- Templates: Jinja2 (dashboard, mappings, sync_detail, missing_skus)
- Dual database: Oracle (ERP data) + SQLite (tracking)
- APScheduler for periodic sync
## Development Commands ## Development Commands
### Database Setup
```bash ```bash
# Start Oracle container # Run FastAPI server
docker-compose up -d cd api && uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
# Run database scripts in order # Tests
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @01_create_table.sql
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @02_import_parteneri.sql
sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @03_import_comenzi.sql
```
### VFP Development
```foxpro
DO vfp/gomag-vending.prg
```
### FastAPI Admin/Dashboard
```bash
cd api
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
```
### Testare
```bash
python api/test_app_basic.py # Test A - fara Oracle python api/test_app_basic.py # Test A - fara Oracle
python api/test_integration.py # Test C - cu Oracle python api/test_integration.py # Test C - cu Oracle
``` ```
## Project Structure ## UI Development Workflow: Before → Preview → After
For UI/frontend changes, follow this visual verification workflow:
### 1. Before Screenshots
Capture current state with Playwright MCP at target viewports:
- **Mobile:** 375x812
- **Desktop:** 1440x900
Save to `screenshots/before/`
### 2. Plan & Preview
- Write implementation plan with design decisions
- Generate preview mockups if needed → save to `screenshots/preview/`
- Get user approval on previews before implementation
### 3. Implementation cu TeamCreate (Agent Teams)
Folosim **TeamCreate** (team agents), NU superpowers subagents. Diferenta:
- **TeamCreate**: agenti independenti cu task list partajat, comunicare directa intre ei, context propriu
- **Subagents (Agent tool)**: agenti care raporteaza doar la main — NU se folosesc
#### Workflow TeamCreate:
1. **Main agent** (team lead) citeste TOATE fisierele implicate, creeaza planul
2. **TeamCreate** creeaza echipa (ex: `ui-polish`)
3. **TaskCreate** creeaza task-uri independente, pe fisiere non-overlapping:
- Task 1: Templates + CSS (HTML templates, style.css, cache-bust)
- Task 2: JavaScript (shared.js, dashboard.js, logs.js, mappings.js)
- Task 3: Verificare Playwright (depinde de Task 1 + Task 2)
4. **Agent tool** cu `team_name` spawneaza teammates care isi iau task-uri din lista
5. Teammates lucreaza in paralel, comunica intre ei, marcheaza task-uri completate
6. Cand Task 1 + Task 2 sunt complete, teammate-ul de verificare preia Task 3
#### Teammate-ul de verificare (Task 3):
1. Navigheaza la fiecare pagina cu Playwright MCP la 375x812 (mobile) si 1440x900 (desktop)
2. Screenshot-uri → `screenshots/after/`
3. Compara `after/` vs `preview/` vizual
4. Raporteaza discrepante la team lead
5. Verifica ca desktop-ul ramane neschimbat
``` ```
/ screenshots/
├── api/ # ✅ Flask Admin & Database ├── before/ # Starea inainte de modificari
│ ├── admin.py # ✅ Flask app with Oracle pool ├── preview/ # Mockup-uri aprobate de user
│ ├── database-scripts/ # ✅ Oracle SQL scripts └── after/ # Verificare post-implementare
│ │ ├── 01_create_table.sql # ✅ ARTICOLE_TERTI table
│ │ ├── 02_import_parteneri.sql # ✅ Partners package
│ │ └── 03_import_comenzi.sql # ✅ Orders package
│ ├── Dockerfile # ✅ Oracle client container
│ ├── tnsnames.ora # ✅ Oracle connection config
│ ├── .env # ✅ Environment variables
│ └── requirements.txt # ✅ Python dependencies
├── docs/ # 📋 Project Documentation
│ ├── PRD.md # ✅ Product Requirements
│ ├── LLM_PROJECT_MANAGER_PROMPT.md # ✅ Project Management
│ └── stories/ # 📋 User Stories
│ ├── P1-001-ARTICOLE_TERTI.md # ✅ Story P1-001 (COMPLETE)
│ ├── P1-002-Package-IMPORT_PARTENERI.md # ✅ Story P1-002 (COMPLETE)
│ ├── P1-003-Package-IMPORT_COMENZI.md # ✅ Story P1-003 (COMPLETE)
│ └── P1-004-Testing-Manual-Packages.md # 📋 Story P1-004 (READY)
├── vfp/ # ⏳ VFP Integration
│ ├── gomag-vending.prg # ✅ Current GoMag client
│ ├── utils.prg # ✅ Utility functions
│ ├── nfjson/ # ✅ JSON parsing library
│ └── sync-comenzi-web.prg # ⏳ Future orchestrator
├── docker-compose.yaml # ✅ Container setup
└── logs/ # ✅ Application logs
``` ```
## Configuration ### Principii
- Team lead citeste TOATE fisierele inainte sa creeze task-uri
- Task-uri pe fisiere non-overlapping (evita conflicte)
- Fiecare task contine prompt detaliat, self-contained
- Desktop-ul nu trebuie sa se schimbe cand se adauga imbunatatiri mobile
- Cache-bust static assets (increment `?v=N`) la fiecare schimbare UI
- Teammates comunica intre ei cu SendMessage, nu doar cu team lead-ul
### Environment Variables (.env) ## Architecture
```env
ORACLE_USER=CONTAFIN_ORACLE ```
ORACLE_PASSWORD=******** [GoMag API] → [Python Sync Service] → [Oracle PL/SQL] → [FastAPI Admin]
ORACLE_DSN=ROA_ROMFAST ↓ ↓ ↑ ↑
TNS_ADMIN=/app JSON Orders Download/Parse/Import Store/Update Dashboard + Config
INSTANTCLIENTPATH=/opt/oracle/instantclient
``` ```
### Business Rules ### FastAPI App Structure
- **Routers:** health, dashboard, mappings, articles, validation, sync
- **Services:** gomag_client, sync, order_reader, import, mapping, article, validation, invoice, sqlite, scheduler
- **Templates:** Jinja2 (dashboard, mappings, missing_skus, logs)
- **Static:** CSS (`style.css`), JS (`shared.js`, `dashboard.js`, `logs.js`, `mappings.js`)
- **Databases:** Oracle (ERP data) + SQLite (order tracking, sync runs)
#### Partners ## Business Rules
### Partners
- Search priority: cod_fiscal → denumire → create new - Search priority: cod_fiscal → denumire → create new
- Individuals (CUI 13 digits): separate nume/prenume - Individuals (CUI 13 digits): separate nume/prenume
- Default address: București Sectorul 1 - Default address: Bucuresti Sectorul 1
- All new partners: ID_UTIL = -3 - All new partners: ID_UTIL = -3
#### Articles ### Articles & Mappings
- Simple SKUs: found directly in nom_articole (not stored) - Simple SKUs: found directly in nom_articole (not stored in ARTICOLE_TERTI)
- Special mappings: only repackaging and complex sets - Repackaging: SKU → CODMAT with different quantities
- Inactive articles: activ=0 (not deleted) - Complex sets: One SKU → multiple CODMATs with percentage pricing (must sum to 100%)
- Inactive articles: activ=0 (soft delete)
#### Orders ### Orders
- Uses existing PACK_COMENZI packages
- Default: ID_GESTIUNE=1, ID_SECTIE=1, ID_POL=0 - Default: ID_GESTIUNE=1, ID_SECTIE=1, ID_POL=0
- Delivery date = order date + 1 day - Delivery date = order date + 1 day
- All orders: INTERNA=0 (external) - All orders: INTERNA=0 (external)
## Phase Implementation Status ## Configuration
### ✅ Phase 1: Database Foundation (75% Complete) ```bash
- **P1-001:** ✅ ARTICOLE_TERTI table + Docker setup # .env
- **P1-002:** ✅ IMPORT_PARTENERI package complete ORACLE_USER=CONTAFIN_ORACLE
- **P1-003:** ✅ IMPORT_COMENZI package complete ORACLE_PASSWORD=********
- **P1-004:** 🔄 Manual testing (READY TO START) ORACLE_DSN=ROA_ROMFAST
TNS_ADMIN=/app
### ⏳ Phase 2: VFP Integration (Planned)
- Adapt gomag-vending.prg for JSON output
- Create sync-comenzi-web.prg orchestrator
- Oracle packages integration
- Logging system with rotation
### ⏳ Phase 3: Web Admin Interface (Planned)
- Flask app with Oracle connection pool
- HTML/CSS admin interface
- JavaScript CRUD operations
- Client/server-side validation
### ⏳ Phase 4: Testing & Deployment (Planned)
- End-to-end testing with real orders
- Complex mappings validation
- Production environment setup
- User documentation
## Key Functions
### Oracle Packages
- `IMPORT_PARTENERI.cauta_sau_creeaza_partener()` - Partner management
- `IMPORT_PARTENERI.parseaza_adresa_semicolon()` - Address parsing
- `IMPORT_COMENZI.gaseste_articol_roa()` - SKU resolution
- `IMPORT_COMENZI.importa_comanda_web()` - Order import
### VFP Utilities (utils.prg)
- `LoadSettings` - INI configuration management
- `InitLog`/`LogMessage`/`CloseLog` - Logging system
- `TestConnectivity` - Connection verification
- `CreateDefaultIni` - Default configuration
## Success Metrics
### Technical KPIs
- Import success rate > 95%
- Average processing time < 30s per order
- Zero downtime for main ROA system
- 100% log coverage
### Business KPIs
- 90% reduction in manual order entry time
- Elimination of manual transcription errors
- New mapping configuration < 5 minutes
## Error Handling
### Categories
1. **Oracle connection errors:** Retry logic + alerts
2. **SKU not found:** Log warning + skip item
3. **Invalid partner:** Create attempt + detailed log
4. **Duplicate orders:** Skip with info log
### Logging Format
``` ```
2025-09-09 14:30:25 | ORDER-123 | OK | ID:456789
2025-09-09 14:30:26 | ORDER-124 | ERROR | SKU 'XYZ' not found
```
## Project Manager Commands
Available commands for project tracking:
- `status` - Overall progress and current story
- `stories` - List all stories with status
- `phase` - Current phase details
- `risks` - Identify and prioritize risks
- `demo [story-id]` - Demonstrate implemented functionality
- `plan` - Re-planning for changes

View File

@@ -5,16 +5,16 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
## Arhitectura ## Arhitectura
``` ```
[GoMag API] → [VFP Orchestrator] → [Oracle PL/SQL] → [FastAPI Admin] [GoMag API] → [Python Sync Service] → [Oracle PL/SQL] → [FastAPI Admin]
↑ ↑ ↑ ↑
JSON Orders Process & Log Store/Update Dashboard + Config JSON Orders Download/Parse/Import Store/Update Dashboard + Config
``` ```
### Stack Tehnologic ### Stack Tehnologic
- **Database:** Oracle PL/SQL packages (PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI) - **API + Admin:** FastAPI + Jinja2 + Bootstrap 5.3
- **Integrare:** Visual FoxPro 9 (gomag-vending.prg, sync-comenzi-web.prg) - **GoMag Integration:** Python (`gomag_client.py` — download comenzi cu paginare)
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite - **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
- **Date:** Oracle 11g/12c (schema ROA), SQLite (tracking local) - **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking)
--- ---
@@ -27,7 +27,6 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
### Instalare ### Instalare
```bash ```bash
# Din project root (gomag/)
pip install -r api/requirements.txt pip install -r api/requirements.txt
cp api/.env.example api/.env cp api/.env.example api/.env
# Editeaza api/.env cu datele de conectare Oracle # Editeaza api/.env cu datele de conectare Oracle
@@ -38,7 +37,6 @@ cp api/.env.example api/.env
**Important:** serverul trebuie pornit **din project root**, nu din `api/`: **Important:** serverul trebuie pornit **din project root**, nu din `api/`:
```bash ```bash
# Din gomag/
python -m uvicorn api.app.main:app --host 0.0.0.0 --port 5003 python -m uvicorn api.app.main:app --host 0.0.0.0 --port 5003
``` ```
@@ -55,13 +53,11 @@ Deschide `http://localhost:5003` in browser.
```bash ```bash
python api/test_app_basic.py python api/test_app_basic.py
``` ```
Verifica importuri de module + rute GET. Asteptat: 32/33 PASS (1 fail pre-existent `/sync` HTML).
**Test C - Integrare Oracle:** **Test C - Integrare Oracle:**
```bash ```bash
python api/test_integration.py python api/test_integration.py
``` ```
Necesita Oracle activ. Verifica health, mappings CRUD, article search, validation, sync.
--- ---
@@ -82,7 +78,7 @@ cp api/.env.example api/.env
| `INSTANTCLIENTPATH` | Cale Instant Client (thick mode) | `/opt/oracle/instantclient_21_15` | | `INSTANTCLIENTPATH` | Cale Instant Client (thick mode) | `/opt/oracle/instantclient_21_15` |
| `FORCE_THIN_MODE` | Thin mode fara Instant Client | `true` | | `FORCE_THIN_MODE` | Thin mode fara Instant Client | `true` |
| `SQLITE_DB_PATH` | Path SQLite (relativ la project root) | `api/data/import.db` | | `SQLITE_DB_PATH` | Path SQLite (relativ la project root) | `api/data/import.db` |
| `JSON_OUTPUT_DIR` | Folder JSON-uri VFP (relativ la project root) | `vfp/output` | | `JSON_OUTPUT_DIR` | Folder JSON-uri descarcate | `api/data/orders` |
| `APP_PORT` | Port HTTP | `5003` | | `APP_PORT` | Port HTTP | `5003` |
| `ID_POL` | ID Politica ROA | `39` | | `ID_POL` | ID Politica ROA | `39` |
| `ID_GESTIUNE` | ID Gestiune ROA | `0` | | `ID_GESTIUNE` | ID Gestiune ROA | `0` |
@@ -97,7 +93,7 @@ cp api/.env.example api/.env
## Structura Proiect ## Structura Proiect
``` ```
gomag/ gomag-vending/
├── api/ # FastAPI Admin + Dashboard ├── api/ # FastAPI Admin + Dashboard
│ ├── app/ │ ├── app/
│ │ ├── main.py # Entry point, lifespan, logging │ │ ├── main.py # Entry point, lifespan, logging
@@ -111,30 +107,28 @@ gomag/
│ │ │ ├── validation.py # /api/validate/* │ │ │ ├── validation.py # /api/validate/*
│ │ │ └── sync.py # /api/sync/* + /api/dashboard/orders │ │ │ └── sync.py # /api/sync/* + /api/dashboard/orders
│ │ ├── services/ │ │ ├── services/
│ │ │ ├── sync_service.py # Orchestrare: JSON→validate→import │ │ │ ├── gomag_client.py # Download comenzi GoMag API
│ │ │ ├── sync_service.py # Orchestrare: download→validate→import
│ │ │ ├── import_service.py # Import comanda in Oracle ROA │ │ │ ├── import_service.py # Import comanda in Oracle ROA
│ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total │ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total
│ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs │ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs
│ │ │ ├── order_reader.py # Citire gomag_orders_page*.json │ │ │ ├── order_reader.py # Citire gomag_orders_page*.json
│ │ │ ├── validation_service.py │ │ │ ├── validation_service.py
│ │ │ ├── article_service.py │ │ │ ├── article_service.py
│ │ │ ├── invoice_service.py # Verificare facturi ROA
│ │ │ └── scheduler_service.py # APScheduler timer │ │ │ └── scheduler_service.py # APScheduler timer
│ │ ├── templates/ # Jinja2 HTML │ │ ├── templates/ # Jinja2 HTML
│ │ └── static/ # CSS + JS │ │ └── static/ # CSS + JS
│ ├── database-scripts/ # Oracle SQL (ARTICOLE_TERTI, packages) │ ├── database-scripts/ # Oracle SQL (ARTICOLE_TERTI, packages)
│ ├── data/ # SQLite DB (import.db) │ ├── data/ # SQLite DB (import.db) + JSON orders
│ ├── .env # Configurare locala (nu in git) │ ├── .env # Configurare locala (nu in git)
│ ├── .env.example # Template configurare │ ├── .env.example # Template configurare
│ ├── test_app_basic.py # Test A - fara Oracle │ ├── test_app_basic.py # Test A - fara Oracle
│ ├── test_integration.py # Test C - cu Oracle │ ├── test_integration.py # Test C - cu Oracle
│ └── requirements.txt │ └── requirements.txt
├── vfp/ # VFP Integration
│ ├── gomag-vending.prg # Client GoMag API (descarca JSON-uri)
│ ├── sync-comenzi-web.prg # Orchestrator VFP
│ ├── utils.prg # Utilitare (log, settings, connectivity)
│ └── output/ # JSON-uri descarcate (gomag_orders_page*.json)
├── logs/ # Log-uri aplicatie (sync_comenzi_*.log) ├── logs/ # Log-uri aplicatie (sync_comenzi_*.log)
├── docs/ # Documentatie (PRD, stories) ├── docs/ # Documentatie (PRD, stories)
├── screenshots/ # Before/preview/after pentru UI changes
├── start.sh # Script pornire (Linux/WSL) ├── start.sh # Script pornire (Linux/WSL)
└── CLAUDE.md # Instructiuni pentru AI assistants └── CLAUDE.md # Instructiuni pentru AI assistants
``` ```
@@ -171,10 +165,10 @@ gomag/
## Fluxul de Import ## Fluxul de Import
``` ```
1. VFP descarca comenzi GoMag API → vfp/output/gomag_orders_page*.json 1. gomag_client.py descarca comenzi GoMag API → JSON files
2. FastAPI citeste JSON-urile (order_reader) 2. order_reader.py parseaza JSON-urile
3. Valideaza SKU-uri contra ARTICOLE_TERTI + NOM_ARTICOLE (validation_service) 3. validation_service.py valideaza SKU-uri contra ARTICOLE_TERTI + NOM_ARTICOLE
4. Import_service creeaza/cauta partener in Oracle (shipping person = facturare) 4. import_service.py creeaza/cauta partener in Oracle (shipping person = facturare)
5. PACK_IMPORT_COMENZI.importa_comanda_web() insereaza comanda in ROA 5. PACK_IMPORT_COMENZI.importa_comanda_web() insereaza comanda in ROA
6. Rezultate salvate in SQLite (orders, sync_run_orders, order_items) 6. Rezultate salvate in SQLite (orders, sync_run_orders, order_items)
``` ```
@@ -192,15 +186,15 @@ gomag/
| Faza | Status | Descriere | | Faza | Status | Descriere |
|------|--------|-----------| |------|--------|-----------|
| Phase 1: Database Foundation | Complet | ARTICOLE_TERTI, PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI | | Phase 1: Database Foundation | Complet | ARTICOLE_TERTI, PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI |
| Phase 2: VFP Integration | Complet | gomag-vending.prg, sync-comenzi-web.prg | | Phase 2: Python Integration | Complet | gomag_client.py, sync_service.py |
| Phase 3-4: FastAPI Dashboard | Complet | Redesign UI, smart polling, filter bar, paginare, tooltip | | Phase 3-4: FastAPI Dashboard | Complet | UI responsive, smart polling, filter bar, paginare |
| Phase 5: Production | 🔄 In Progress | Logging , Auth ⏳, SMTP ⏳, NSSM service ⏳ | | Phase 5: Production | In Progress | Logging done, Auth + SMTP pending |
--- ---
## WSL2 Note ## WSL2 Note
- `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual - `uvicorn --reload` **nu functioneaza** pe `/mnt/e/` (WSL2 limitation) — restarta manual
- Serverul trebuie pornit din **project root** (`gomag/`), nu din `api/` - Serverul trebuie pornit din **project root**, nu din `api/`
- `JSON_OUTPUT_DIR` si `SQLITE_DB_PATH` sunt relative la project root - `JSON_OUTPUT_DIR` si `SQLITE_DB_PATH` sunt relative la project root

View File

@@ -103,7 +103,8 @@ CREATE TABLE IF NOT EXISTS orders (
factura_total_fara_tva REAL, factura_total_fara_tva REAL,
factura_total_tva REAL, factura_total_tva REAL,
factura_total_cu_tva REAL, factura_total_cu_tva REAL,
invoice_checked_at TEXT invoice_checked_at TEXT,
order_total REAL
); );
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date); CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
@@ -301,6 +302,7 @@ def init_sqlite():
("factura_total_tva", "REAL"), ("factura_total_tva", "REAL"),
("factura_total_cu_tva", "REAL"), ("factura_total_cu_tva", "REAL"),
("invoice_checked_at", "TEXT"), ("invoice_checked_at", "TEXT"),
("order_total", "REAL"),
]: ]:
if col not in order_cols: if col not in order_cols:
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}") conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")

View File

@@ -149,6 +149,7 @@ async def sync_run_log(run_id: str):
"id_partener": o.get("id_partener"), "id_partener": o.get("id_partener"),
"error_message": o.get("error_message"), "error_message": o.get("error_message"),
"missing_skus": o.get("missing_skus"), "missing_skus": o.get("missing_skus"),
"order_total": o.get("order_total"),
"factura_numar": o.get("factura_numar"), "factura_numar": o.get("factura_numar"),
"factura_serie": o.get("factura_serie"), "factura_serie": o.get("factura_serie"),
} }

View File

@@ -54,6 +54,7 @@ class OrderData:
items: list = field(default_factory=list) # list of OrderItem items: list = field(default_factory=list) # list of OrderItem
billing: OrderBilling = field(default_factory=OrderBilling) billing: OrderBilling = field(default_factory=OrderBilling)
shipping: Optional[OrderShipping] = None shipping: Optional[OrderShipping] = None
total: float = 0.0
payment_name: str = "" payment_name: str = ""
delivery_name: str = "" delivery_name: str = ""
source_file: str = "" source_file: str = ""
@@ -163,6 +164,7 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
items=items, items=items,
billing=billing, billing=billing,
shipping=shipping, shipping=shipping,
total=float(data.get("total", 0) or 0),
payment_name=str(payment.get("name", "")) if isinstance(payment, dict) else "", payment_name=str(payment.get("name", "")) if isinstance(payment, dict) else "",
delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "", delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "",
source_file=source_file source_file=source_file

View File

@@ -50,7 +50,8 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
id_partener: int = None, error_message: str = None, id_partener: int = None, error_message: str = None,
missing_skus: list = None, items_count: int = 0, missing_skus: list = None, items_count: int = 0,
shipping_name: str = None, billing_name: str = None, shipping_name: str = None, billing_name: str = None,
payment_method: str = None, delivery_method: str = None): payment_method: str = None, delivery_method: str = None,
order_total: float = None):
"""Upsert a single order — one row per order_number, status updated in place.""" """Upsert a single order — one row per order_number, status updated in place."""
db = await get_sqlite() db = await get_sqlite()
try: try:
@@ -59,8 +60,8 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
(order_number, order_date, customer_name, status, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count, id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name, last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method) payment_method, delivery_method, order_total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
status = CASE status = CASE
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED' WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
@@ -80,12 +81,13 @@ async def upsert_order(sync_run_id: str, order_number: str, order_date: str,
billing_name = COALESCE(excluded.billing_name, orders.billing_name), billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method), payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method), delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
order_total = COALESCE(excluded.order_total, orders.order_total),
updated_at = datetime('now') updated_at = datetime('now')
""", (order_number, order_date, customer_name, status, """, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, id_comanda, id_partener, error_message,
json.dumps(missing_skus) if missing_skus else None, json.dumps(missing_skus) if missing_skus else None,
items_count, sync_run_id, shipping_name, billing_name, items_count, sync_run_id, shipping_name, billing_name,
payment_method, delivery_method)) payment_method, delivery_method, order_total))
await db.commit() await db.commit()
finally: finally:
await db.close() await db.close()
@@ -122,8 +124,8 @@ async def save_orders_batch(orders_data: list[dict]):
(order_number, order_date, customer_name, status, (order_number, order_date, customer_name, status,
id_comanda, id_partener, error_message, missing_skus, items_count, id_comanda, id_partener, error_message, missing_skus, items_count,
last_sync_run_id, shipping_name, billing_name, last_sync_run_id, shipping_name, billing_name,
payment_method, delivery_method) payment_method, delivery_method, order_total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(order_number) DO UPDATE SET ON CONFLICT(order_number) DO UPDATE SET
status = CASE status = CASE
WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED' WHEN orders.status = 'IMPORTED' AND excluded.status = 'ALREADY_IMPORTED'
@@ -143,6 +145,7 @@ async def save_orders_batch(orders_data: list[dict]):
billing_name = COALESCE(excluded.billing_name, orders.billing_name), billing_name = COALESCE(excluded.billing_name, orders.billing_name),
payment_method = COALESCE(excluded.payment_method, orders.payment_method), payment_method = COALESCE(excluded.payment_method, orders.payment_method),
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method), delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
order_total = COALESCE(excluded.order_total, orders.order_total),
updated_at = datetime('now') updated_at = datetime('now')
""", [ """, [
(d["order_number"], d["order_date"], d["customer_name"], d["status"], (d["order_number"], d["order_date"], d["customer_name"], d["status"],
@@ -150,7 +153,8 @@ async def save_orders_batch(orders_data: list[dict]):
json.dumps(d["missing_skus"]) if d.get("missing_skus") else None, json.dumps(d["missing_skus"]) if d.get("missing_skus") else None,
d.get("items_count", 0), d["sync_run_id"], d.get("items_count", 0), d["sync_run_id"],
d.get("shipping_name"), d.get("billing_name"), d.get("shipping_name"), d.get("billing_name"),
d.get("payment_method"), d.get("delivery_method")) d.get("payment_method"), d.get("delivery_method"),
d.get("order_total"))
for d in orders_data for d in orders_data
]) ])

View File

@@ -286,6 +286,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"items_count": len(order.items), "items_count": len(order.items),
"shipping_name": shipping_name, "billing_name": billing_name, "shipping_name": shipping_name, "billing_name": billing_name,
"payment_method": payment_method, "delivery_method": delivery_method, "payment_method": payment_method, "delivery_method": delivery_method,
"order_total": order.total or None,
"items": order_items_data, "items": order_items_data,
}) })
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → DEJA IMPORTAT (ID: {id_comanda_roa})") _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → DEJA IMPORTAT (ID: {id_comanda_roa})")
@@ -313,6 +314,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
"items_count": len(order.items), "items_count": len(order.items),
"shipping_name": shipping_name, "billing_name": billing_name, "shipping_name": shipping_name, "billing_name": billing_name,
"payment_method": payment_method, "delivery_method": delivery_method, "payment_method": payment_method, "delivery_method": delivery_method,
"order_total": order.total or None,
"items": order_items_data, "items": order_items_data,
}) })
_log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})") _log_line(run_id, f"#{order.number} [{order.date or '?'}] {customer} → OMIS (lipsa: {', '.join(missing_skus)})")
@@ -365,6 +367,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
billing_name=billing_name, billing_name=billing_name,
payment_method=payment_method, payment_method=payment_method,
delivery_method=delivery_method, delivery_method=delivery_method,
order_total=order.total or None,
) )
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED") await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
# Store ROA address IDs (R9) # Store ROA address IDs (R9)
@@ -390,6 +393,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None
billing_name=billing_name, billing_name=billing_name,
payment_method=payment_method, payment_method=payment_method,
delivery_method=delivery_method, delivery_method=delivery_method,
order_total=order.total or None,
) )
await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR") await sqlite_service.add_sync_run_order(run_id, order.number, "ERROR")
await sqlite_service.add_order_items(order.number, order_items_data) await sqlite_service.add_order_items(order.number, order_items_data)

View File

@@ -1,13 +1,5 @@
/* ── Design tokens ───────────────────────────────── */ /* ── Design tokens ───────────────────────────────── */
:root { :root {
/* Sidebar */
--sidebar-width: 224px;
--sidebar-bg: #111827;
--sidebar-text: #d1d5db;
--sidebar-active-bg: #1f2937;
--sidebar-active-text: #ffffff;
--sidebar-border: #374151;
/* Surfaces */ /* Surfaces */
--body-bg: #f9fafb; --body-bg: #f9fafb;
--card-bg: #ffffff; --card-bg: #ffffff;
@@ -27,93 +19,89 @@
--text-secondary: #4b5563; --text-secondary: #4b5563;
--text-muted: #6b7280; --text-muted: #6b7280;
--border-color: #e5e7eb; --border-color: #e5e7eb;
/* Dots */
--dot-green: #22c55e;
--dot-yellow: #eab308;
--dot-red: #ef4444;
} }
/* ── Base ────────────────────────────────────────── */ /* ── Base ────────────────────────────────────────── */
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 0.875rem; font-size: 1rem;
background-color: var(--body-bg); background-color: var(--body-bg);
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
/* ── Sidebar ─────────────────────────────────────── */ /* ── Top Navbar ──────────────────────────────────── */
.sidebar { .top-navbar {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: var(--sidebar-width); right: 0;
height: 100vh; height: 48px;
background-color: var(--sidebar-bg); background: #fff;
padding: 0; border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 1.5rem;
z-index: 1000; z-index: 1000;
overflow-y: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
transition: transform 0.3s ease;
} }
.sidebar-header { .navbar-brand {
padding: 1.25rem 1rem; font-weight: 700;
border-bottom: 1px solid var(--sidebar-border); font-size: 1rem;
color: #111827;
white-space: nowrap;
} }
.sidebar-header h5 { .navbar-links {
color: #fff; display: flex;
margin: 0; align-items: stretch;
font-size: 1.1rem; gap: 0;
font-weight: 600; overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
} }
.navbar-links::-webkit-scrollbar { display: none; }
.sidebar .nav-link { .nav-tab {
color: var(--sidebar-text); display: flex;
font-size: 0.875rem; align-items: center;
padding: 0 1rem;
height: 48px;
color: #64748b;
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500; font-weight: 500;
padding: 0.5rem 0.75rem; border-bottom: 2px solid transparent;
border-radius: 0.375rem; white-space: nowrap;
margin: 0.125rem 0.5rem; flex-shrink: 0;
transition: background 0.15s, color 0.15s; transition: color 0.15s, border-color 0.15s;
} }
.nav-tab:hover {
.sidebar .nav-link:hover { color: #111827;
color: var(--sidebar-active-text); background: #f9fafb;
background-color: var(--sidebar-active-bg); text-decoration: none;
} }
.nav-tab.active {
.sidebar .nav-link.active { color: var(--blue-600);
color: var(--sidebar-active-text); border-bottom-color: var(--blue-600);
background-color: var(--sidebar-active-bg);
}
.sidebar .nav-link i {
margin-right: 0.5rem;
width: 1.2rem;
text-align: center;
}
.sidebar-footer {
position: absolute;
bottom: 0;
padding: 0.75rem 1rem;
border-top: 1px solid var(--sidebar-border);
width: 100%;
} }
/* ── Main content ────────────────────────────────── */ /* ── Main content ────────────────────────────────── */
.main-content { .main-content {
margin-left: var(--sidebar-width); padding-top: 64px;
padding: 1.5rem; padding-left: 1.5rem;
padding-right: 1.5rem;
padding-bottom: 1.5rem;
min-height: 100vh; min-height: 100vh;
} }
/* ── Sidebar toggle (mobile) ─────────────────────── */
.sidebar-toggle {
position: fixed;
top: 0.5rem;
left: 0.5rem;
z-index: 1100;
border-radius: 0.375rem;
}
/* ── Cards ───────────────────────────────────────── */ /* ── Cards ───────────────────────────────────────── */
.card { .card {
border: none; border: none;
@@ -126,17 +114,17 @@ body {
background: var(--card-bg); background: var(--card-bg);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.9375rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
/* ── Tables ──────────────────────────────────────── */ /* ── Tables ──────────────────────────────────────── */
.table { .table {
font-size: 0.875rem; font-size: 1rem;
} }
.table th { .table th {
font-size: 0.75rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -147,13 +135,14 @@ body {
} }
.table td { .table td {
padding: 0.75rem 1rem; padding: 0.625rem 1rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 1rem;
} }
/* ── Badges — soft pill style ────────────────────── */ /* ── Badges — soft pill style ────────────────────── */
.badge { .badge {
font-size: 0.75rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
@@ -173,7 +162,7 @@ body {
/* ── Buttons ─────────────────────────────────────── */ /* ── Buttons ─────────────────────────────────────── */
.btn { .btn {
font-size: 0.875rem; font-size: 0.9375rem;
border-radius: 0.375rem; border-radius: 0.375rem;
} }
@@ -193,7 +182,7 @@ body {
/* ── Forms ───────────────────────────────────────── */ /* ── Forms ───────────────────────────────────────── */
.form-control, .form-select { .form-control, .form-select {
font-size: 0.875rem; font-size: 0.9375rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border-color: #d1d5db; border-color: #d1d5db;
@@ -204,12 +193,50 @@ body {
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
} }
/* ── Pagination ──────────────────────────────────── */ /* ── Unified Pagination Bar ──────────────────────── */
.pagination .page-link { .pagination-bar {
font-size: 0.875rem; display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
} }
/* ── Loading spinner ─────────────────────────────── */ .page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
font-size: 0.8125rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: #fff;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
text-decoration: none;
user-select: none;
}
.page-btn:hover:not(:disabled):not(.active) {
background: #f3f4f6;
border-color: #9ca3af;
color: var(--text-primary);
text-decoration: none;
}
.page-btn.active {
background: var(--blue-600);
border-color: var(--blue-600);
color: #fff;
font-weight: 600;
}
.page-btn:disabled, .page-btn.disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
/* Loading spinner ────────────────────────────────── */
.spinner-overlay { .spinner-overlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
@@ -220,6 +247,42 @@ body {
justify-content: center; justify-content: center;
} }
/* ── Colored dots ────────────────────────────────── */
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-green { background: var(--dot-green); }
.dot-yellow { background: var(--dot-yellow); }
.dot-red { background: var(--dot-red); }
.dot-gray { background: #9ca3af; }
.dot-blue { background: #3b82f6; }
/* ── Flat row (mobile + desktop) ────────────────── */
.flat-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
font-size: 1rem;
}
.flat-row:last-child { border-bottom: none; }
.flat-row:hover { background: #f9fafb; cursor: pointer; }
.grow { flex: 1; min-width: 0; }
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Colored filter count - text color only ─────── */
.fc-green { color: #16a34a; }
.fc-yellow { color: #ca8a04; }
.fc-red { color: #dc2626; }
.fc-neutral { color: #6b7280; }
.fc-blue { color: #2563eb; }
/* ── Log viewer (dark theme — keep as-is) ────────── */ /* ── Log viewer (dark theme — keep as-is) ────────── */
.log-viewer { .log-viewer {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -270,7 +333,7 @@ body {
/* ── Order detail modal ──────────────────────────── */ /* ── Order detail modal ──────────────────────────── */
.modal-lg .table-sm td, .modal-lg .table-sm td,
.modal-lg .table-sm th { .modal-lg .table-sm th {
font-size: 0.8125rem; font-size: 0.875rem;
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
} }
@@ -320,7 +383,7 @@ tr.mapping-deleted td {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: #fff;
font-size: 0.875rem; font-size: 0.9375rem;
cursor: pointer; cursor: pointer;
transition: background 0.15s, border-color 0.15s; transition: background 0.15s, border-color 0.15s;
white-space: nowrap; white-space: nowrap;
@@ -332,20 +395,12 @@ tr.mapping-deleted td {
color: #fff; color: #fff;
} }
.filter-pill.active .filter-count { .filter-pill.active .filter-count {
background: rgba(255,255,255,0.25); color: rgba(255,255,255,0.9);
color: #fff;
} }
.filter-count { .filter-count {
display: inline-block; font-size: 0.8125rem;
min-width: 1.25rem;
padding: 0 0.3rem;
border-radius: 999px;
background: #e5e7eb;
font-size: 0.75rem;
font-weight: 600; font-weight: 600;
text-align: center;
line-height: 1.4;
} }
/* ── Search input ────────────────────────────────── */ /* ── Search input ────────────────────────────────── */
@@ -354,7 +409,7 @@ tr.mapping-deleted td {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.875rem; font-size: 0.9375rem;
outline: none; outline: none;
min-width: 180px; min-width: 180px;
} }
@@ -375,7 +430,7 @@ tr.mapping-deleted td {
.autocomplete-item { .autocomplete-item {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.9375rem;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
} }
.autocomplete-item:hover, .autocomplete-item.active { .autocomplete-item:hover, .autocomplete-item.active {
@@ -387,7 +442,7 @@ tr.mapping-deleted td {
} }
.autocomplete-item .denumire { .autocomplete-item .denumire {
color: #64748b; color: #64748b;
font-size: 0.8rem; font-size: 0.875rem;
} }
/* ── Tooltip for Client/Cont ─────────────────────── */ /* ── Tooltip for Client/Cont ─────────────────────── */
@@ -439,7 +494,7 @@ tr.mapping-deleted td {
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 0.875rem; font-size: 1rem;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: background 0.12s; transition: background 0.12s;
@@ -451,7 +506,7 @@ tr.mapping-deleted td {
gap: 0.5rem; gap: 0.5rem;
padding: 0.4rem 1rem; padding: 0.4rem 1rem;
background: #eff6ff; background: #eff6ff;
font-size: 0.875rem; font-size: 1rem;
color: var(--blue-700); color: var(--blue-700);
border-top: 1px solid #dbeafe; border-top: 1px solid #dbeafe;
} }
@@ -489,14 +544,14 @@ tr.mapping-deleted td {
display: none; display: none;
gap: 0.375rem; gap: 0.375rem;
align-items: center; align-items: center;
font-size: 0.875rem; font-size: 0.9375rem;
} }
.period-custom-range.visible { display: flex; } .period-custom-range.visible { display: flex; }
/* ── select-compact (used in filter bars) ─────────── */ /* ── select-compact (used in filter bars) ─────────── */
.select-compact { .select-compact {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
font-size: 0.875rem; font-size: 0.9375rem;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: #fff;
@@ -506,14 +561,14 @@ tr.mapping-deleted td {
/* ── btn-compact (kept for backward compat) ──────── */ /* ── btn-compact (kept for backward compat) ──────── */
.btn-compact { .btn-compact {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
font-size: 0.875rem; font-size: 0.9375rem;
} }
/* ── Result banner ───────────────────────────────── */ /* ── Result banner ───────────────────────────────── */
.result-banner { .result-banner {
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.875rem; font-size: 0.9375rem;
background: #d1fae5; background: #d1fae5;
color: #065f46; color: #065f46;
border: 1px solid #6ee7b7; border: 1px solid #6ee7b7;
@@ -521,7 +576,7 @@ tr.mapping-deleted td {
/* ── Badge-pct (mappings page) ───────────────────── */ /* ── Badge-pct (mappings page) ───────────────────── */
.badge-pct { .badge-pct {
font-size: 0.7rem; font-size: 0.75rem;
padding: 0.1rem 0.35rem; padding: 0.1rem 0.35rem;
border-radius: 4px; border-radius: 4px;
font-weight: 600; font-weight: 600;
@@ -529,10 +584,132 @@ tr.mapping-deleted td {
.badge-pct.complete { background: #d1fae5; color: #065f46; } .badge-pct.complete { background: #d1fae5; color: #065f46; }
.badge-pct.incomplete { background: #fef3c7; color: #92400e; } .badge-pct.incomplete { background: #fef3c7; color: #92400e; }
/* ── Context Menu ────────────────────────────────── */
.context-menu-trigger {
background: none;
border: none;
color: #9ca3af;
padding: 0.2rem 0.4rem;
cursor: pointer;
border-radius: 0.25rem;
font-size: 1rem;
line-height: 1;
transition: color 0.12s, background 0.12s;
}
.context-menu-trigger:hover {
color: var(--text-secondary);
background: #f3f4f6;
}
.context-menu {
position: fixed;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
z-index: 1050;
min-width: 150px;
padding: 0.25rem 0;
}
.context-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.9rem;
font-size: 0.9375rem;
background: none;
border: none;
cursor: pointer;
color: var(--text-primary);
transition: background 0.1s;
}
.context-menu-item:hover { background: #f3f4f6; }
.context-menu-item.text-danger { color: #dc2626; }
.context-menu-item.text-danger:hover { background: #fee2e2; }
/* ── Pagination info strip ───────────────────────── */
.pag-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
}
.pag-strip-bottom {
border-bottom: none;
border-top: 1px solid var(--border-color);
}
/* ── Per page selector ───────────────────────────── */
.per-page-label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.9375rem;
color: var(--text-muted);
white-space: nowrap;
}
/* ── Mobile list vs desktop table ────────────────── */
.mobile-list { display: none; }
/* ── Mappings flat-rows: always visible ────────────── */
.mappings-flat-list { display: block; }
/* ── Mobile ⋯ dropdown ─────────────────────────── */
.mobile-more-dropdown { position: relative; display: inline-block; }
.mobile-more-dropdown .dropdown-toggle::after { display: none; }
/* ── Mobile segmented control (hidden on desktop) ── */
.mobile-seg { display: none; }
/* ── Responsive ──────────────────────────────────── */ /* ── Responsive ──────────────────────────────────── */
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.sidebar { transform: translateX(-100%); } .top-navbar {
.sidebar.show { transform: translateX(0); } padding: 0 0.5rem;
.main-content { margin-left: 0; } gap: 0.5rem;
.sidebar-toggle { display: block !important; } }
.navbar-brand {
font-size: 0.875rem;
}
.nav-tab {
padding: 0 0.625rem;
font-size: 0.8125rem;
}
.main-content {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.filter-bar {
gap: 0.375rem;
}
.filter-pill { padding: 0.25rem 0.5rem; font-size: 0.8125rem; }
.search-input { min-width: 0; width: 100%; order: 99; }
.page-btn.page-number { display: none; }
.page-btn.page-ellipsis { display: none; }
.table-responsive { display: none; }
.mobile-list { display: block; }
/* Segmented filter control (replaces pills on mobile) */
.filter-bar .filter-pill { display: none; }
.filter-bar .mobile-seg { display: flex; }
/* Sync card compact */
.sync-card-controls {
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
}
.sync-card-info {
flex-wrap: wrap;
gap: 0.375rem;
font-size: 0.8rem;
padding: 0.375rem 0.75rem;
}
/* Hide per-page selector on mobile */
.per-page-label { display: none; }
} }

View File

@@ -92,14 +92,13 @@ function updateSyncPanel(data) {
const st = document.getElementById('lastSyncStatus'); const st = document.getElementById('lastSyncStatus');
if (d) d.textContent = lr.started_at ? lr.started_at.replace('T', ' ').slice(0, 16) : '\u2014'; if (d) d.textContent = lr.started_at ? lr.started_at.replace('T', ' ').slice(0, 16) : '\u2014';
if (dur) dur.textContent = lr.duration_seconds ? Math.round(lr.duration_seconds) + 's' : '\u2014'; if (dur) dur.textContent = lr.duration_seconds ? Math.round(lr.duration_seconds) + 's' : '\u2014';
// Updated counts: ↑new =already ⊘skipped ✕errors
if (cnt) { if (cnt) {
const newImp = lr.new_imported || 0; const newImp = lr.new_imported || 0;
const already = lr.already_imported || 0; const already = lr.already_imported || 0;
if (already > 0) { if (already > 0) {
cnt.textContent = '\u2191' + newImp + ' =' + already + ' \u2298' + (lr.skipped || 0) + ' \u2715' + (lr.errors || 0); cnt.innerHTML = `<span class="dot dot-green me-1"></span>${newImp} noi, ${already} deja &nbsp; <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise &nbsp; <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
} else { } else {
cnt.textContent = '\u2191' + (lr.imported || 0) + ' \u2298' + (lr.skipped || 0) + ' \u2715' + (lr.errors || 0); cnt.innerHTML = `<span class="dot dot-green me-1"></span>${lr.imported || 0} imp. &nbsp; <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise &nbsp; <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
} }
} }
if (st) { if (st) {
@@ -300,13 +299,13 @@ async function loadDashOrders() {
if (o.status !== 'IMPORTED' && o.status !== 'ALREADY_IMPORTED') { if (o.status !== 'IMPORTED' && o.status !== 'ALREADY_IMPORTED') {
invoiceBadge = '<span class="text-muted">-</span>'; invoiceBadge = '<span class="text-muted">-</span>';
} else if (o.invoice && o.invoice.facturat) { } else if (o.invoice && o.invoice.facturat) {
invoiceBadge = `<span class="badge bg-success">Facturat</span>`; invoiceBadge = `<span style="color:#16a34a;font-weight:500">Facturat</span>`;
if (o.invoice.serie_act || o.invoice.numar_act) { if (o.invoice.serie_act || o.invoice.numar_act) {
invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`; invoiceBadge += `<br><small>${esc(o.invoice.serie_act || '')} ${esc(String(o.invoice.numar_act || ''))}</small>`;
} }
invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-'; invoiceTotal = o.invoice.total_cu_tva ? Number(o.invoice.total_cu_tva).toFixed(2) : '-';
} else { } else {
invoiceBadge = '<span class="badge bg-danger">Nefacturat</span>'; invoiceBadge = `<span style="color:#dc2626">Nefacturat</span>`;
} }
return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')"> return `<tr style="cursor:pointer" onclick="openDashOrderDetail('${esc(o.order_number)}')">
@@ -314,7 +313,7 @@ async function loadDashOrders() {
<td>${dateStr}</td> <td>${dateStr}</td>
${renderClientCell(o)} ${renderClientCell(o)}
<td>${o.items_count || 0}</td> <td>${o.items_count || 0}</td>
<td>${statusBadge}</td> <td class="text-nowrap">${statusDot(o.status)} ${statusLabelText(o.status)}</td>
<td>${o.id_comanda || '-'}</td> <td>${o.id_comanda || '-'}</td>
<td>${invoiceBadge}</td> <td>${invoiceBadge}</td>
<td>${invoiceTotal}</td> <td>${invoiceTotal}</td>
@@ -322,20 +321,53 @@ async function loadDashOrders() {
}).join(''); }).join('');
} }
// Mobile flat rows
const mobileList = document.getElementById('dashMobileList');
if (mobileList) {
if (orders.length === 0) {
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
} else {
mobileList.innerHTML = orders.map(o => {
const d = o.order_date || '';
let dateFmt = '-';
if (d.length >= 10) {
dateFmt = d.slice(8, 10) + '.' + d.slice(5, 7) + '.' + d.slice(2, 4);
if (d.length >= 16) dateFmt += ' ' + d.slice(11, 16);
}
const name = o.shipping_name || o.customer_name || o.billing_name || '\u2014';
const totalStr = o.order_total ? Math.round(o.order_total) : '';
return `<div class="flat-row" onclick="openDashOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
${statusDot(o.status)}
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate">${esc(name)}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + totalStr : ''}</span>
</div>`;
}).join('');
}
}
// Mobile segmented control
renderMobileSegmented('dashMobileSeg', [
{ label: 'Toate', count: c.total || 0, value: 'all', active: (activeStatus || 'all') === 'all', colorClass: 'fc-neutral' },
{ label: 'Imp.', count: c.imported_all || c.imported || 0, value: 'IMPORTED', active: activeStatus === 'IMPORTED', colorClass: 'fc-green' },
{ label: 'Omise', count: c.skipped || 0, value: 'SKIPPED', active: activeStatus === 'SKIPPED', colorClass: 'fc-yellow' },
{ label: 'Erori', count: c.error || c.errors || 0, value: 'ERROR', active: activeStatus === 'ERROR', colorClass: 'fc-red' },
{ label: 'Nefact.', count: c.uninvoiced || c.nefacturate || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-neutral' }
], (val) => {
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-status="${val}"]`);
if (pill) pill.classList.add('active');
dashPage = 1;
loadDashOrders();
});
// Pagination // Pagination
const pag = data.pagination || {}; const pag = data.pagination || {};
const totalPages = pag.total_pages || data.pages || 1; const totalPages = pag.total_pages || data.pages || 1;
const totalOrders = (data.counts || {}).total || data.total || 0; const totalOrders = (data.counts || {}).total || data.total || 0;
const pageInfo = `${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}`;
document.getElementById('dashPageInfo').textContent = pageInfo;
const pagInfoTop = document.getElementById('dashPageInfoTop');
if (pagInfoTop) pagInfoTop.textContent = pageInfo;
const pagHtml = totalPages > 1 ? ` const pagOpts = { perPage: dashPerPage, perPageFn: 'dashChangePerPage', perPageOptions: [25, 50, 100, 250] };
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button> const pagHtml = `<small class="text-muted me-auto">${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}</small>` + renderUnifiedPagination(dashPage, totalPages, 'dashGoPage', pagOpts);
<small class="text-muted">${dashPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${dashPage >= totalPages ? 'disabled' : ''} onclick="dashGoPage(${dashPage + 1})"><i class="bi bi-chevron-right"></i></button>
` : '';
const pagDiv = document.getElementById('dashPagination'); const pagDiv = document.getElementById('dashPagination');
if (pagDiv) pagDiv.innerHTML = pagHtml; if (pagDiv) pagDiv.innerHTML = pagHtml;
const pagDivTop = document.getElementById('dashPaginationTop'); const pagDivTop = document.getElementById('dashPaginationTop');
@@ -396,16 +428,15 @@ function escHtml(s) {
// Alias kept for backward compat with inline handlers in modal // Alias kept for backward compat with inline handlers in modal
function esc(s) { return escHtml(s); } function esc(s) { return escHtml(s); }
function fmtDate(dateStr) {
if (!dateStr) return '-'; function statusLabelText(status) {
try { switch ((status || '').toUpperCase()) {
const d = new Date(dateStr); case 'IMPORTED': return 'Importat';
const hasTime = dateStr.includes(':'); case 'ALREADY_IMPORTED': return 'Deja imp.';
if (hasTime) { case 'SKIPPED': return 'Omis';
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); case 'ERROR': return 'Eroare';
} default: return esc(status);
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); }
} catch { return dateStr; }
} }
function orderStatusBadge(status) { function orderStatusBadge(status) {

View File

@@ -10,14 +10,6 @@ let currentQmOrderNumber = '';
let ordersSortColumn = 'order_date'; let ordersSortColumn = 'order_date';
let ordersSortDirection = 'desc'; let ordersSortDirection = 'desc';
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function fmtDuration(startedAt, finishedAt) { function fmtDuration(startedAt, finishedAt) {
if (!startedAt || !finishedAt) return '-'; if (!startedAt || !finishedAt) return '-';
const diffMs = new Date(finishedAt) - new Date(startedAt); const diffMs = new Date(finishedAt) - new Date(startedAt);
@@ -27,24 +19,12 @@ function fmtDuration(startedAt, finishedAt) {
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
} }
function fmtDate(dateStr) {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
const hasTime = dateStr.includes(':');
if (hasTime) {
return d.toLocaleString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return dateStr; }
}
function runStatusBadge(status) { function runStatusBadge(status) {
switch ((status || '').toLowerCase()) { switch ((status || '').toLowerCase()) {
case 'completed': return '<span class="badge bg-success">completed</span>'; case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
case 'running': return '<span class="badge bg-primary">running</span>'; case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
case 'failed': return '<span class="badge bg-danger">failed</span>'; case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`; default: return `<span style="font-weight:600">${esc(status)}</span>`;
} }
} }
@@ -58,6 +38,18 @@ function orderStatusBadge(status) {
} }
} }
function logStatusText(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED': return 'Importat';
case 'ALREADY_IMPORTED': return 'Deja imp.';
case 'SKIPPED': return 'Omis';
case 'ERROR': return 'Eroare';
default: return esc(status);
}
}
function logsGoPage(p) { loadRunOrders(currentRunId, null, p); }
// ── Runs Dropdown ──────────────────────────────── // ── Runs Dropdown ────────────────────────────────
async function loadRuns() { async function loadRuns() {
@@ -88,6 +80,8 @@ async function loadRuns() {
return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`; return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`;
}).join(''); }).join('');
} }
const ddMobile = document.getElementById('runsDropdownMobile');
if (ddMobile) ddMobile.innerHTML = dd.innerHTML;
} catch (err) { } catch (err) {
const dd = document.getElementById('runsDropdown'); const dd = document.getElementById('runsDropdown');
dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`; dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`;
@@ -110,6 +104,8 @@ async function selectRun(runId) {
// Sync dropdown selection // Sync dropdown selection
const dd = document.getElementById('runsDropdown'); const dd = document.getElementById('runsDropdown');
if (dd && dd.value !== runId) dd.value = runId; if (dd && dd.value !== runId) dd.value = runId;
const ddMobile = document.getElementById('runsDropdownMobile');
if (ddMobile && ddMobile.value !== runId) ddMobile.value = runId;
if (!runId) { if (!runId) {
document.getElementById('logViewerSection').style.display = 'none'; document.getElementById('logViewerSection').style.display = 'none';
@@ -117,8 +113,8 @@ async function selectRun(runId) {
} }
document.getElementById('logViewerSection').style.display = ''; document.getElementById('logViewerSection').style.display = '';
document.getElementById('logRunId').textContent = runId; const logRunIdEl = document.getElementById('logRunId'); if (logRunIdEl) logRunIdEl.textContent = runId;
document.getElementById('logStatusBadge').innerHTML = '<span class="badge bg-secondary">...</span>'; document.getElementById('logStatusBadge').innerHTML = '...';
document.getElementById('textLogSection').style.display = 'none'; document.getElementById('textLogSection').style.display = 'none';
await loadRunOrders(runId, 'all', 1); await loadRunOrders(runId, 'all', 1);
@@ -133,13 +129,9 @@ async function loadRunOrders(runId, statusFilter, page) {
if (statusFilter != null) currentFilter = statusFilter; if (statusFilter != null) currentFilter = statusFilter;
if (page != null) ordersPage = page; if (page != null) ordersPage = page;
// Update filter button styles // Update filter pill active state
document.querySelectorAll('#orderFilterBtns button').forEach(btn => { document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary') btn.classList.toggle('active', btn.dataset.logStatus === currentFilter);
.replace(' btn-success', ' btn-outline-success')
.replace(' btn-info', ' btn-outline-info')
.replace(' btn-warning', ' btn-outline-warning')
.replace(' btn-danger', ' btn-outline-danger');
}); });
try { try {
@@ -155,15 +147,6 @@ async function loadRunOrders(runId, statusFilter, page) {
const alreadyEl = document.getElementById('countAlreadyImported'); const alreadyEl = document.getElementById('countAlreadyImported');
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0; if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
// Highlight active filter
const filterMap = { 'all': 0, 'IMPORTED': 1, 'ALREADY_IMPORTED': 2, 'SKIPPED': 3, 'ERROR': 4 };
const btns = document.querySelectorAll('#orderFilterBtns button');
const idx = filterMap[currentFilter] ?? 0;
if (btns[idx]) {
const colorMap = ['primary', 'success', 'info', 'warning', 'danger'];
btns[idx].className = btns[idx].className.replace(`btn-outline-${colorMap[idx]}`, `btn-${colorMap[idx]}`);
}
const tbody = document.getElementById('runOrdersBody'); const tbody = document.getElementById('runOrdersBody');
const orders = data.orders || []; const orders = data.orders || [];
@@ -178,32 +161,62 @@ async function loadRunOrders(runId, statusFilter, page) {
<td><code>${esc(o.order_number)}</code></td> <td><code>${esc(o.order_number)}</code></td>
<td>${esc(o.customer_name)}</td> <td>${esc(o.customer_name)}</td>
<td>${o.items_count || 0}</td> <td>${o.items_count || 0}</td>
<td>${orderStatusBadge(o.status)}</td> <td class="text-nowrap">${statusDot(o.status)} ${logStatusText(o.status)}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
// Mobile flat rows
const mobileList = document.getElementById('logsMobileList');
if (mobileList) {
if (orders.length === 0) {
mobileList.innerHTML = '<div class="flat-row text-muted py-3 justify-content-center">Nicio comanda</div>';
} else {
mobileList.innerHTML = orders.map(o => {
const d = o.order_date || '';
let dateFmt = '-';
if (d.length >= 10) {
dateFmt = d.slice(8, 10) + '.' + d.slice(5, 7) + '.' + d.slice(2, 4);
if (d.length >= 16) dateFmt += ' ' + d.slice(11, 16);
}
const totalStr = o.order_total ? Math.round(o.order_total) : '';
return `<div class="flat-row" onclick="openOrderDetail('${esc(o.order_number)}')" style="font-size:0.875rem">
${statusDot(o.status)}
<span style="color:#6b7280" class="text-nowrap">${dateFmt}</span>
<span class="grow truncate">${esc(o.customer_name || '—')}</span>
<span class="text-nowrap">x${o.items_count || 0}${totalStr ? ' · ' + totalStr : ''}</span>
</div>`;
}).join('');
}
}
// Mobile segmented control
renderMobileSegmented('logsMobileSeg', [
{ label: 'Toate', count: counts.total || 0, value: 'all', active: currentFilter === 'all', colorClass: 'fc-neutral' },
{ label: 'Imp.', count: counts.imported || 0, value: 'IMPORTED', active: currentFilter === 'IMPORTED', colorClass: 'fc-green' },
{ label: 'Deja', count: counts.already_imported || 0, value: 'ALREADY_IMPORTED', active: currentFilter === 'ALREADY_IMPORTED', colorClass: 'fc-blue' },
{ label: 'Omise', count: counts.skipped || 0, value: 'SKIPPED', active: currentFilter === 'SKIPPED', colorClass: 'fc-yellow' },
{ label: 'Erori', count: counts.error || 0, value: 'ERROR', active: currentFilter === 'ERROR', colorClass: 'fc-red' }
], (val) => filterOrders(val));
// Orders pagination // Orders pagination
const totalPages = data.pages || 1; const totalPages = data.pages || 1;
const infoEl = document.getElementById('ordersPageInfo'); const infoEl = document.getElementById('ordersPageInfo');
infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`; if (infoEl) infoEl.textContent = `${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}`;
const pagHtml = `<small class="text-muted me-auto">${data.total || 0} comenzi | Pagina ${ordersPage} din ${totalPages}</small>` + renderUnifiedPagination(ordersPage, totalPages, 'logsGoPage');
const pagDiv = document.getElementById('ordersPagination'); const pagDiv = document.getElementById('ordersPagination');
if (totalPages > 1) { if (pagDiv) pagDiv.innerHTML = pagHtml;
pagDiv.innerHTML = ` const pagDivTop = document.getElementById('ordersPaginationTop');
<button class="btn btn-sm btn-outline-secondary" ${ordersPage <= 1 ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage - 1})"><i class="bi bi-chevron-left"></i></button> if (pagDivTop) pagDivTop.innerHTML = pagHtml;
<small class="text-muted">${ordersPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${ordersPage >= totalPages ? 'disabled' : ''} onclick="loadRunOrders('${esc(runId)}', null, ${ordersPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
// Update run status badge // Update run status badge
const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`); const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`);
const runData = await runRes.json(); const runData = await runRes.json();
if (runData.run) { if (runData.run) {
document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status); document.getElementById('logStatusBadge').innerHTML = runStatusBadge(runData.run.status);
// Update mobile run dot
const mDot = document.getElementById('mobileRunDot');
if (mDot) mDot.className = 'sync-status-dot ' + (runData.run.status || 'idle');
} }
} catch (err) { } catch (err) {
document.getElementById('runOrdersBody').innerHTML = document.getElementById('runOrdersBody').innerHTML =
@@ -517,6 +530,12 @@ async function saveQuickMapping() {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadRuns(); loadRuns();
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
btn.addEventListener('click', function() {
filterOrders(this.dataset.logStatus || 'all');
});
});
const preselected = document.getElementById('preselectedRun'); const preselected = document.getElementById('preselectedRun');
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : ''); const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
@@ -533,4 +552,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; } if (logPollTimer) { clearInterval(logPollTimer); logPollTimer = null; }
} }
}); });
document.getElementById('autoRefreshToggleMobile')?.addEventListener('change', (e) => {
const desktop = document.getElementById('autoRefreshToggle');
if (desktop) desktop.checked = e.target.checked;
desktop?.dispatchEvent(new Event('change'));
});
}); });

View File

@@ -1,4 +1,5 @@
let currentPage = 1; let currentPage = 1;
let mappingsPerPage = 50;
let currentSearch = ''; let currentSearch = '';
let searchTimeout = null; let searchTimeout = null;
let sortColumn = 'sku'; let sortColumn = 'sku';
@@ -69,6 +70,20 @@ function updatePctCounts(counts) {
if (elAll) elAll.textContent = counts.total || 0; if (elAll) elAll.textContent = counts.total || 0;
if (elComplete) elComplete.textContent = counts.complete || 0; if (elComplete) elComplete.textContent = counts.complete || 0;
if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0; if (elIncomplete) elIncomplete.textContent = counts.incomplete || 0;
// Mobile segmented control
renderMobileSegmented('mappingsMobileSeg', [
{ label: 'Toate', count: counts.total || 0, value: 'all', active: pctFilter === 'all', colorClass: 'fc-neutral' },
{ label: 'Complete', count: counts.complete || 0, value: 'complete', active: pctFilter === 'complete', colorClass: 'fc-green' },
{ label: 'Incompl.', count: counts.incomplete || 0, value: 'incomplete', active: pctFilter === 'incomplete', colorClass: 'fc-yellow' }
], (val) => {
document.querySelectorAll('.filter-pill[data-pct]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-pct="${val}"]`);
if (pill) pill.classList.add('active');
pctFilter = val;
currentPage = 1;
loadMappings();
});
} }
// ── Load & Render ──────────────────────────────── // ── Load & Render ────────────────────────────────
@@ -79,7 +94,7 @@ async function loadMappings() {
const params = new URLSearchParams({ const params = new URLSearchParams({
search: currentSearch, search: currentSearch,
page: currentPage, page: currentPage,
per_page: 50, per_page: mappingsPerPage,
sort_by: sortColumn, sort_by: sortColumn,
sort_dir: sortDirection sort_dir: sortDirection
}); });
@@ -103,116 +118,129 @@ async function loadMappings() {
renderPagination(data); renderPagination(data);
updateSortIcons(); updateSortIcons();
} catch (err) { } catch (err) {
document.getElementById('mappingsBody').innerHTML = document.getElementById('mappingsFlatList').innerHTML =
`<tr><td colspan="9" class="text-center text-danger">Eroare: ${err.message}</td></tr>`; `<div class="flat-row text-danger py-3 justify-content-center">Eroare: ${err.message}</div>`;
} }
} }
function renderTable(mappings, showDeleted) { function renderTable(mappings, showDeleted) {
const tbody = document.getElementById('mappingsBody'); const container = document.getElementById('mappingsFlatList');
if (!mappings || mappings.length === 0) { if (!mappings || mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4">Nu exista mapari</td></tr>'; container.innerHTML = '<div class="flat-row text-muted py-4 justify-content-center">Nu exista mapari</div>';
return; return;
} }
// Group by SKU for visual grouping (R6)
let html = '';
let prevSku = null; let prevSku = null;
let groupIdx = 0; let html = '';
let skuGroupCounts = {};
// Count items per SKU
mappings.forEach(m => { mappings.forEach(m => {
skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1;
});
mappings.forEach((m, i) => {
const isNewGroup = m.sku !== prevSku; const isNewGroup = m.sku !== prevSku;
if (isNewGroup) groupIdx++;
const groupClass = groupIdx % 2 === 0 ? 'sku-group-even' : 'sku-group-odd';
const isMulti = skuGroupCounts[m.sku] > 1;
const inactiveClass = !m.activ && !m.sters ? 'table-secondary opacity-75' : '';
const deletedClass = m.sters ? 'mapping-deleted' : '';
// SKU cell: show only on first row of group
let skuCell, productCell;
if (isNewGroup) { if (isNewGroup) {
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
// Percentage total badge
let pctBadge = ''; let pctBadge = '';
if (m.pct_total !== undefined) { if (m.pct_total !== undefined) {
if (m.is_complete) { pctBadge = m.is_complete
pctBadge = ` <span class="badge-pct complete" title="100% alocat">&#10003; 100%</span>`; ? ` <span class="badge-pct complete">&#10003; 100%</span>`
} else { : ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
const pctVal = typeof m.pct_total === 'number' ? m.pct_total.toFixed(2) : m.pct_total;
pctBadge = ` <span class="badge-pct incomplete" title="${pctVal}% alocat">&#9888; ${pctVal}%</span>`;
}
} }
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}${pctBadge}</td>`; const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`; html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
} else { <span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
skuCell = ''; ${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
productCell = ''; title="${m.activ ? 'Activ' : 'Inactiv'}"></span>
<strong class="me-1 text-nowrap">${esc(m.sku)}</strong>${pctBadge}
<span class="grow truncate text-muted" style="font-size:0.875rem">${esc(m.product_name || '')}</span>
${m.sters
? `<button class="btn btn-sm btn-outline-success" onclick="event.stopPropagation();restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza" style="padding:0.1rem 0.4rem"><i class="bi bi-arrow-counterclockwise"></i></button>`
: `<button class="context-menu-trigger" data-sku="${esc(m.sku)}" data-codmat="${esc(m.codmat)}" data-cantitate="${m.cantitate_roa}" data-procent="${m.procent_pret}">&#8942;</button>`
}
</div>`;
} }
const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : '';
html += `<tr class="${groupClass} ${inactiveClass} ${deletedClass}"> html += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
${skuCell} <code>${esc(m.codmat)}</code>
${productCell} <span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
<td><code>${esc(m.codmat)}</code></td> <span class="text-nowrap" style="font-size:0.875rem">
<td>${esc(m.denumire || '-')}</td> <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
<td>${esc(m.um || '-')}</td> ${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>${m.cantitate_roa}</td> · <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
<td class="${m.sters ? '' : 'editable'}" ${m.sters ? '' : `onclick="editCell(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</td> ${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</span>
<td> </span>
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'} </div>`;
${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
${m.activ ? 'Activ' : 'Inactiv'}
</span>
</td>
<td>
${m.sters ? `<button class="btn btn-sm btn-outline-success" onclick="restoreMapping('${esc(m.sku)}', '${esc(m.codmat)}')" title="Restaureaza"><i class="bi bi-arrow-counterclockwise"></i></button>` : `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditModal('${esc(m.sku)}', '${esc(m.codmat)}', ${m.cantitate_roa}, ${m.procent_pret})" title="Editeaza">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMappingConfirm('${esc(m.sku)}', '${esc(m.codmat)}')" title="Sterge">
<i class="bi bi-trash"></i>
</button>`}
</td>
</tr>`;
prevSku = m.sku; prevSku = m.sku;
}); });
container.innerHTML = html;
tbody.innerHTML = html; // Wire context menu triggers
container.querySelectorAll('.context-menu-trigger').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const { sku, codmat, cantitate, procent } = btn.dataset;
const rect = btn.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom + 2, [
{ label: 'Editeaza', action: () => openEditModal(sku, codmat, parseFloat(cantitate), parseFloat(procent)) },
{ label: 'Sterge', action: () => deleteMappingConfirm(sku, codmat), danger: true }
]);
});
});
}
// Inline edit for flat-row values (cantitate / procent)
function editFlatValue(span, sku, codmat, field, currentValue) {
if (span.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm d-inline';
input.value = currentValue;
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
input.style.width = '70px';
input.style.display = 'inline';
const originalText = span.textContent;
span.textContent = '';
span.appendChild(input);
input.focus();
input.select();
const save = async () => {
const newValue = parseFloat(input.value);
if (isNaN(newValue) || newValue === currentValue) {
span.textContent = originalText;
return;
}
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) { loadMappings(); }
else { span.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
} catch (err) { span.textContent = originalText; }
};
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); save(); }
if (e.key === 'Escape') { span.textContent = originalText; }
});
} }
function renderPagination(data) { function renderPagination(data) {
const info = document.getElementById('pageInfo'); const pagOpts = { perPage: mappingsPerPage, perPageFn: 'mappingsChangePerPage', perPageOptions: [25, 50, 100, 250] };
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`; const infoHtml = `<small class="text-muted me-auto">${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}</small>`;
const pagHtml = infoHtml + renderUnifiedPagination(data.page, data.pages || 1, 'goPage', pagOpts);
const ul = document.getElementById('pagination'); const top = document.getElementById('mappingsPagTop');
if (data.pages <= 1) { ul.innerHTML = ''; return; } const bot = document.getElementById('mappingsPagBottom');
if (top) top.innerHTML = pagHtml;
let html = ''; if (bot) bot.innerHTML = pagHtml;
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">&laquo;</a></li>`;
let start = Math.max(1, data.page - 3);
let end = Math.min(data.pages, start + 6);
start = Math.max(1, end - 6);
for (let i = start; i <= end; i++) {
html += `<li class="page-item ${i === data.page ? 'active' : ''}">
<a class="page-link" href="#" onclick="goPage(${i}); return false;">${i}</a></li>`;
}
html += `<li class="page-item ${data.page >= data.pages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goPage(${data.page + 1}); return false;">&raquo;</a></li>`;
ul.innerHTML = html;
} }
function mappingsChangePerPage(val) { mappingsPerPage = parseInt(val) || 50; currentPage = 1; loadMappings(); }
function goPage(p) { function goPage(p) {
currentPage = p; currentPage = p;
loadMappings(); loadMappings();
@@ -411,36 +439,34 @@ async function saveMapping() {
let inlineAddVisible = false; let inlineAddVisible = false;
function showInlineAddRow() { function showInlineAddRow() {
// On mobile, open the full modal instead
if (window.innerWidth < 768) {
new bootstrap.Modal(document.getElementById('addModal')).show();
return;
}
if (inlineAddVisible) return; if (inlineAddVisible) return;
inlineAddVisible = true; inlineAddVisible = true;
const tbody = document.getElementById('mappingsBody'); const container = document.getElementById('mappingsFlatList');
const row = document.createElement('tr'); const row = document.createElement('div');
row.id = 'inlineAddRow'; row.id = 'inlineAddRow';
row.className = 'table-info'; row.className = 'flat-row';
row.style.background = '#eff6ff';
row.style.gap = '0.5rem';
row.innerHTML = ` row.innerHTML = `
<td colspan="2"> <input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:160px"> <div class="position-relative" style="flex:1;min-width:0">
</td>
<td colspan="2" class="position-relative">
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off"> <input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div> <div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
<small class="text-muted" id="inlineSelected"></small> <small class="text-muted" id="inlineSelected"></small>
</td> </div>
<td>-</td> <input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:70px" placeholder="Cant.">
<td> <input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:70px" placeholder="%">
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:80px"> <button class="btn btn-sm btn-success" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
</td> <button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
<td>
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:80px">
</td>
<td>-</td>
<td>
<button class="btn btn-sm btn-success me-1" onclick="saveInlineMapping()" title="Salveaza"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary" onclick="cancelInlineAdd()" title="Anuleaza"><i class="bi bi-x-lg"></i></button>
</td>
`; `;
tbody.insertBefore(row, tbody.firstChild); container.insertBefore(row, container.firstChild);
document.getElementById('inlineSku').focus(); document.getElementById('inlineSku').focus();
// Setup autocomplete for inline CODMAT // Setup autocomplete for inline CODMAT
@@ -515,51 +541,6 @@ function cancelInlineAdd() {
inlineAddVisible = false; inlineAddVisible = false;
} }
// ── Inline Edit ──────────────────────────────────
function editCell(td, sku, codmat, field, currentValue) {
if (td.querySelector('input')) return;
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.value = currentValue;
input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
input.style.width = '80px';
const originalText = td.textContent;
td.textContent = '';
td.appendChild(input);
input.focus();
input.select();
const save = async () => {
const newValue = parseFloat(input.value);
if (isNaN(newValue) || newValue === currentValue) {
td.textContent = originalText;
return;
}
try {
const body = {};
body[field] = newValue;
const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) { loadMappings(); }
else { td.textContent = originalText; alert('Eroare: ' + (data.error || 'Update failed')); }
} catch (err) { td.textContent = originalText; }
};
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') { td.textContent = originalText; }
});
}
// ── Toggle Active with Toast Undo ──────────────── // ── Toggle Active with Toast Undo ────────────────
async function toggleActive(sku, codmat, currentActive) { async function toggleActive(sku, codmat, currentActive) {
@@ -714,7 +695,3 @@ function handleMappingConflict(data) {
} }
} }
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

214
api/app/static/js/shared.js Normal file
View File

@@ -0,0 +1,214 @@
// shared.js - Unified utilities for all pages
// ── HTML escaping ─────────────────────────────────
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── Date formatting ───────────────────────────────
function fmtDate(dateStr, includeSeconds) {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
const hasTime = dateStr.includes(':');
if (hasTime) {
const opts = { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' };
if (includeSeconds) opts.second = '2-digit';
return d.toLocaleString('ro-RO', opts);
}
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return dateStr; }
}
// ── Unified Pagination ────────────────────────────
/**
* Renders a full pagination bar with First/Prev/numbers/Next/Last.
* @param {number} currentPage
* @param {number} totalPages
* @param {string} goToFnName - name of global function to call with page number
* @param {object} [opts] - optional: { perPage, perPageFn, perPageOptions }
* @returns {string} HTML string
*/
function renderUnifiedPagination(currentPage, totalPages, goToFnName, opts) {
if (totalPages <= 1 && !(opts && opts.perPage)) {
return '';
}
let html = '<div class="d-flex align-items-center gap-2 flex-wrap">';
// Per-page selector
if (opts && opts.perPage && opts.perPageFn) {
const options = opts.perPageOptions || [25, 50, 100, 250];
html += `<label class="per-page-label">Per pagina: <select class="select-compact ms-1" onchange="${opts.perPageFn}(this.value)">`;
options.forEach(v => {
html += `<option value="${v}"${v === opts.perPage ? ' selected' : ''}>${v}</option>`;
});
html += '</select></label>';
}
if (totalPages <= 1) {
html += '</div>';
return html;
}
html += '<div class="pagination-bar">';
// First
html += `<button class="page-btn" onclick="${goToFnName}(1)" ${currentPage <= 1 ? 'disabled' : ''}>&laquo;</button>`;
// Prev
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>&lsaquo;</button>`;
// Page numbers with ellipsis
const range = 2;
let pages = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - range && i <= currentPage + range)) {
pages.push(i);
}
}
let lastP = 0;
pages.forEach(p => {
if (lastP && p - lastP > 1) {
html += `<span class="page-btn disabled page-ellipsis">…</span>`;
}
html += `<button class="page-btn page-number${p === currentPage ? ' active' : ''}" onclick="${goToFnName}(${p})">${p}</button>`;
lastP = p;
});
// Next
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>&rsaquo;</button>`;
// Last
html += `<button class="page-btn" onclick="${goToFnName}(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>&raquo;</button>`;
html += '</div></div>';
return html;
}
// ── Context Menu ──────────────────────────────────
let _activeContextMenu = null;
function closeAllContextMenus() {
if (_activeContextMenu) {
_activeContextMenu.remove();
_activeContextMenu = null;
}
}
document.addEventListener('click', closeAllContextMenus);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllContextMenus();
});
/**
* Show a context menu at the given position.
* @param {number} x - clientX
* @param {number} y - clientY
* @param {Array} items - [{label, action, danger}]
*/
function showContextMenu(x, y, items) {
closeAllContextMenus();
const menu = document.createElement('div');
menu.className = 'context-menu';
items.forEach(item => {
const btn = document.createElement('button');
btn.className = 'context-menu-item' + (item.danger ? ' text-danger' : '');
btn.textContent = item.label;
btn.addEventListener('click', (e) => {
e.stopPropagation();
closeAllContextMenus();
item.action();
});
menu.appendChild(btn);
});
document.body.appendChild(menu);
_activeContextMenu = menu;
// Position menu, keeping it within viewport
const rect = menu.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = x;
let top = y;
if (left + 160 > vw) left = vw - 165;
if (top + rect.height > vh) top = vh - rect.height - 5;
menu.style.left = left + 'px';
menu.style.top = top + 'px';
}
/**
* Wire right-click on desktop + three-dots button on mobile for a table.
* @param {string} rowSelector - CSS selector for clickable rows
* @param {function} menuItemsFn - called with row element, returns [{label, action, danger}]
*/
function initContextMenus(rowSelector, menuItemsFn) {
document.addEventListener('contextmenu', (e) => {
const row = e.target.closest(rowSelector);
if (!row) return;
e.preventDefault();
showContextMenu(e.clientX, e.clientY, menuItemsFn(row));
});
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.context-menu-trigger');
if (!trigger) return;
const row = trigger.closest(rowSelector);
if (!row) return;
e.stopPropagation();
const rect = trigger.getBoundingClientRect();
showContextMenu(rect.left, rect.bottom + 2, menuItemsFn(row));
});
}
// ── Mobile segmented control ─────────────────────
/**
* Render a Bootstrap btn-group segmented control for mobile.
* @param {string} containerId - ID of the container div
* @param {Array} pills - [{label, count, colorClass, value, active}]
* @param {function} onSelect - callback(value)
*/
function renderMobileSegmented(containerId, pills, onSelect) {
const container = document.getElementById(containerId);
if (!container) return;
const btnStyle = 'font-size:0.75rem;height:32px;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:0.25rem;flex:1;padding:0 0.25rem';
container.innerHTML = `<div class="btn-group btn-group-sm w-100">${pills.map(p => {
const cls = p.active ? 'btn btn-primary' : 'btn btn-outline-secondary';
const countColor = (!p.active && p.colorClass) ? ` class="${p.colorClass}"` : '';
return `<button type="button" class="${cls}" style="${btnStyle}" data-seg-value="${esc(p.value)}">${esc(p.label)} <b${countColor}>${p.count}</b></button>`;
}).join('')}</div>`;
container.querySelectorAll('[data-seg-value]').forEach(btn => {
btn.addEventListener('click', () => onSelect(btn.dataset.segValue));
});
}
// ── Dot helper ────────────────────────────────────
function statusDot(status) {
switch ((status || '').toUpperCase()) {
case 'IMPORTED':
case 'ALREADY_IMPORTED':
case 'COMPLETED':
case 'RESOLVED':
return '<span class="dot dot-green"></span>';
case 'SKIPPED':
case 'UNRESOLVED':
case 'INCOMPLETE':
return '<span class="dot dot-yellow"></span>';
case 'ERROR':
case 'FAILED':
return '<span class="dot dot-red"></span>';
default:
return '<span class="dot dot-gray"></span>';
}
}

View File

@@ -6,52 +6,27 @@
<title>{% block title %}GoMag Import Manager{% endblock %}</title> <title>{% block title %}GoMag Import Manager{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet"> <link href="/static/css/style.css?v=5" rel="stylesheet">
</head> </head>
<body> <body>
<!-- Sidebar --> <!-- Top Navbar -->
<nav id="sidebar" class="sidebar"> <nav class="top-navbar">
<div class="sidebar-header"> <div class="navbar-brand">GoMag Import</div>
<h5><i class="bi bi-box-seam"></i> GoMag Import</h5> <div class="navbar-links">
</div> <a href="/" class="nav-tab {% block nav_dashboard %}{% endblock %}"><span class="d-none d-md-inline">Dashboard</span><span class="d-md-none">Acasa</span></a>
<ul class="nav flex-column"> <a href="/mappings" class="nav-tab {% block nav_mappings %}{% endblock %}"><span class="d-none d-md-inline">Mapari SKU</span><span class="d-md-none">Mapari</span></a>
<li class="nav-item"> <a href="/missing-skus" class="nav-tab {% block nav_missing %}{% endblock %}"><span class="d-none d-md-inline">SKU-uri Lipsa</span><span class="d-md-none">Lipsa</span></a>
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/"> <a href="/logs" class="nav-tab {% block nav_logs %}{% endblock %}"><span class="d-none d-md-inline">Jurnale Import</span><span class="d-md-none">Jurnale</span></a>
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
<i class="bi bi-link-45deg"></i> Mapari SKU
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_missing %}{% endblock %}" href="/missing-skus">
<i class="bi bi-exclamation-triangle"></i> SKU-uri Lipsa
</a>
</li>
<li class="nav-item">
<a class="nav-link {% block nav_logs %}{% endblock %}" href="/logs">
<i class="bi bi-journal-text"></i> Jurnale Import
</a>
</li>
</ul>
<div class="sidebar-footer">
<small class="text-muted">v1.0</small>
</div> </div>
</nav> </nav>
<!-- Mobile toggle -->
<button class="btn btn-dark d-md-none sidebar-toggle" type="button" onclick="document.getElementById('sidebar').classList.toggle('show')">
<i class="bi bi-list"></i>
</button>
<!-- Main content --> <!-- Main content -->
<main class="main-content"> <main class="main-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/shared.js?v=5"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -11,7 +11,7 @@
<div class="sync-card-controls"> <div class="sync-card-controls">
<span id="syncStatusDot" class="sync-status-dot idle"></span> <span id="syncStatusDot" class="sync-status-dot idle"></span>
<span id="syncStatusText" class="text-secondary">Inactiv</span> <span id="syncStatusText" class="text-secondary">Inactiv</span>
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2">
<label class="d-flex align-items-center gap-1 text-muted"> <label class="d-flex align-items-center gap-1 text-muted">
Auto: Auto:
<input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()"> <input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()">
@@ -63,31 +63,19 @@
<input type="date" id="periodEnd" class="select-compact"> <input type="date" id="periodEnd" class="select-compact">
</div> </div>
<!-- Status pills --> <!-- Status pills -->
<button class="filter-pill active" data-status="all">Toate <span class="filter-count" id="cntAll">0</span></button> <button class="filter-pill active d-none d-md-inline-flex" data-status="all">Toate <span class="filter-count fc-neutral" id="cntAll">0</span></button>
<button class="filter-pill" data-status="IMPORTED">Importat <span class="filter-count" id="cntImp">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="IMPORTED">Importat <span class="filter-count fc-green" id="cntImp">0</span></button>
<button class="filter-pill" data-status="SKIPPED">Omise <span class="filter-count" id="cntSkip">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="cntSkip">0</span></button>
<button class="filter-pill" data-status="ERROR">Erori <span class="filter-count" id="cntErr">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="ERROR">Erori <span class="filter-count fc-red" id="cntErr">0</span></button>
<button class="filter-pill" data-status="UNINVOICED">Nefact. <span class="filter-count" id="cntNef">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefact. <span class="filter-count fc-neutral" id="cntNef">0</span></button>
<!-- Search (integrated, end of row) --> <!-- Search (integrated, end of row) -->
<input type="search" id="orderSearch" placeholder="Cauta..." class="search-input"> <input type="search" id="orderSearch" placeholder="Cauta..." class="search-input">
</div> </div>
<div class="d-md-none mb-2" id="dashMobileSeg"></div>
</div> </div>
<!-- Pagination top bar --> <div id="dashPaginationTop" class="pag-strip"></div>
<div class="card-body py-1 px-3 border-bottom d-flex justify-content-between align-items-center gap-2">
<small class="text-muted" id="dashPageInfoTop"></small>
<div class="d-flex align-items-center gap-2">
<label class="text-muted text-nowrap">Per pagina:
<select id="perPageSelect" class="select-compact ms-1" onchange="dashChangePerPage(this.value)">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</label>
<div id="dashPaginationTop" class="d-flex align-items-center gap-2"></div>
</div>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="dashMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
@@ -108,10 +96,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="dashPageInfo"></small>
<div id="dashPagination" class="d-flex align-items-center gap-2"></div>
</div>
</div> </div>
<!-- Order Detail Modal --> <!-- Order Detail Modal -->
@@ -193,5 +178,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/dashboard.js"></script> <script src="/static/js/dashboard.js?v=5"></script>
{% endblock %} {% endblock %}

View File

@@ -5,59 +5,64 @@
{% block content %} {% block content %}
<h4 class="mb-4">Jurnale Import</h4> <h4 class="mb-4">Jurnale Import</h4>
<!-- Sync Run Selector --> <!-- Sync Run Selector + Status + Controls (single card) -->
<div class="card mb-4"> <div class="card mb-3">
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex align-items-center gap-3"> <!-- Desktop layout -->
<div class="d-none d-md-flex align-items-center gap-3 flex-wrap">
<label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label> <label class="form-label mb-0 fw-bold text-nowrap">Sync Run:</label>
<select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)"> <select class="form-select form-select-sm" id="runsDropdown" onchange="selectRun(this.value)" style="max-width:400px">
<option value="">Se incarca...</option> <option value="">Se incarca...</option>
</select> </select>
<button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button> <button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button>
<span id="logStatusBadge" style="font-weight:600">-</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked>
<label class="form-check-label small" for="autoRefreshToggle">Auto-refresh</label>
</div>
<button class="btn btn-sm btn-outline-secondary" id="btnShowTextLog" onclick="toggleTextLog()">
<i class="bi bi-file-text"></i> Log text brut
</button>
</div>
<!-- Mobile compact layout -->
<div class="d-flex d-md-none align-items-center gap-2">
<span id="mobileRunDot" class="sync-status-dot idle" style="width:8px;height:8px"></span>
<select class="form-select form-select-sm flex-grow-1" id="runsDropdownMobile" onchange="selectRun(this.value)" style="font-size:0.8rem">
<option value="">Se incarca...</option>
</select>
<button class="btn btn-sm btn-outline-secondary" onclick="loadRuns()" title="Reincarca"><i class="bi bi-arrow-clockwise"></i></button>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown"><i class="bi bi-three-dots-vertical"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<label class="dropdown-item d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" id="autoRefreshToggleMobile" checked> Auto-refresh
</label>
</li>
<li><a class="dropdown-item" href="#" onclick="toggleTextLog();return false"><i class="bi bi-file-text me-1"></i> Log text brut</a></li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Detail Viewer (shown when run selected) --> <!-- Detail Viewer (shown when run selected) -->
<div id="logViewerSection" style="display:none;"> <div id="logViewerSection" style="display:none;">
<!-- Filter bar --> <!-- Filter pills -->
<div class="card mb-3"> <div class="filter-bar mb-3" id="orderFilterPills">
<div class="card-header d-flex justify-content-between align-items-center"> <button class="filter-pill active d-none d-md-inline-flex" data-log-status="all">Toate <span class="filter-count fc-neutral" id="countAll">0</span></button>
<span>Run: <code id="logRunId"></code> <span class="badge bg-secondary" id="logStatusBadge">-</span></span> <button class="filter-pill d-none d-md-inline-flex" data-log-status="IMPORTED">Importate <span class="filter-count fc-green" id="countImported">0</span></button>
<div class="d-flex align-items-center gap-3"> <button class="filter-pill d-none d-md-inline-flex" data-log-status="ALREADY_IMPORTED">Deja imp. <span class="filter-count fc-blue" id="countAlreadyImported">0</span></button>
<div class="form-check form-switch mb-0"> <button class="filter-pill d-none d-md-inline-flex" data-log-status="SKIPPED">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked> <button class="filter-pill d-none d-md-inline-flex" data-log-status="ERROR">Erori <span class="filter-count fc-red" id="countError">0</span></button>
<label class="form-check-label small" for="autoRefreshToggle">Auto-refresh</label>
</div>
<button class="btn btn-sm btn-outline-secondary" id="btnShowTextLog" onclick="toggleTextLog()">
<i class="bi bi-file-text"></i> Log text brut
</button>
</div>
</div>
<div class="card-body py-2">
<div class="btn-group" role="group" id="orderFilterBtns">
<button type="button" class="btn btn-sm btn-primary" onclick="filterOrders('all')">
Toate <span class="badge bg-light text-dark ms-1" id="countAll">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="filterOrders('IMPORTED')">
Importate <span class="badge bg-light text-dark ms-1" id="countImported">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-info" onclick="filterOrders('ALREADY_IMPORTED')">
Deja imp. <span class="badge bg-light text-dark ms-1" id="countAlreadyImported">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-warning" onclick="filterOrders('SKIPPED')">
Omise <span class="badge bg-light text-dark ms-1" id="countSkipped">0</span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="filterOrders('ERROR')">
Erori <span class="badge bg-light text-dark ms-1" id="countError">0</span>
</button>
</div>
</div>
</div> </div>
<div class="d-md-none mb-2" id="logsMobileSeg"></div>
<!-- Orders table --> <!-- Orders table -->
<div class="card mb-3"> <div class="card mb-3">
<div id="ordersPaginationTop" class="pag-strip"></div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="logsMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
@@ -76,10 +81,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="ordersPagination" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="ordersPageInfo"></small>
<div id="ordersPagination" class="d-flex align-items-center gap-2"></div>
</div>
</div> </div>
<!-- Collapsible text log --> <!-- Collapsible text log -->
@@ -173,5 +175,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/logs.js"></script> <script src="/static/js/logs.js?v=5"></script>
{% endblock %} {% endblock %}

View File

@@ -5,12 +5,23 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Mapari SKU</h4> <h4 class="mb-0">Mapari SKU</h4>
<div> <div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button> <!-- Desktop buttons -->
<button class="btn btn-sm btn-outline-secondary" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</button>
<button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> Adauga Mapare</button> <button class="btn btn-sm btn-outline-primary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import CSV</button>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button> <button class="btn btn-sm btn-primary" onclick="showInlineAddRow()"><i class="bi bi-plus-lg"></i> <span class="d-none d-md-inline">Adauga Mapare</span><span class="d-md-none">Mapare</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
<!-- Mobile ⋯ dropdown -->
<div class="dropdown d-md-none">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-three-dots-vertical"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="downloadTemplate();return false"><i class="bi bi-file-earmark-arrow-down me-1"></i> Template CSV</a></li>
<li><a class="dropdown-item" href="#" onclick="exportCsv();return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload me-1"></i> Import CSV</a></li>
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right me-1"></i> Formular complet</a></li>
</ul>
</div>
</div> </div>
</div> </div>
@@ -38,41 +49,23 @@
<!-- Percentage filter pills --> <!-- Percentage filter pills -->
<div class="filter-bar" id="mappingsFilterBar"> <div class="filter-bar" id="mappingsFilterBar">
<button class="filter-pill active" data-pct="all">Toate <span class="filter-count" id="mCntAll">0</span></button> <button class="filter-pill active d-none d-md-inline-flex" data-pct="all">Toate <span class="filter-count fc-neutral" id="mCntAll">0</span></button>
<button class="filter-pill" data-pct="complete">Complete &#10003; <span class="filter-count" id="mCntComplete">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-pct="complete">Complete <span class="filter-count fc-green" id="mCntComplete">0</span></button>
<button class="filter-pill" data-pct="incomplete">Incomplete &#9888; <span class="filter-count" id="mCntIncomplete">0</span></button> <button class="filter-pill d-none d-md-inline-flex" data-pct="incomplete">Incomplete <span class="filter-count fc-yellow" id="mCntIncomplete">0</span></button>
</div> </div>
<div class="d-md-none mb-2" id="mappingsMobileSeg"></div>
<!-- Table --> <!-- Top pagination -->
<div id="mappingsPagTop" class="pag-strip"></div>
<!-- Flat-row list (unified desktop + mobile) -->
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div id="mappingsFlatList" class="mappings-flat-list">
<table class="table table-hover mb-0"> <div class="flat-row text-muted py-4 justify-content-center">Se incarca...</div>
<thead>
<tr>
<th class="sortable" onclick="sortBy('sku')">SKU <span class="sort-icon" data-col="sku"></span></th>
<th>Produs Web</th>
<th class="sortable" onclick="sortBy('codmat')">CODMAT <span class="sort-icon" data-col="codmat"></span></th>
<th class="sortable" onclick="sortBy('denumire')">Denumire <span class="sort-icon" data-col="denumire"></span></th>
<th>UM</th>
<th class="sortable" onclick="sortBy('cantitate_roa')">Cantitate ROA <span class="sort-icon" data-col="cantitate_roa"></span></th>
<th class="sortable" onclick="sortBy('procent_pret')">Procent Pret <span class="sort-icon" data-col="procent_pret"></span></th>
<th class="sortable" onclick="sortBy('activ')">Activ <span class="sort-icon" data-col="activ"></span></th>
<th style="width:100px">Actiuni</th>
</tr>
</thead>
<tbody id="mappingsBody">
<tr><td colspan="9" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody>
</table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center"> <div id="mappingsPagBottom" class="pag-strip pag-strip-bottom"></div>
<small class="text-muted" id="pageInfo"></small>
<nav>
<ul class="pagination pagination-sm mb-0" id="pagination"></ul>
</nav>
</div>
</div> </div>
<!-- Add/Edit Modal with multi-CODMAT support (R11) --> <!-- Add/Edit Modal with multi-CODMAT support (R11) -->
@@ -161,5 +154,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/mappings.js"></script> <script src="/static/js/mappings.js?v=5"></script>
{% endblock %} {% endblock %}

View File

@@ -5,63 +5,65 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">SKU-uri Lipsa</h4> <h4 class="mb-0">SKU-uri Lipsa</h4>
<div> <div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()"> <button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportMissingCsv()">
<i class="bi bi-download"></i> Export CSV <i class="bi bi-download"></i> Export CSV
</button> </button>
<!-- Mobile ⋯ dropdown -->
<div class="dropdown d-md-none">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-three-dots-vertical"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="document.getElementById('rescanBtn').click();return false"><i class="bi bi-arrow-clockwise me-1"></i> Re-scan</a></li>
<li><a class="dropdown-item" href="#" onclick="exportMissingCsv();return false"><i class="bi bi-download me-1"></i> Export CSV</a></li>
</ul>
</div>
</div> </div>
</div> </div>
<!-- Unified filter bar --> <!-- Unified filter bar -->
<div class="filter-bar" id="skusFilterBar"> <div class="filter-bar" id="skusFilterBar">
<button class="filter-pill active" data-sku-status="unresolved"> <button class="filter-pill active d-none d-md-inline-flex" data-sku-status="unresolved">
Nerezolvate <span class="filter-count" id="cntUnres">0</span> Nerezolvate <span class="filter-count fc-yellow" id="cntUnres">0</span>
</button> </button>
<button class="filter-pill" data-sku-status="resolved"> <button class="filter-pill d-none d-md-inline-flex" data-sku-status="resolved">
Rezolvate <span class="filter-count" id="cntRes">0</span> Rezolvate <span class="filter-count fc-green" id="cntRes">0</span>
</button> </button>
<button class="filter-pill" data-sku-status="all"> <button class="filter-pill d-none d-md-inline-flex" data-sku-status="all">
Toate <span class="filter-count" id="cntAllSkus">0</span> Toate <span class="filter-count fc-neutral" id="cntAllSkus">0</span>
</button> </button>
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input"> <input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
<button id="rescanBtn" class="btn btn-sm btn-secondary ms-2">&#8635; Re-scan</button> <button id="rescanBtn" class="btn btn-sm btn-secondary ms-2 d-none d-md-inline-flex">&#8635; Re-scan</button>
<span id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;"> <span id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;">
<span class="sync-live-dot"></span> <span class="sync-live-dot"></span>
<span id="rescanProgressText">Scanare...</span> <span id="rescanProgressText">Scanare...</span>
</span> </span>
</div> </div>
<div class="d-md-none mb-2" id="skusMobileSeg"></div>
<!-- Result banner --> <!-- Result banner -->
<div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div> <div id="rescanResult" class="result-banner" style="display:none;margin-bottom:0.75rem;"></div>
<div id="skusPagTop" class="pag-strip mb-2"></div>
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
<div id="missingMobileList" class="mobile-list"></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th>Status</th>
<th>SKU</th> <th>SKU</th>
<th>Produs</th> <th>Produs</th>
<th>Nr. Comenzi</th>
<th>Client</th>
<th>First Seen</th>
<th>Status</th>
<th>Actiune</th> <th>Actiune</th>
</tr> </tr>
</thead> </thead>
<tbody id="missingBody"> <tbody id="missingBody">
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr> <tr><td colspan="4" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="card-footer">
<small class="text-muted" id="missingInfo"></small>
</div>
</div> </div>
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
<nav id="paginationNav" class="mt-3">
<ul class="pagination justify-content-center" id="paginationControls"></ul>
</nav>
<!-- Map SKU Modal with multi-CODMAT support (R11) --> <!-- Map SKU Modal with multi-CODMAT support (R11) -->
<div class="modal fade" id="mapModal" tabindex="-1"> <div class="modal fade" id="mapModal" tabindex="-1">
@@ -98,7 +100,9 @@ let currentMapSku = '';
let mapAcTimeout = null; let mapAcTimeout = null;
let currentPage = 1; let currentPage = 1;
let skuStatusFilter = 'unresolved'; let skuStatusFilter = 'unresolved';
const perPage = 20; let missingPerPage = 20;
function missingChangePerPage(val) { missingPerPage = parseInt(val) || 20; currentPage = 1; loadMissingSkus(); }
// ── Filter pills ────────────────────────────────── // ── Filter pills ──────────────────────────────────
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => { document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
@@ -158,7 +162,7 @@ function loadMissingSkus(page) {
const resolvedVal = resolvedParamFor(skuStatusFilter); const resolvedVal = resolvedParamFor(skuStatusFilter);
params.set('resolved', resolvedVal); params.set('resolved', resolvedVal);
params.set('page', currentPage); params.set('page', currentPage);
params.set('per_page', perPage); params.set('per_page', missingPerPage);
const search = document.getElementById('skuSearch')?.value?.trim(); const search = document.getElementById('skuSearch')?.value?.trim();
if (search) params.set('search', search); if (search) params.set('search', search);
@@ -170,12 +174,27 @@ function loadMissingSkus(page) {
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0; if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
if (el('cntRes')) el('cntRes').textContent = c.resolved || 0; if (el('cntRes')) el('cntRes').textContent = c.resolved || 0;
if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 0; if (el('cntAllSkus')) el('cntAllSkus').textContent = c.total || 0;
// Mobile segmented control
renderMobileSegmented('skusMobileSeg', [
{ label: 'Nerez.', count: c.unresolved || 0, value: 'unresolved', active: skuStatusFilter === 'unresolved', colorClass: 'fc-yellow' },
{ label: 'Rez.', count: c.resolved || 0, value: 'resolved', active: skuStatusFilter === 'resolved', colorClass: 'fc-green' },
{ label: 'Toate', count: c.total || 0, value: 'all', active: skuStatusFilter === 'all', colorClass: 'fc-neutral' }
], (val) => {
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(b => b.classList.remove('active'));
const pill = document.querySelector(`.filter-pill[data-sku-status="${val}"]`);
if (pill) pill.classList.add('active');
skuStatusFilter = val;
currentPage = 1;
loadMissingSkus();
});
renderMissingSkusTable(data.skus || data.missing_skus || [], data); renderMissingSkusTable(data.skus || data.missing_skus || [], data);
renderPagination(data); renderPagination(data);
}) })
.catch(err => { .catch(err => {
document.getElementById('missingBody').innerHTML = document.getElementById('missingBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">${err.message}</td></tr>`; `<tr><td colspan="4" class="text-center text-danger">${err.message}</td></tr>`;
}); });
} }
@@ -184,38 +203,24 @@ function loadMissing(page) { loadMissingSkus(page); }
function renderMissingSkusTable(skus, data) { function renderMissingSkusTable(skus, data) {
const tbody = document.getElementById('missingBody'); const tbody = document.getElementById('missingBody');
if (data) { const mobileList = document.getElementById('missingMobileList');
document.getElementById('missingInfo').textContent =
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
}
if (!skus || skus.length === 0) { if (!skus || skus.length === 0) {
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' : const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit'; skuStatusFilter === 'resolved' ? 'Niciun SKU rezolvat' : 'Niciun SKU gasit';
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">${msg}</td></tr>`; tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted py-4">${msg}</td></tr>`;
if (mobileList) mobileList.innerHTML = `<div class="flat-row text-muted py-3 justify-content-center">${msg}</div>`;
return; return;
} }
tbody.innerHTML = skus.map(s => { tbody.innerHTML = skus.map(s => {
const statusBadge = s.resolved const trAttrs = !s.resolved
? '<span class="badge bg-success">Rezolvat</span>' ? ` style="cursor:pointer" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"`
: '<span class="badge bg-warning">Nerezolvat</span>'; : '';
return `<tr${trAttrs}>
let firstCustomer = '-'; <td>${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}</td>
try {
const customers = JSON.parse(s.customers || '[]');
if (customers.length > 0) firstCustomer = customers[0];
} catch (e) { /* ignore */ }
const orderCount = s.order_count != null ? s.order_count : '-';
return `<tr class="${s.resolved ? 'table-light' : ''}">
<td><code>${esc(s.sku)}</code></td> <td><code>${esc(s.sku)}</code></td>
<td>${esc(s.product_name || '-')}</td> <td class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
<td>${esc(orderCount)}</td>
<td><small>${esc(firstCustomer)}</small></td>
<td><small>${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'}</small></td>
<td>${statusBadge}</td>
<td> <td>
${!s.resolved ${!s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza"> ? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;" title="Mapeaza">
@@ -225,31 +230,33 @@ function renderMissingSkusTable(skus, data) {
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
if (mobileList) {
mobileList.innerHTML = skus.map(s => {
const actionHtml = !s.resolved
? `<a href="#" class="btn-map-icon" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}'); return false;"><i class="bi bi-link-45deg"></i></a>`
: `<small class="text-muted">${s.resolved_at ? new Date(s.resolved_at).toLocaleDateString('ro-RO') : ''}</small>`;
const flatRowAttrs = !s.resolved
? ` onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')" style="cursor:pointer"`
: '';
return `<div class="flat-row"${flatRowAttrs}>
${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}
<code class="me-1 text-nowrap">${esc(s.sku)}</code>
<span class="grow truncate">${esc(s.product_name || '-')}</span>
${actionHtml}
</div>`;
}).join('');
}
} }
function renderPagination(data) { function renderPagination(data) {
const ul = document.getElementById('paginationControls'); const pagOpts = { perPage: missingPerPage, perPageFn: 'missingChangePerPage', perPageOptions: [20, 50, 100] };
const total = data.pages || 1; const infoHtml = `<small class="text-muted me-auto">Total: ${data.total || 0} | Pagina ${data.page || 1} din ${data.pages || 1}</small>`;
const page = data.page || 1; const pagHtml = infoHtml + renderUnifiedPagination(data.page || 1, data.pages || 1, 'loadMissing', pagOpts);
if (total <= 1) { ul.innerHTML = ''; return; } const top = document.getElementById('skusPagTop');
const bot = document.getElementById('skusPagBottom');
let html = ''; if (top) top.innerHTML = pagHtml;
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}"> if (bot) bot.innerHTML = pagHtml;
<a class="page-link" href="#" onclick="loadMissingSkus(${page - 1}); return false;">Anterior</a></li>`;
const range = 2;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= page - range && i <= page + range)) {
html += `<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadMissingSkus(${i}); return false;">${i}</a></li>`;
} else if (i === page - range - 1 || i === page + range + 1) {
html += `<li class="page-item disabled"><span class="page-link">…</span></li>`;
}
}
html += `<li class="page-item ${page >= total ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadMissingSkus(${page + 1}); return false;">Urmator</a></li>`;
ul.innerHTML = html;
} }
// ── Multi-CODMAT Map Modal ─────────────────────── // ── Multi-CODMAT Map Modal ───────────────────────
@@ -384,9 +391,5 @@ function exportMissingCsv() {
window.location.href = '/api/validate/missing-skus-csv'; window.location.href = '/api/validate/missing-skus-csv';
} }
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
</script> </script>
{% endblock %} {% endblock %}