Compare commits
4 Commits
5a0ea462e5
...
ac8a01eb3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac8a01eb3e | ||
|
|
c4fa643eca | ||
|
|
9a6bec33ff | ||
|
|
680f670037 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@
|
||||
*.err
|
||||
*.ERR
|
||||
*.log
|
||||
/screenshots
|
||||
/.playwright-mcp
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
326
CLAUDE.md
326
CLAUDE.md
@@ -1,270 +1,128 @@
|
||||
# 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
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
```
|
||||
Importa automat comenzi din GoMag in sistemul ERP ROA Oracle. Stack complet Python/FastAPI.
|
||||
|
||||
### Tech Stack
|
||||
- **Backend:** Oracle PL/SQL packages
|
||||
- **Integration:** Visual FoxPro 9
|
||||
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
|
||||
- **Data:** Oracle 11g/12c (ROA system), SQLite (local 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
|
||||
- **API + Admin:** FastAPI + Jinja2 + Bootstrap 5.3
|
||||
- **GoMag Integration:** Python (`gomag_client.py` — API download with pagination)
|
||||
- **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
|
||||
- **Database:** Oracle PL/SQL packages (IMPORT_PARTENERI, IMPORT_COMENZI) + SQLite (tracking)
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Database Setup
|
||||
```bash
|
||||
# Start Oracle container
|
||||
docker-compose up -d
|
||||
# Run FastAPI server
|
||||
cd api && uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
|
||||
|
||||
# Run database scripts in order
|
||||
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
|
||||
# Tests
|
||||
python api/test_app_basic.py # Test A - fara 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
|
||||
|
||||
```
|
||||
/
|
||||
├── api/ # ✅ Flask Admin & Database
|
||||
│ ├── admin.py # ✅ Flask app with Oracle pool
|
||||
│ ├── database-scripts/ # ✅ Oracle SQL scripts
|
||||
│ │ ├── 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
|
||||
screenshots/
|
||||
├── before/ # Starea inainte de modificari
|
||||
├── preview/ # Mockup-uri aprobate de user
|
||||
└── after/ # Verificare post-implementare
|
||||
```
|
||||
|
||||
## 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)
|
||||
```env
|
||||
ORACLE_USER=CONTAFIN_ORACLE
|
||||
ORACLE_PASSWORD=********
|
||||
ORACLE_DSN=ROA_ROMFAST
|
||||
TNS_ADMIN=/app
|
||||
INSTANTCLIENTPATH=/opt/oracle/instantclient
|
||||
## Architecture
|
||||
|
||||
```
|
||||
[GoMag API] → [Python Sync Service] → [Oracle PL/SQL] → [FastAPI Admin]
|
||||
↓ ↓ ↑ ↑
|
||||
JSON Orders Download/Parse/Import Store/Update Dashboard + Config
|
||||
```
|
||||
|
||||
### 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
|
||||
- Individuals (CUI 13 digits): separate nume/prenume
|
||||
- Default address: București Sectorul 1
|
||||
- Default address: Bucuresti Sectorul 1
|
||||
- All new partners: ID_UTIL = -3
|
||||
|
||||
#### Articles
|
||||
- Simple SKUs: found directly in nom_articole (not stored)
|
||||
- Special mappings: only repackaging and complex sets
|
||||
- Inactive articles: activ=0 (not deleted)
|
||||
### Articles & Mappings
|
||||
- Simple SKUs: 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 (must sum to 100%)
|
||||
- Inactive articles: activ=0 (soft delete)
|
||||
|
||||
#### Orders
|
||||
- Uses existing PACK_COMENZI packages
|
||||
### Orders
|
||||
- Default: ID_GESTIUNE=1, ID_SECTIE=1, ID_POL=0
|
||||
- Delivery date = order date + 1 day
|
||||
- All orders: INTERNA=0 (external)
|
||||
|
||||
## Phase Implementation Status
|
||||
## Configuration
|
||||
|
||||
### ✅ Phase 1: Database Foundation (75% Complete)
|
||||
- **P1-001:** ✅ ARTICOLE_TERTI table + Docker setup
|
||||
- **P1-002:** ✅ IMPORT_PARTENERI package complete
|
||||
- **P1-003:** ✅ IMPORT_COMENZI package complete
|
||||
- **P1-004:** 🔄 Manual testing (READY TO START)
|
||||
|
||||
### ⏳ 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
|
||||
```bash
|
||||
# .env
|
||||
ORACLE_USER=CONTAFIN_ORACLE
|
||||
ORACLE_PASSWORD=********
|
||||
ORACLE_DSN=ROA_ROMFAST
|
||||
TNS_ADMIN=/app
|
||||
```
|
||||
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
|
||||
50
README.md
50
README.md
@@ -5,16 +5,16 @@ System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
|
||||
## 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
|
||||
- **Database:** Oracle PL/SQL packages (PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI)
|
||||
- **Integrare:** Visual FoxPro 9 (gomag-vending.prg, sync-comenzi-web.prg)
|
||||
- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
|
||||
- **Date:** Oracle 11g/12c (schema ROA), SQLite (tracking local)
|
||||
- **API + Admin:** FastAPI + Jinja2 + Bootstrap 5.3
|
||||
- **GoMag Integration:** Python (`gomag_client.py` — download comenzi cu paginare)
|
||||
- **Sync Orchestrator:** Python (`sync_service.py` — download → parse → validate → import)
|
||||
- **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
|
||||
|
||||
```bash
|
||||
# Din project root (gomag/)
|
||||
pip install -r api/requirements.txt
|
||||
cp api/.env.example api/.env
|
||||
# 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/`:
|
||||
|
||||
```bash
|
||||
# Din gomag/
|
||||
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
|
||||
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:**
|
||||
```bash
|
||||
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` |
|
||||
| `FORCE_THIN_MODE` | Thin mode fara Instant Client | `true` |
|
||||
| `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` |
|
||||
| `ID_POL` | ID Politica ROA | `39` |
|
||||
| `ID_GESTIUNE` | ID Gestiune ROA | `0` |
|
||||
@@ -97,7 +93,7 @@ cp api/.env.example api/.env
|
||||
## Structura Proiect
|
||||
|
||||
```
|
||||
gomag/
|
||||
gomag-vending/
|
||||
├── api/ # FastAPI Admin + Dashboard
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # Entry point, lifespan, logging
|
||||
@@ -111,30 +107,28 @@ gomag/
|
||||
│ │ │ ├── validation.py # /api/validate/*
|
||||
│ │ │ └── sync.py # /api/sync/* + /api/dashboard/orders
|
||||
│ │ ├── 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
|
||||
│ │ │ ├── mapping_service.py # CRUD ARTICOLE_TERTI + pct_total
|
||||
│ │ │ ├── sqlite_service.py # Tracking runs/orders/missing SKUs
|
||||
│ │ │ ├── order_reader.py # Citire gomag_orders_page*.json
|
||||
│ │ │ ├── validation_service.py
|
||||
│ │ │ ├── article_service.py
|
||||
│ │ │ ├── invoice_service.py # Verificare facturi ROA
|
||||
│ │ │ └── scheduler_service.py # APScheduler timer
|
||||
│ │ ├── templates/ # Jinja2 HTML
|
||||
│ │ └── static/ # CSS + JS
|
||||
│ ├── 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.example # Template configurare
|
||||
│ ├── test_app_basic.py # Test A - fara Oracle
|
||||
│ ├── test_integration.py # Test C - cu Oracle
|
||||
│ └── 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)
|
||||
├── docs/ # Documentatie (PRD, stories)
|
||||
├── screenshots/ # Before/preview/after pentru UI changes
|
||||
├── start.sh # Script pornire (Linux/WSL)
|
||||
└── CLAUDE.md # Instructiuni pentru AI assistants
|
||||
```
|
||||
@@ -171,10 +165,10 @@ gomag/
|
||||
## Fluxul de Import
|
||||
|
||||
```
|
||||
1. VFP descarca comenzi GoMag API → vfp/output/gomag_orders_page*.json
|
||||
2. FastAPI citeste JSON-urile (order_reader)
|
||||
3. Valideaza SKU-uri contra ARTICOLE_TERTI + NOM_ARTICOLE (validation_service)
|
||||
4. Import_service creeaza/cauta partener in Oracle (shipping person = facturare)
|
||||
1. gomag_client.py descarca comenzi GoMag API → JSON files
|
||||
2. order_reader.py parseaza JSON-urile
|
||||
3. validation_service.py valideaza SKU-uri contra ARTICOLE_TERTI + NOM_ARTICOLE
|
||||
4. import_service.py creeaza/cauta partener in Oracle (shipping person = facturare)
|
||||
5. PACK_IMPORT_COMENZI.importa_comanda_web() insereaza comanda in ROA
|
||||
6. Rezultate salvate in SQLite (orders, sync_run_orders, order_items)
|
||||
```
|
||||
@@ -192,15 +186,15 @@ gomag/
|
||||
|
||||
| Faza | Status | Descriere |
|
||||
|------|--------|-----------|
|
||||
| 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 3-4: FastAPI Dashboard | ✅ Complet | Redesign UI, smart polling, filter bar, paginare, tooltip |
|
||||
| Phase 5: Production | 🔄 In Progress | Logging ✅, Auth ⏳, SMTP ⏳, NSSM service ⏳ |
|
||||
| Phase 1: Database Foundation | Complet | ARTICOLE_TERTI, PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI |
|
||||
| Phase 2: Python Integration | Complet | gomag_client.py, sync_service.py |
|
||||
| Phase 3-4: FastAPI Dashboard | Complet | UI responsive, smart polling, filter bar, paginare |
|
||||
| Phase 5: Production | In Progress | Logging done, Auth + SMTP pending |
|
||||
|
||||
---
|
||||
|
||||
## WSL2 Note
|
||||
|
||||
- `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
|
||||
|
||||
@@ -103,7 +103,8 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
factura_total_fara_tva REAL,
|
||||
factura_total_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_date ON orders(order_date);
|
||||
@@ -301,6 +302,7 @@ def init_sqlite():
|
||||
("factura_total_tva", "REAL"),
|
||||
("factura_total_cu_tva", "REAL"),
|
||||
("invoice_checked_at", "TEXT"),
|
||||
("order_total", "REAL"),
|
||||
]:
|
||||
if col not in order_cols:
|
||||
conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {typedef}")
|
||||
|
||||
@@ -149,6 +149,7 @@ async def sync_run_log(run_id: str):
|
||||
"id_partener": o.get("id_partener"),
|
||||
"error_message": o.get("error_message"),
|
||||
"missing_skus": o.get("missing_skus"),
|
||||
"order_total": o.get("order_total"),
|
||||
"factura_numar": o.get("factura_numar"),
|
||||
"factura_serie": o.get("factura_serie"),
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class OrderData:
|
||||
items: list = field(default_factory=list) # list of OrderItem
|
||||
billing: OrderBilling = field(default_factory=OrderBilling)
|
||||
shipping: Optional[OrderShipping] = None
|
||||
total: float = 0.0
|
||||
payment_name: str = ""
|
||||
delivery_name: str = ""
|
||||
source_file: str = ""
|
||||
@@ -163,6 +164,7 @@ def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
|
||||
items=items,
|
||||
billing=billing,
|
||||
shipping=shipping,
|
||||
total=float(data.get("total", 0) or 0),
|
||||
payment_name=str(payment.get("name", "")) if isinstance(payment, dict) else "",
|
||||
delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "",
|
||||
source_file=source_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,
|
||||
missing_skus: list = None, items_count: int = 0,
|
||||
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."""
|
||||
db = await get_sqlite()
|
||||
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,
|
||||
id_comanda, id_partener, error_message, missing_skus, items_count,
|
||||
last_sync_run_id, shipping_name, billing_name,
|
||||
payment_method, delivery_method)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
payment_method, delivery_method, order_total)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(order_number) DO UPDATE SET
|
||||
status = CASE
|
||||
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),
|
||||
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
|
||||
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
|
||||
order_total = COALESCE(excluded.order_total, orders.order_total),
|
||||
updated_at = datetime('now')
|
||||
""", (order_number, order_date, customer_name, status,
|
||||
id_comanda, id_partener, error_message,
|
||||
json.dumps(missing_skus) if missing_skus else None,
|
||||
items_count, sync_run_id, shipping_name, billing_name,
|
||||
payment_method, delivery_method))
|
||||
payment_method, delivery_method, order_total))
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -122,8 +124,8 @@ async def save_orders_batch(orders_data: list[dict]):
|
||||
(order_number, order_date, customer_name, status,
|
||||
id_comanda, id_partener, error_message, missing_skus, items_count,
|
||||
last_sync_run_id, shipping_name, billing_name,
|
||||
payment_method, delivery_method)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
payment_method, delivery_method, order_total)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(order_number) DO UPDATE SET
|
||||
status = CASE
|
||||
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),
|
||||
payment_method = COALESCE(excluded.payment_method, orders.payment_method),
|
||||
delivery_method = COALESCE(excluded.delivery_method, orders.delivery_method),
|
||||
order_total = COALESCE(excluded.order_total, orders.order_total),
|
||||
updated_at = datetime('now')
|
||||
""", [
|
||||
(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,
|
||||
d.get("items_count", 0), d["sync_run_id"],
|
||||
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
|
||||
])
|
||||
|
||||
|
||||
@@ -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),
|
||||
"shipping_name": shipping_name, "billing_name": billing_name,
|
||||
"payment_method": payment_method, "delivery_method": delivery_method,
|
||||
"order_total": order.total or None,
|
||||
"items": order_items_data,
|
||||
})
|
||||
_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),
|
||||
"shipping_name": shipping_name, "billing_name": billing_name,
|
||||
"payment_method": payment_method, "delivery_method": delivery_method,
|
||||
"order_total": order.total or None,
|
||||
"items": order_items_data,
|
||||
})
|
||||
_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,
|
||||
payment_method=payment_method,
|
||||
delivery_method=delivery_method,
|
||||
order_total=order.total or None,
|
||||
)
|
||||
await sqlite_service.add_sync_run_order(run_id, order.number, "IMPORTED")
|
||||
# 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,
|
||||
payment_method=payment_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_order_items(order.number, order_items_data)
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
/* ── Design tokens ───────────────────────────────── */
|
||||
:root {
|
||||
/* Sidebar */
|
||||
--sidebar-width: 224px;
|
||||
--sidebar-bg: #111827;
|
||||
--sidebar-text: #d1d5db;
|
||||
--sidebar-active-bg: #1f2937;
|
||||
--sidebar-active-text: #ffffff;
|
||||
--sidebar-border: #374151;
|
||||
|
||||
/* Surfaces */
|
||||
--body-bg: #f9fafb;
|
||||
--card-bg: #ffffff;
|
||||
@@ -27,93 +19,89 @@
|
||||
--text-secondary: #4b5563;
|
||||
--text-muted: #6b7280;
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
/* Dots */
|
||||
--dot-green: #22c55e;
|
||||
--dot-yellow: #eab308;
|
||||
--dot-red: #ef4444;
|
||||
}
|
||||
|
||||
/* ── Base ────────────────────────────────────────── */
|
||||
body {
|
||||
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);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────── */
|
||||
.sidebar {
|
||||
/* ── Top Navbar ──────────────────────────────────── */
|
||||
.top-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
background-color: var(--sidebar-bg);
|
||||
padding: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
gap: 1.5rem;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-header h5 {
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
.navbar-links {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.navbar-links::-webkit-scrollbar { display: none; }
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: var(--sidebar-text);
|
||||
font-size: 0.875rem;
|
||||
.nav-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
height: 48px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 0.125rem 0.5rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: var(--sidebar-active-text);
|
||||
background-color: var(--sidebar-active-bg);
|
||||
.nav-tab:hover {
|
||||
color: #111827;
|
||||
background: #f9fafb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: var(--sidebar-active-text);
|
||||
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%;
|
||||
.nav-tab.active {
|
||||
color: var(--blue-600);
|
||||
border-bottom-color: var(--blue-600);
|
||||
}
|
||||
|
||||
/* ── Main content ────────────────────────────────── */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 1.5rem;
|
||||
padding-top: 64px;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Sidebar toggle (mobile) ─────────────────────── */
|
||||
.sidebar-toggle {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 1100;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────────────── */
|
||||
.card {
|
||||
border: none;
|
||||
@@ -126,17 +114,17 @@ body {
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
/* ── Tables ──────────────────────────────────────── */
|
||||
.table {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -147,13 +135,14 @@ body {
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.625rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ── Badges — soft pill style ────────────────────── */
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
@@ -173,7 +162,7 @@ body {
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────── */
|
||||
.btn {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -193,7 +182,7 @@ body {
|
||||
|
||||
/* ── Forms ───────────────────────────────────────── */
|
||||
.form-control, .form-select {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border-color: #d1d5db;
|
||||
@@ -204,12 +193,50 @@ body {
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
/* ── Pagination ──────────────────────────────────── */
|
||||
.pagination .page-link {
|
||||
font-size: 0.875rem;
|
||||
/* ── Unified Pagination Bar ──────────────────────── */
|
||||
.pagination-bar {
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
@@ -220,6 +247,42 @@ body {
|
||||
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 {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
@@ -270,7 +333,7 @@ body {
|
||||
/* ── Order detail modal ──────────────────────────── */
|
||||
.modal-lg .table-sm td,
|
||||
.modal-lg .table-sm th {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
@@ -320,7 +383,7 @@ tr.mapping-deleted td {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
@@ -332,20 +395,12 @@ tr.mapping-deleted td {
|
||||
color: #fff;
|
||||
}
|
||||
.filter-pill.active .filter-count {
|
||||
background: rgba(255,255,255,0.25);
|
||||
color: #fff;
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
display: inline-block;
|
||||
min-width: 1.25rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Search input ────────────────────────────────── */
|
||||
@@ -354,7 +409,7 @@ tr.mapping-deleted td {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
outline: none;
|
||||
min-width: 180px;
|
||||
}
|
||||
@@ -375,7 +430,7 @@ tr.mapping-deleted td {
|
||||
.autocomplete-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.autocomplete-item:hover, .autocomplete-item.active {
|
||||
@@ -387,7 +442,7 @@ tr.mapping-deleted td {
|
||||
}
|
||||
.autocomplete-item .denumire {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Tooltip for Client/Cont ─────────────────────── */
|
||||
@@ -439,7 +494,7 @@ tr.mapping-deleted td {
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
@@ -451,7 +506,7 @@ tr.mapping-deleted td {
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
background: #eff6ff;
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
color: var(--blue-700);
|
||||
border-top: 1px solid #dbeafe;
|
||||
}
|
||||
@@ -489,14 +544,14 @@ tr.mapping-deleted td {
|
||||
display: none;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.period-custom-range.visible { display: flex; }
|
||||
|
||||
/* ── select-compact (used in filter bars) ─────────── */
|
||||
.select-compact {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
@@ -506,14 +561,14 @@ tr.mapping-deleted td {
|
||||
/* ── btn-compact (kept for backward compat) ──────── */
|
||||
.btn-compact {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* ── Result banner ───────────────────────────────── */
|
||||
.result-banner {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #6ee7b7;
|
||||
@@ -521,7 +576,7 @@ tr.mapping-deleted td {
|
||||
|
||||
/* ── Badge-pct (mappings page) ───────────────────── */
|
||||
.badge-pct {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
@@ -529,10 +584,132 @@ tr.mapping-deleted td {
|
||||
.badge-pct.complete { background: #d1fae5; color: #065f46; }
|
||||
.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 ──────────────────────────────────── */
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar { transform: translateX(-100%); }
|
||||
.sidebar.show { transform: translateX(0); }
|
||||
.main-content { margin-left: 0; }
|
||||
.sidebar-toggle { display: block !important; }
|
||||
.top-navbar {
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -92,14 +92,13 @@ function updateSyncPanel(data) {
|
||||
const st = document.getElementById('lastSyncStatus');
|
||||
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';
|
||||
// Updated counts: ↑new =already ⊘skipped ✕errors
|
||||
if (cnt) {
|
||||
const newImp = lr.new_imported || 0;
|
||||
const already = lr.already_imported || 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 <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
|
||||
} 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. <span class="dot dot-yellow me-1"></span>${lr.skipped || 0} omise <span class="dot dot-red me-1"></span>${lr.errors || 0} erori`;
|
||||
}
|
||||
}
|
||||
if (st) {
|
||||
@@ -300,13 +299,13 @@ async function loadDashOrders() {
|
||||
if (o.status !== 'IMPORTED' && o.status !== 'ALREADY_IMPORTED') {
|
||||
invoiceBadge = '<span class="text-muted">-</span>';
|
||||
} 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) {
|
||||
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) : '-';
|
||||
} 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)}')">
|
||||
@@ -314,7 +313,7 @@ async function loadDashOrders() {
|
||||
<td>${dateStr}</td>
|
||||
${renderClientCell(o)}
|
||||
<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>${invoiceBadge}</td>
|
||||
<td>${invoiceTotal}</td>
|
||||
@@ -322,20 +321,53 @@ async function loadDashOrders() {
|
||||
}).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
|
||||
const pag = data.pagination || {};
|
||||
const totalPages = pag.total_pages || data.pages || 1;
|
||||
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 ? `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${dashPage <= 1 ? 'disabled' : ''} onclick="dashGoPage(${dashPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<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 pagOpts = { perPage: dashPerPage, perPageFn: 'dashChangePerPage', perPageOptions: [25, 50, 100, 250] };
|
||||
const pagHtml = `<small class="text-muted me-auto">${totalOrders} comenzi | Pagina ${dashPage} din ${totalPages}</small>` + renderUnifiedPagination(dashPage, totalPages, 'dashGoPage', pagOpts);
|
||||
const pagDiv = document.getElementById('dashPagination');
|
||||
if (pagDiv) pagDiv.innerHTML = pagHtml;
|
||||
const pagDivTop = document.getElementById('dashPaginationTop');
|
||||
@@ -396,16 +428,15 @@ function escHtml(s) {
|
||||
// Alias kept for backward compat with inline handlers in modal
|
||||
function esc(s) { return escHtml(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' });
|
||||
|
||||
function statusLabelText(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);
|
||||
}
|
||||
return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch { return dateStr; }
|
||||
}
|
||||
|
||||
function orderStatusBadge(status) {
|
||||
|
||||
@@ -10,14 +10,6 @@ let currentQmOrderNumber = '';
|
||||
let ordersSortColumn = 'order_date';
|
||||
let ordersSortDirection = 'desc';
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function fmtDuration(startedAt, finishedAt) {
|
||||
if (!startedAt || !finishedAt) return '-';
|
||||
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';
|
||||
}
|
||||
|
||||
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) {
|
||||
switch ((status || '').toLowerCase()) {
|
||||
case 'completed': return '<span class="badge bg-success">completed</span>';
|
||||
case 'running': return '<span class="badge bg-primary">running</span>';
|
||||
case 'failed': return '<span class="badge bg-danger">failed</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
case 'completed': return '<span style="color:#16a34a;font-weight:600">completed</span>';
|
||||
case 'running': return '<span style="color:#2563eb;font-weight:600">running</span>';
|
||||
case 'failed': return '<span style="color:#dc2626;font-weight:600">failed</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 ────────────────────────────────
|
||||
|
||||
async function loadRuns() {
|
||||
@@ -88,6 +80,8 @@ async function loadRuns() {
|
||||
return `<option value="${esc(r.run_id)}" ${selected}>${esc(label)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
const ddMobile = document.getElementById('runsDropdownMobile');
|
||||
if (ddMobile) ddMobile.innerHTML = dd.innerHTML;
|
||||
} catch (err) {
|
||||
const dd = document.getElementById('runsDropdown');
|
||||
dd.innerHTML = `<option value="">Eroare: ${esc(err.message)}</option>`;
|
||||
@@ -110,6 +104,8 @@ async function selectRun(runId) {
|
||||
// Sync dropdown selection
|
||||
const dd = document.getElementById('runsDropdown');
|
||||
if (dd && dd.value !== runId) dd.value = runId;
|
||||
const ddMobile = document.getElementById('runsDropdownMobile');
|
||||
if (ddMobile && ddMobile.value !== runId) ddMobile.value = runId;
|
||||
|
||||
if (!runId) {
|
||||
document.getElementById('logViewerSection').style.display = 'none';
|
||||
@@ -117,8 +113,8 @@ async function selectRun(runId) {
|
||||
}
|
||||
|
||||
document.getElementById('logViewerSection').style.display = '';
|
||||
document.getElementById('logRunId').textContent = runId;
|
||||
document.getElementById('logStatusBadge').innerHTML = '<span class="badge bg-secondary">...</span>';
|
||||
const logRunIdEl = document.getElementById('logRunId'); if (logRunIdEl) logRunIdEl.textContent = runId;
|
||||
document.getElementById('logStatusBadge').innerHTML = '...';
|
||||
document.getElementById('textLogSection').style.display = 'none';
|
||||
|
||||
await loadRunOrders(runId, 'all', 1);
|
||||
@@ -133,13 +129,9 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
if (statusFilter != null) currentFilter = statusFilter;
|
||||
if (page != null) ordersPage = page;
|
||||
|
||||
// Update filter button styles
|
||||
document.querySelectorAll('#orderFilterBtns button').forEach(btn => {
|
||||
btn.className = btn.className.replace(' btn-primary', ' btn-outline-primary')
|
||||
.replace(' btn-success', ' btn-outline-success')
|
||||
.replace(' btn-info', ' btn-outline-info')
|
||||
.replace(' btn-warning', ' btn-outline-warning')
|
||||
.replace(' btn-danger', ' btn-outline-danger');
|
||||
// Update filter pill active state
|
||||
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.logStatus === currentFilter);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -155,15 +147,6 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
const alreadyEl = document.getElementById('countAlreadyImported');
|
||||
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 orders = data.orders || [];
|
||||
|
||||
@@ -178,32 +161,62 @@ async function loadRunOrders(runId, statusFilter, page) {
|
||||
<td><code>${esc(o.order_number)}</code></td>
|
||||
<td>${esc(o.customer_name)}</td>
|
||||
<td>${o.items_count || 0}</td>
|
||||
<td>${orderStatusBadge(o.status)}</td>
|
||||
<td class="text-nowrap">${statusDot(o.status)} ${logStatusText(o.status)}</td>
|
||||
</tr>`;
|
||||
}).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
|
||||
const totalPages = data.pages || 1;
|
||||
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');
|
||||
if (totalPages > 1) {
|
||||
pagDiv.innerHTML = `
|
||||
<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>
|
||||
<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 = '';
|
||||
}
|
||||
if (pagDiv) pagDiv.innerHTML = pagHtml;
|
||||
const pagDivTop = document.getElementById('ordersPaginationTop');
|
||||
if (pagDivTop) pagDivTop.innerHTML = pagHtml;
|
||||
|
||||
// Update run status badge
|
||||
const runRes = await fetch(`/api/sync/run/${encodeURIComponent(runId)}`);
|
||||
const runData = await runRes.json();
|
||||
if (runData.run) {
|
||||
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) {
|
||||
document.getElementById('runOrdersBody').innerHTML =
|
||||
@@ -517,6 +530,12 @@ async function saveQuickMapping() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadRuns();
|
||||
|
||||
document.querySelectorAll('#orderFilterPills .filter-pill').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
filterOrders(this.dataset.logStatus || 'all');
|
||||
});
|
||||
});
|
||||
|
||||
const preselected = document.getElementById('preselectedRun');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
|
||||
@@ -533,4 +552,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
let currentPage = 1;
|
||||
let mappingsPerPage = 50;
|
||||
let currentSearch = '';
|
||||
let searchTimeout = null;
|
||||
let sortColumn = 'sku';
|
||||
@@ -69,6 +70,20 @@ function updatePctCounts(counts) {
|
||||
if (elAll) elAll.textContent = counts.total || 0;
|
||||
if (elComplete) elComplete.textContent = counts.complete || 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 ────────────────────────────────
|
||||
@@ -79,7 +94,7 @@ async function loadMappings() {
|
||||
const params = new URLSearchParams({
|
||||
search: currentSearch,
|
||||
page: currentPage,
|
||||
per_page: 50,
|
||||
per_page: mappingsPerPage,
|
||||
sort_by: sortColumn,
|
||||
sort_dir: sortDirection
|
||||
});
|
||||
@@ -103,116 +118,129 @@ async function loadMappings() {
|
||||
renderPagination(data);
|
||||
updateSortIcons();
|
||||
} catch (err) {
|
||||
document.getElementById('mappingsBody').innerHTML =
|
||||
`<tr><td colspan="9" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
|
||||
document.getElementById('mappingsFlatList').innerHTML =
|
||||
`<div class="flat-row text-danger py-3 justify-content-center">Eroare: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable(mappings, showDeleted) {
|
||||
const tbody = document.getElementById('mappingsBody');
|
||||
const container = document.getElementById('mappingsFlatList');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Group by SKU for visual grouping (R6)
|
||||
let html = '';
|
||||
let prevSku = null;
|
||||
let groupIdx = 0;
|
||||
let skuGroupCounts = {};
|
||||
|
||||
// Count items per SKU
|
||||
let html = '';
|
||||
mappings.forEach(m => {
|
||||
skuGroupCounts[m.sku] = (skuGroupCounts[m.sku] || 0) + 1;
|
||||
});
|
||||
|
||||
mappings.forEach((m, i) => {
|
||||
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) {
|
||||
const badge = isMulti ? ` <span class="badge bg-info">Set (${skuGroupCounts[m.sku]})</span>` : '';
|
||||
// Percentage total badge
|
||||
let pctBadge = '';
|
||||
if (m.pct_total !== undefined) {
|
||||
if (m.is_complete) {
|
||||
pctBadge = ` <span class="badge-pct complete" title="100% alocat">✓ 100%</span>`;
|
||||
} else {
|
||||
const pctVal = typeof m.pct_total === 'number' ? m.pct_total.toFixed(2) : m.pct_total;
|
||||
pctBadge = ` <span class="badge-pct incomplete" title="${pctVal}% alocat">⚠ ${pctVal}%</span>`;
|
||||
pctBadge = m.is_complete
|
||||
? ` <span class="badge-pct complete">✓ 100%</span>`
|
||||
: ` <span class="badge-pct incomplete">${typeof m.pct_total === 'number' ? m.pct_total.toFixed(0) : m.pct_total}%</span>`;
|
||||
}
|
||||
const inactiveStyle = !m.activ && !m.sters ? 'opacity:0.6;' : '';
|
||||
html += `<div class="flat-row" style="background:#f8fafc;font-weight:600;border-top:1px solid #e5e7eb;${inactiveStyle}">
|
||||
<span class="${m.activ ? 'dot dot-green' : 'dot dot-yellow'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
||||
${m.sters ? '' : `onclick="event.stopPropagation();toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}
|
||||
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}">⋮</button>`
|
||||
}
|
||||
skuCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}"><strong>${esc(m.sku)}</strong>${badge}${pctBadge}</td>`;
|
||||
productCell = `<td rowspan="${isMulti ? skuGroupCounts[m.sku] : 1}">${esc(m.product_name || '-')}</td>`;
|
||||
} else {
|
||||
skuCell = '';
|
||||
productCell = '';
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `<tr class="${groupClass} ${inactiveClass} ${deletedClass}">
|
||||
${skuCell}
|
||||
${productCell}
|
||||
<td><code>${esc(m.codmat)}</code></td>
|
||||
<td>${esc(m.denumire || '-')}</td>
|
||||
<td>${esc(m.um || '-')}</td>
|
||||
<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>
|
||||
<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>
|
||||
<td>
|
||||
<span class="badge ${m.activ ? 'bg-success' : 'bg-secondary'}" ${m.sters ? '' : 'style="cursor:pointer"'}
|
||||
${m.sters ? '' : `onclick="toggleActive('${esc(m.sku)}', '${esc(m.codmat)}', ${m.activ})"`}>
|
||||
${m.activ ? 'Activ' : 'Inactiv'}
|
||||
const deletedStyle = m.sters ? 'text-decoration:line-through;opacity:0.5;' : '';
|
||||
html += `<div class="flat-row" style="padding-left:1.5rem;font-size:0.9rem;${deletedStyle}">
|
||||
<code>${esc(m.codmat)}</code>
|
||||
<span class="grow truncate text-muted" style="font-size:0.85rem">${esc(m.denumire || '')}</span>
|
||||
<span class="text-nowrap" style="font-size:0.875rem">
|
||||
<span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
||||
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'cantitate_roa', ${m.cantitate_roa})"`}>x${m.cantitate_roa}</span>
|
||||
· <span class="${m.sters ? '' : 'editable'}" style="cursor:${m.sters ? 'default' : 'pointer'}"
|
||||
${m.sters ? '' : `onclick="editFlatValue(this, '${esc(m.sku)}', '${esc(m.codmat)}', 'procent_pret', ${m.procent_pret})"`}>${m.procent_pret}%</span>
|
||||
</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>`;
|
||||
|
||||
</div>`;
|
||||
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) {
|
||||
const info = document.getElementById('pageInfo');
|
||||
info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`;
|
||||
|
||||
const ul = document.getElementById('pagination');
|
||||
if (data.pages <= 1) { ul.innerHTML = ''; return; }
|
||||
|
||||
let html = '';
|
||||
html += `<li class="page-item ${data.page <= 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goPage(${data.page - 1}); return false;">«</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;">»</a></li>`;
|
||||
|
||||
ul.innerHTML = html;
|
||||
const pagOpts = { perPage: mappingsPerPage, perPageFn: 'mappingsChangePerPage', perPageOptions: [25, 50, 100, 250] };
|
||||
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 top = document.getElementById('mappingsPagTop');
|
||||
const bot = document.getElementById('mappingsPagBottom');
|
||||
if (top) top.innerHTML = pagHtml;
|
||||
if (bot) bot.innerHTML = pagHtml;
|
||||
}
|
||||
|
||||
function mappingsChangePerPage(val) { mappingsPerPage = parseInt(val) || 50; currentPage = 1; loadMappings(); }
|
||||
|
||||
function goPage(p) {
|
||||
currentPage = p;
|
||||
loadMappings();
|
||||
@@ -411,36 +439,34 @@ async function saveMapping() {
|
||||
let inlineAddVisible = false;
|
||||
|
||||
function showInlineAddRow() {
|
||||
// On mobile, open the full modal instead
|
||||
if (window.innerWidth < 768) {
|
||||
new bootstrap.Modal(document.getElementById('addModal')).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (inlineAddVisible) return;
|
||||
inlineAddVisible = true;
|
||||
|
||||
const tbody = document.getElementById('mappingsBody');
|
||||
const row = document.createElement('tr');
|
||||
const container = document.getElementById('mappingsFlatList');
|
||||
const row = document.createElement('div');
|
||||
row.id = 'inlineAddRow';
|
||||
row.className = 'table-info';
|
||||
row.className = 'flat-row';
|
||||
row.style.background = '#eff6ff';
|
||||
row.style.gap = '0.5rem';
|
||||
row.innerHTML = `
|
||||
<td colspan="2">
|
||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:160px">
|
||||
</td>
|
||||
<td colspan="2" class="position-relative">
|
||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
||||
<div class="position-relative" style="flex:1;min-width:0">
|
||||
<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>
|
||||
<small class="text-muted" id="inlineSelected"></small>
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:80px">
|
||||
</td>
|
||||
<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>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm" id="inlineCantitate" value="1" step="0.001" min="0.001" style="width:70px" placeholder="Cant.">
|
||||
<input type="number" class="form-control form-control-sm" id="inlineProcent" value="100" step="0.01" min="0" max="100" style="width:70px" placeholder="%">
|
||||
<button class="btn btn-sm btn-success" 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();
|
||||
|
||||
// Setup autocomplete for inline CODMAT
|
||||
@@ -515,51 +541,6 @@ function cancelInlineAdd() {
|
||||
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 ────────────────
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
214
api/app/static/js/shared.js
Normal file
214
api/app/static/js/shared.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── 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' : ''}>«</button>`;
|
||||
// Prev
|
||||
html += `<button class="page-btn" onclick="${goToFnName}(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>‹</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' : ''}>›</button>`;
|
||||
// Last
|
||||
html += `<button class="page-btn" onclick="${goToFnName}(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>»</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>';
|
||||
}
|
||||
}
|
||||
@@ -6,52 +6,27 @@
|
||||
<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-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>
|
||||
<body>
|
||||
<!-- Sidebar -->
|
||||
<nav id="sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h5><i class="bi bi-box-seam"></i> GoMag Import</h5>
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
|
||||
<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>
|
||||
<!-- Top Navbar -->
|
||||
<nav class="top-navbar">
|
||||
<div class="navbar-brand">GoMag Import</div>
|
||||
<div class="navbar-links">
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
</div>
|
||||
</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 class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<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 %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="sync-card-controls">
|
||||
<span id="syncStatusDot" class="sync-status-dot idle"></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">
|
||||
Auto:
|
||||
<input type="checkbox" id="schedulerToggle" class="cursor-pointer" onchange="toggleScheduler()">
|
||||
@@ -63,31 +63,19 @@
|
||||
<input type="date" id="periodEnd" class="select-compact">
|
||||
</div>
|
||||
<!-- Status pills -->
|
||||
<button class="filter-pill active" data-status="all">Toate <span class="filter-count" 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" data-status="SKIPPED">Omise <span class="filter-count" 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" data-status="UNINVOICED">Nefact. <span class="filter-count" id="cntNef">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 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 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 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 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) -->
|
||||
<input type="search" id="orderSearch" placeholder="Cauta..." class="search-input">
|
||||
</div>
|
||||
<div class="d-md-none mb-2" id="dashMobileSeg"></div>
|
||||
</div>
|
||||
<!-- Pagination top bar -->
|
||||
<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 id="dashPaginationTop" class="pag-strip"></div>
|
||||
<div class="card-body p-0">
|
||||
<div id="dashMobileList" class="mobile-list"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
@@ -108,10 +96,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="dashPageInfo"></small>
|
||||
<div id="dashPagination" class="d-flex align-items-center gap-2"></div>
|
||||
</div>
|
||||
<div id="dashPagination" class="pag-strip pag-strip-bottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- Order Detail Modal -->
|
||||
@@ -193,5 +178,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/dashboard.js?v=5"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,26 +5,17 @@
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Jurnale Import</h4>
|
||||
|
||||
<!-- Sync Run Selector -->
|
||||
<div class="card mb-4">
|
||||
<!-- Sync Run Selector + Status + Controls (single card) -->
|
||||
<div class="card mb-3">
|
||||
<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>
|
||||
<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>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary text-nowrap" onclick="loadRuns()" title="Reincarca lista"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Viewer (shown when run selected) -->
|
||||
<div id="logViewerSection" style="display:none;">
|
||||
<!-- Filter bar -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Run: <code id="logRunId"></code> <span class="badge bg-secondary" id="logStatusBadge">-</span></span>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<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>
|
||||
@@ -33,31 +24,45 @@
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Detail Viewer (shown when run selected) -->
|
||||
<div id="logViewerSection" style="display:none;">
|
||||
<!-- Filter pills -->
|
||||
<div class="filter-bar mb-3" id="orderFilterPills">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="d-md-none mb-2" id="logsMobileSeg"></div>
|
||||
|
||||
<!-- Orders table -->
|
||||
<div class="card mb-3">
|
||||
<div id="ordersPaginationTop" class="pag-strip"></div>
|
||||
<div class="card-body p-0">
|
||||
<div id="logsMobileList" class="mobile-list"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
@@ -76,10 +81,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="ordersPageInfo"></small>
|
||||
<div id="ordersPagination" class="d-flex align-items-center gap-2"></div>
|
||||
</div>
|
||||
<div id="ordersPagination" class="pag-strip pag-strip-bottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible text log -->
|
||||
@@ -173,5 +175,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/logs.js"></script>
|
||||
<script src="/static/js/logs.js?v=5"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,12 +5,23 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Mapari SKU</h4>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="downloadTemplate()"><i class="bi bi-file-earmark-arrow-down"></i> Template CSV</button>
|
||||
<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-primary" data-bs-toggle="modal" data-bs-target="#importModal"><i class="bi bi-upload"></i> Import 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-secondary" data-bs-toggle="modal" data-bs-target="#addModal"><i class="bi bi-box-arrow-up-right"></i> Formular complet</button>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<!-- Desktop buttons -->
|
||||
<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-secondary d-none d-md-inline-flex" onclick="exportCsv()"><i class="bi bi-download"></i> Export CSV</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-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>
|
||||
|
||||
@@ -38,41 +49,23 @@
|
||||
|
||||
<!-- Percentage filter pills -->
|
||||
<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" data-pct="complete">Complete ✓ <span class="filter-count" id="mCntComplete">0</span></button>
|
||||
<button class="filter-pill" data-pct="incomplete">Incomplete ⚠ <span class="filter-count" id="mCntIncomplete">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 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 d-none d-md-inline-flex" data-pct="incomplete">Incomplete <span class="filter-count fc-yellow" id="mCntIncomplete">0</span></button>
|
||||
</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-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<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 id="mappingsFlatList" class="mappings-flat-list">
|
||||
<div class="flat-row text-muted py-4 justify-content-center">Se incarca...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted" id="pageInfo"></small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0" id="pagination"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div id="mappingsPagBottom" class="pag-strip pag-strip-bottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal with multi-CODMAT support (R11) -->
|
||||
@@ -161,5 +154,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/mappings.js"></script>
|
||||
<script src="/static/js/mappings.js?v=5"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,63 +5,65 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">SKU-uri Lipsa</h4>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportMissingCsv()">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" onclick="exportMissingCsv()">
|
||||
<i class="bi bi-download"></i> Export CSV
|
||||
</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>
|
||||
|
||||
<!-- Unified filter bar -->
|
||||
<div class="filter-bar" id="skusFilterBar">
|
||||
<button class="filter-pill active" data-sku-status="unresolved">
|
||||
Nerezolvate <span class="filter-count" id="cntUnres">0</span>
|
||||
<button class="filter-pill active d-none d-md-inline-flex" data-sku-status="unresolved">
|
||||
Nerezolvate <span class="filter-count fc-yellow" id="cntUnres">0</span>
|
||||
</button>
|
||||
<button class="filter-pill" data-sku-status="resolved">
|
||||
Rezolvate <span class="filter-count" id="cntRes">0</span>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-sku-status="resolved">
|
||||
Rezolvate <span class="filter-count fc-green" id="cntRes">0</span>
|
||||
</button>
|
||||
<button class="filter-pill" data-sku-status="all">
|
||||
Toate <span class="filter-count" id="cntAllSkus">0</span>
|
||||
<button class="filter-pill d-none d-md-inline-flex" data-sku-status="all">
|
||||
Toate <span class="filter-count fc-neutral" id="cntAllSkus">0</span>
|
||||
</button>
|
||||
<input type="search" id="skuSearch" placeholder="Cauta SKU / produs..." class="search-input">
|
||||
<button id="rescanBtn" class="btn btn-sm btn-secondary ms-2">↻ Re-scan</button>
|
||||
<button id="rescanBtn" class="btn btn-sm btn-secondary ms-2 d-none d-md-inline-flex">↻ Re-scan</button>
|
||||
<span id="rescanProgress" class="align-items-center gap-2 text-primary" style="display:none;">
|
||||
<span class="sync-live-dot"></span>
|
||||
<span id="rescanProgressText">Scanare...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-md-none mb-2" id="skusMobileSeg"></div>
|
||||
<!-- Result banner -->
|
||||
<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-body p-0">
|
||||
<div id="missingMobileList" class="mobile-list"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>SKU</th>
|
||||
<th>Produs</th>
|
||||
<th>Nr. Comenzi</th>
|
||||
<th>Client</th>
|
||||
<th>First Seen</th>
|
||||
<th>Status</th>
|
||||
<th>Actiune</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted" id="missingInfo"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="paginationNav" class="mt-3">
|
||||
<ul class="pagination justify-content-center" id="paginationControls"></ul>
|
||||
</nav>
|
||||
<div id="skusPagBottom" class="pag-strip pag-strip-bottom"></div>
|
||||
|
||||
<!-- Map SKU Modal with multi-CODMAT support (R11) -->
|
||||
<div class="modal fade" id="mapModal" tabindex="-1">
|
||||
@@ -98,7 +100,9 @@ let currentMapSku = '';
|
||||
let mapAcTimeout = null;
|
||||
let currentPage = 1;
|
||||
let skuStatusFilter = 'unresolved';
|
||||
const perPage = 20;
|
||||
let missingPerPage = 20;
|
||||
|
||||
function missingChangePerPage(val) { missingPerPage = parseInt(val) || 20; currentPage = 1; loadMissingSkus(); }
|
||||
|
||||
// ── Filter pills ──────────────────────────────────
|
||||
document.querySelectorAll('.filter-pill[data-sku-status]').forEach(btn => {
|
||||
@@ -158,7 +162,7 @@ function loadMissingSkus(page) {
|
||||
const resolvedVal = resolvedParamFor(skuStatusFilter);
|
||||
params.set('resolved', resolvedVal);
|
||||
params.set('page', currentPage);
|
||||
params.set('per_page', perPage);
|
||||
params.set('per_page', missingPerPage);
|
||||
const search = document.getElementById('skuSearch')?.value?.trim();
|
||||
if (search) params.set('search', search);
|
||||
|
||||
@@ -170,12 +174,27 @@ function loadMissingSkus(page) {
|
||||
if (el('cntUnres')) el('cntUnres').textContent = c.unresolved || 0;
|
||||
if (el('cntRes')) el('cntRes').textContent = c.resolved || 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);
|
||||
renderPagination(data);
|
||||
})
|
||||
.catch(err => {
|
||||
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) {
|
||||
const tbody = document.getElementById('missingBody');
|
||||
if (data) {
|
||||
document.getElementById('missingInfo').textContent =
|
||||
`Total: ${data.total || 0} | Pagina: ${data.page || 1} din ${data.pages || 1}`;
|
||||
}
|
||||
const mobileList = document.getElementById('missingMobileList');
|
||||
|
||||
if (!skus || skus.length === 0) {
|
||||
const msg = skuStatusFilter === 'unresolved' ? 'Toate SKU-urile sunt mapate!' :
|
||||
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;
|
||||
}
|
||||
|
||||
tbody.innerHTML = skus.map(s => {
|
||||
const statusBadge = s.resolved
|
||||
? '<span class="badge bg-success">Rezolvat</span>'
|
||||
: '<span class="badge bg-warning">Nerezolvat</span>';
|
||||
|
||||
let firstCustomer = '-';
|
||||
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' : ''}">
|
||||
const trAttrs = !s.resolved
|
||||
? ` style="cursor:pointer" onclick="openMapModal('${esc(s.sku)}', '${esc(s.product_name || '')}')"`
|
||||
: '';
|
||||
return `<tr${trAttrs}>
|
||||
<td>${s.resolved ? '<span class="dot dot-green"></span>' : '<span class="dot dot-yellow"></span>'}</td>
|
||||
<td><code>${esc(s.sku)}</code></td>
|
||||
<td>${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 class="truncate" style="max-width:300px">${esc(s.product_name || '-')}</td>
|
||||
<td>
|
||||
${!s.resolved
|
||||
? `<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>
|
||||
</tr>`;
|
||||
}).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) {
|
||||
const ul = document.getElementById('paginationControls');
|
||||
const total = data.pages || 1;
|
||||
const page = data.page || 1;
|
||||
if (total <= 1) { ul.innerHTML = ''; return; }
|
||||
|
||||
let html = '';
|
||||
html += `<li class="page-item ${page <= 1 ? 'disabled' : ''}">
|
||||
<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;
|
||||
const pagOpts = { perPage: missingPerPage, perPageFn: 'missingChangePerPage', perPageOptions: [20, 50, 100] };
|
||||
const infoHtml = `<small class="text-muted me-auto">Total: ${data.total || 0} | Pagina ${data.page || 1} din ${data.pages || 1}</small>`;
|
||||
const pagHtml = infoHtml + renderUnifiedPagination(data.page || 1, data.pages || 1, 'loadMissing', pagOpts);
|
||||
const top = document.getElementById('skusPagTop');
|
||||
const bot = document.getElementById('skusPagBottom');
|
||||
if (top) top.innerHTML = pagHtml;
|
||||
if (bot) bot.innerHTML = pagHtml;
|
||||
}
|
||||
|
||||
// ── Multi-CODMAT Map Modal ───────────────────────
|
||||
@@ -384,9 +391,5 @@ function exportMissingCsv() {
|
||||
window.location.href = '/api/validate/missing-skus-csv';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user